three-rb 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +19 -0
  3. data/LICENSE +21 -0
  4. data/README.md +153 -0
  5. data/docs/browser-runtime.md +153 -0
  6. data/docs/implementation-plan.md +874 -0
  7. data/docs/loaded-assets-design.md +400 -0
  8. data/docs/next-work.md +107 -0
  9. data/docs/publishing.md +64 -0
  10. data/docs/release-readiness.md +83 -0
  11. data/examples/browser/README.md +54 -0
  12. data/examples/browser/assets/animated_triangle.gltf +123 -0
  13. data/examples/browser/assets/checker.svg +11 -0
  14. data/examples/browser/assets/compressed_triangle.gltf +74 -0
  15. data/examples/browser/assets/studio.hdr +5 -0
  16. data/examples/browser/assets/triangle.gltf +67 -0
  17. data/examples/browser/composition/README.md +35 -0
  18. data/examples/browser/composition/boot.mjs +6 -0
  19. data/examples/browser/composition/index.html +136 -0
  20. data/examples/browser/composition/main.rb +216 -0
  21. data/examples/browser/composition/smoke_test.mjs +266 -0
  22. data/examples/browser/cube/README.md +41 -0
  23. data/examples/browser/cube/boot.mjs +6 -0
  24. data/examples/browser/cube/index.html +142 -0
  25. data/examples/browser/cube/main.rb +62 -0
  26. data/examples/browser/cube/smoke_test.mjs +99 -0
  27. data/examples/browser/cubemap/README.md +23 -0
  28. data/examples/browser/cubemap/boot.mjs +6 -0
  29. data/examples/browser/cubemap/index.html +142 -0
  30. data/examples/browser/cubemap/main.rb +84 -0
  31. data/examples/browser/cubemap/smoke_test.mjs +91 -0
  32. data/examples/browser/gltf/README.md +23 -0
  33. data/examples/browser/gltf/boot.mjs +6 -0
  34. data/examples/browser/gltf/index.html +142 -0
  35. data/examples/browser/gltf/main.rb +105 -0
  36. data/examples/browser/gltf/smoke_test.mjs +162 -0
  37. data/examples/browser/picking/README.md +33 -0
  38. data/examples/browser/picking/boot.mjs +6 -0
  39. data/examples/browser/picking/index.html +142 -0
  40. data/examples/browser/picking/main.rb +113 -0
  41. data/examples/browser/picking/smoke_test.mjs +78 -0
  42. data/examples/browser/postprocessing/README.md +26 -0
  43. data/examples/browser/postprocessing/boot.mjs +6 -0
  44. data/examples/browser/postprocessing/index.html +142 -0
  45. data/examples/browser/postprocessing/main.rb +117 -0
  46. data/examples/browser/postprocessing/smoke_test.mjs +121 -0
  47. data/examples/browser/primitives/README.md +33 -0
  48. data/examples/browser/primitives/boot.mjs +6 -0
  49. data/examples/browser/primitives/index.html +142 -0
  50. data/examples/browser/primitives/main.rb +116 -0
  51. data/examples/browser/primitives/smoke_test.mjs +113 -0
  52. data/examples/browser/serialization/README.md +33 -0
  53. data/examples/browser/serialization/boot.mjs +6 -0
  54. data/examples/browser/serialization/index.html +142 -0
  55. data/examples/browser/serialization/main.rb +97 -0
  56. data/examples/browser/serialization/smoke_test.mjs +67 -0
  57. data/examples/browser/shared/boot.mjs +79 -0
  58. data/examples/browser/shared/smoke_test_helpers.mjs +151 -0
  59. data/examples/browser/textures/README.md +35 -0
  60. data/examples/browser/textures/boot.mjs +6 -0
  61. data/examples/browser/textures/index.html +142 -0
  62. data/examples/browser/textures/main.rb +142 -0
  63. data/examples/browser/textures/smoke_test.mjs +189 -0
  64. data/lib/three/animation/animation_action.rb +57 -0
  65. data/lib/three/animation/animation_clip.rb +22 -0
  66. data/lib/three/animation/animation_mixer.rb +43 -0
  67. data/lib/three/backends/base.rb +87 -0
  68. data/lib/three/backends/threejs/materialization.rb +143 -0
  69. data/lib/three/backends/threejs/parameters.rb +97 -0
  70. data/lib/three/backends/threejs/resource_management.rb +69 -0
  71. data/lib/three/backends/threejs/ruby_wasm_adapter.rb +873 -0
  72. data/lib/three/backends/threejs/synchronization.rb +224 -0
  73. data/lib/three/backends/threejs.rb +365 -0
  74. data/lib/three/cameras/camera.rb +39 -0
  75. data/lib/three/cameras/orthographic_camera.rb +107 -0
  76. data/lib/three/cameras/perspective_camera.rb +137 -0
  77. data/lib/three/constants.rb +40 -0
  78. data/lib/three/controls/orbit_controls.rb +118 -0
  79. data/lib/three/core/buffer_attribute.rb +151 -0
  80. data/lib/three/core/buffer_geometry.rb +181 -0
  81. data/lib/three/core/clock.rb +58 -0
  82. data/lib/three/core/event_dispatcher.rb +57 -0
  83. data/lib/three/core/layers.rb +75 -0
  84. data/lib/three/core/object3d.rb +331 -0
  85. data/lib/three/core/raycaster.rb +73 -0
  86. data/lib/three/dirty.rb +58 -0
  87. data/lib/three/exporters/three_json_exporter.rb +187 -0
  88. data/lib/three/geometries/box_geometry.rb +97 -0
  89. data/lib/three/geometries/plane_geometry.rb +70 -0
  90. data/lib/three/geometries/sphere_geometry.rb +107 -0
  91. data/lib/three/lights/ambient_light.rb +12 -0
  92. data/lib/three/lights/directional_light.rb +38 -0
  93. data/lib/three/lights/hemisphere_light.rb +34 -0
  94. data/lib/three/lights/light.rb +85 -0
  95. data/lib/three/lights/point_light.rb +33 -0
  96. data/lib/three/loaders/cube_texture_loader.rb +13 -0
  97. data/lib/three/loaders/gltf_loader.rb +48 -0
  98. data/lib/three/loaders/rgbe_loader.rb +15 -0
  99. data/lib/three/loaders/texture_loader.rb +13 -0
  100. data/lib/three/loaders/three_json_loader.rb +409 -0
  101. data/lib/three/materials/line_basic_material.rb +65 -0
  102. data/lib/three/materials/material.rb +158 -0
  103. data/lib/three/materials/mesh_basic_material.rb +64 -0
  104. data/lib/three/materials/mesh_lambert_material.rb +71 -0
  105. data/lib/three/materials/mesh_matcap_material.rb +86 -0
  106. data/lib/three/materials/mesh_normal_material.rb +42 -0
  107. data/lib/three/materials/mesh_phong_material.rb +119 -0
  108. data/lib/three/materials/mesh_physical_material.rb +155 -0
  109. data/lib/three/materials/mesh_standard_material.rb +149 -0
  110. data/lib/three/materials/mesh_toon_material.rb +98 -0
  111. data/lib/three/materials/points_material.rb +74 -0
  112. data/lib/three/materials/shadow_material.rb +45 -0
  113. data/lib/three/materials/sprite_material.rb +75 -0
  114. data/lib/three/math/color.rb +133 -0
  115. data/lib/three/math/euler.rb +197 -0
  116. data/lib/three/math/math_utils.rb +36 -0
  117. data/lib/three/math/matrix3.rb +255 -0
  118. data/lib/three/math/matrix4.rb +448 -0
  119. data/lib/three/math/quaternion.rb +277 -0
  120. data/lib/three/math/vector2.rb +95 -0
  121. data/lib/three/math/vector3.rb +396 -0
  122. data/lib/three/objects/external_object3d.rb +28 -0
  123. data/lib/three/objects/group.rb +12 -0
  124. data/lib/three/objects/instanced_mesh.rb +110 -0
  125. data/lib/three/objects/line.rb +41 -0
  126. data/lib/three/objects/mesh.rb +45 -0
  127. data/lib/three/objects/points.rb +41 -0
  128. data/lib/three/objects/sprite.rb +57 -0
  129. data/lib/three/postprocessing/dot_screen_pass.rb +83 -0
  130. data/lib/three/postprocessing/effect_composer.rb +56 -0
  131. data/lib/three/postprocessing/output_pass.rb +40 -0
  132. data/lib/three/postprocessing/render_pass.rb +42 -0
  133. data/lib/three/postprocessing/unreal_bloom_pass.rb +56 -0
  134. data/lib/three/renderers/renderer.rb +11 -0
  135. data/lib/three/renderers/threejs_renderer.rb +85 -0
  136. data/lib/three/scenes/scene.rb +29 -0
  137. data/lib/three/textures/cube_texture.rb +72 -0
  138. data/lib/three/textures/rgbe_texture.rb +45 -0
  139. data/lib/three/textures/texture.rb +200 -0
  140. data/lib/three/version.rb +5 -0
  141. data/lib/three-rb.rb +3 -0
  142. data/lib/three.rb +77 -0
  143. data/package.json +30 -0
  144. data/pnpm-lock.yaml +86 -0
  145. metadata +216 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "js"
4
+
5
+ begin
6
+ JS.global[:__threeReady].await
7
+
8
+ require_relative "../../../lib/three"
9
+
10
+ document = JS.global[:document]
11
+ window = JS.global[:window]
12
+ viewport = document.call(:querySelector, "#viewport")
13
+ status = document.call(:querySelector, "#status")
14
+ status_dot = document.call(:querySelector, "#status-dot")
15
+ status[:textContent] = "Starting Ruby scene"
16
+
17
+ scene = Three::Scene.new
18
+ camera = Three::PerspectiveCamera.new(45, aspect: 1.0, near: 0.1, far: 100)
19
+ camera.position.set(0, 0.15, 4.0)
20
+
21
+ scene.add(Three::AmbientLight.new(0xffffff, 0.65))
22
+
23
+ key_light = Three::DirectionalLight.new(0xffffff, 1.0)
24
+ key_light.position.set(2.5, 3.0, 4.0)
25
+ scene.add(key_light)
26
+
27
+ renderer = Three::Renderers::ThreeJSRenderer.new(
28
+ canvas: "#scene",
29
+ antialias: true,
30
+ alpha: false,
31
+ preserveDrawingBuffer: true
32
+ )
33
+ renderer.set_clear_color(0x11151a, 1)
34
+
35
+ gltf = Three::Loaders::GLTFLoader.new(backend: renderer.backend).load("/examples/browser/assets/animated_triangle.gltf")
36
+ model = gltf.scene
37
+ model.position.x = -0.75
38
+ model.scale.set(1.2, 1.2, 1.2)
39
+ scene.add(model)
40
+
41
+ draco_decoder_path = "/node_modules/three/examples/jsm/libs/draco/gltf/"
42
+ compressed_gltf = Three::Loaders::GLTFLoader.new(
43
+ backend: renderer.backend,
44
+ draco_decoder_path: draco_decoder_path
45
+ ).load("/examples/browser/assets/compressed_triangle.gltf")
46
+ compressed_model = compressed_gltf.scene
47
+ compressed_model.position.x = 1.05
48
+ compressed_model.scale.set(0.82, 0.82, 0.82)
49
+ scene.add(compressed_model)
50
+
51
+ clock = Three::Clock.new
52
+ mixer = Three::AnimationMixer.new(model, backend: renderer.backend)
53
+ action = mixer.clip_action(gltf.animations.first)
54
+ action.play
55
+
56
+ resize = proc do
57
+ width = [viewport[:clientWidth].to_i, 1].max
58
+ height = [viewport[:clientHeight].to_i, 1].max
59
+
60
+ camera.aspect = width.to_f / height
61
+ camera.update_projection_matrix
62
+ renderer.set_size(width, height)
63
+ end
64
+
65
+ resize.call
66
+ window.call(:addEventListener, "resize", resize)
67
+ renderer.render(scene, camera)
68
+
69
+ JS.global[:__threeRbRenderer] = renderer.handle
70
+ JS.global[:__threeRbGltfRootScene] = renderer.backend.materialize(scene)
71
+ JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
72
+ JS.global[:__threeRbGltfScene] = renderer.backend.materialize(model)
73
+ JS.global[:__threeRbCompressedGltfScene] = renderer.backend.materialize(compressed_model)
74
+ JS.global[:__threeRbCompressedGltfDecoderPath] = draco_decoder_path
75
+ JS.global[:__threeRbGltfAnimations] = gltf.animations.length
76
+ JS.global[:__threeRbGltfAnimationName] = gltf.animations.first&.name
77
+ JS.global[:__threeRbGltfAnimationDuration] = gltf.animations.first&.duration
78
+ JS.global[:__threeRbGltfMixer] = mixer.handle
79
+ JS.global[:__threeRbGltfAction] = action.handle
80
+ JS.global[:__threeRbGltfFrame] = 0
81
+ JS.global[:__threeRbGltfAnimationTime] = 0
82
+ JS.global[:__threeRbDisposeGltf] = proc do
83
+ mixer.stop_all_action
84
+ mixer.uncache_root
85
+ renderer.dispose_subtree(model, remove: true, dispose_textures: true)
86
+ renderer.dispose_subtree(compressed_model, remove: true, dispose_textures: true)
87
+ JS.global[:__threeRbGltfDisposed] = true
88
+ end
89
+
90
+ frame = 0
91
+ renderer.animation_loop do
92
+ frame += 1
93
+ delta = clock.get_delta
94
+ mixer.update(delta)
95
+ JS.global[:__threeRbGltfAnimationTime] = JS.global[:__threeRbGltfAnimationTime].to_f + delta
96
+ JS.global[:__threeRbGltfFrame] = frame
97
+ renderer.render(scene, camera)
98
+ end
99
+
100
+ status[:textContent] = "Running"
101
+ status_dot[:dataset][:state] = "running"
102
+ rescue StandardError => error
103
+ JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
104
+ raise
105
+ end
@@ -0,0 +1,162 @@
1
+ import {
2
+ assertNoDiagnostics,
3
+ assertNonBlankCanvas,
4
+ createDiagnostics,
5
+ createSmokePage,
6
+ loadPlaywright,
7
+ sampleCanvas,
8
+ startServer,
9
+ waitForRunning
10
+ } from "../shared/smoke_test_helpers.mjs";
11
+
12
+ async function main() {
13
+ const { chromium } = await loadPlaywright();
14
+ const server = await startServer();
15
+ const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
16
+ const diagnostics = createDiagnostics();
17
+
18
+ try {
19
+ const page = await createSmokePage(browser, diagnostics);
20
+
21
+ await page.goto(`${server.url}/examples/browser/gltf/`, { waitUntil: "load" });
22
+ await waitForRunning(page, diagnostics);
23
+ await page.waitForFunction(
24
+ () => globalThis.__threeRbGltfScene?.children?.length > 0 &&
25
+ globalThis.__threeRbCompressedGltfScene?.children?.length > 0,
26
+ null,
27
+ { timeout: 10_000 }
28
+ );
29
+ await page.waitForTimeout(1_000);
30
+
31
+ assertNonBlankCanvas(await sampleCanvas(page));
32
+
33
+ const scene = await page.evaluate(() => ({
34
+ frame: globalThis.__threeRbGltfFrame,
35
+ animationTime: globalThis.__threeRbGltfAnimationTime,
36
+ animationCount: globalThis.__threeRbGltfAnimations,
37
+ animationName: globalThis.__threeRbGltfAnimationName,
38
+ animationDuration: globalThis.__threeRbGltfAnimationDuration,
39
+ actionTime: globalThis.__threeRbGltfAction?.time,
40
+ animatedNodeQuaternion: globalThis.__threeRbGltfScene?.children?.[0]?.quaternion?.toArray?.(),
41
+ renderInfo: globalThis.__threeRbRenderer?.info?.render,
42
+ cameraType: globalThis.__threeRbCamera?.type,
43
+ rootChildren: globalThis.__threeRbGltfRootScene?.children?.length,
44
+ gltfType: globalThis.__threeRbGltfScene?.type,
45
+ gltfIsObject3D: globalThis.__threeRbGltfScene?.isObject3D,
46
+ gltfChildren: globalThis.__threeRbGltfScene?.children?.length,
47
+ compressedGltfType: globalThis.__threeRbCompressedGltfScene?.type,
48
+ compressedGltfIsObject3D: globalThis.__threeRbCompressedGltfScene?.isObject3D,
49
+ compressedGltfChildren: globalThis.__threeRbCompressedGltfScene?.children?.length,
50
+ compressedGeometryAttributes: Object.keys(globalThis.__threeRbCompressedGltfScene?.children?.[0]?.geometry?.attributes || {}),
51
+ compressedDecoderPath: globalThis.__threeRbCompressedGltfDecoderPath
52
+ }));
53
+
54
+ if (scene.cameraType !== "PerspectiveCamera") {
55
+ throw new Error(`expected a PerspectiveCamera glTF view: ${JSON.stringify(scene)}`);
56
+ }
57
+ if (scene.gltfIsObject3D !== true || scene.gltfChildren < 1) {
58
+ throw new Error(`expected a loaded glTF Object3D scene: ${JSON.stringify(scene)}`);
59
+ }
60
+ if (scene.compressedGltfIsObject3D !== true || scene.compressedGltfChildren < 1) {
61
+ throw new Error(`expected a Draco-compressed glTF Object3D scene: ${JSON.stringify(scene)}`);
62
+ }
63
+ if (!scene.compressedGeometryAttributes.includes("position") || !scene.compressedDecoderPath.endsWith("/draco/gltf/")) {
64
+ throw new Error(`Draco-compressed glTF geometry was not decoded: ${JSON.stringify(scene)}`);
65
+ }
66
+ if (!scene.renderInfo || scene.renderInfo.triangles < 1) {
67
+ throw new Error(`renderer did not draw the glTF triangle: ${JSON.stringify(scene)}`);
68
+ }
69
+ if (!scene.frame) {
70
+ throw new Error(`glTF example animation did not advance: ${JSON.stringify(scene)}`);
71
+ }
72
+ if (scene.animationCount !== 1 || scene.animationName !== "TriangleSpin" || scene.animationDuration !== 2) {
73
+ throw new Error(`glTF animation metadata was not exposed: ${JSON.stringify(scene)}`);
74
+ }
75
+ if (!(scene.animationTime > 0) || !(scene.actionTime > 0)) {
76
+ throw new Error(`AnimationMixer did not advance the glTF action: ${JSON.stringify(scene)}`);
77
+ }
78
+ if (!scene.animatedNodeQuaternion || Math.abs(scene.animatedNodeQuaternion[1]) < 0.05) {
79
+ throw new Error(`glTF animated node quaternion did not change: ${JSON.stringify(scene)}`);
80
+ }
81
+
82
+ const disposal = await page.evaluate(() => {
83
+ const root = globalThis.__threeRbGltfScene;
84
+ const compressedRoot = globalThis.__threeRbCompressedGltfScene;
85
+ const rootScene = globalThis.__threeRbGltfRootScene;
86
+ const stats = {
87
+ geometries: 0,
88
+ materials: 0,
89
+ textures: 0,
90
+ geometryDisposeEvents: 0,
91
+ materialDisposeEvents: 0,
92
+ textureDisposeEvents: 0
93
+ };
94
+
95
+ [root, compressedRoot].forEach((loadedRoot) => loadedRoot.traverse((object) => {
96
+ if (object.geometry) {
97
+ stats.geometries += 1;
98
+ object.geometry.addEventListener("dispose", () => {
99
+ stats.geometryDisposeEvents += 1;
100
+ });
101
+ }
102
+
103
+ const materials = Array.isArray(object.material) ? object.material : [object.material].filter(Boolean);
104
+ for (const material of materials) {
105
+ stats.materials += 1;
106
+ material.addEventListener("dispose", () => {
107
+ stats.materialDisposeEvents += 1;
108
+ });
109
+
110
+ if (material.map) {
111
+ stats.textures += 1;
112
+ material.map.addEventListener("dispose", () => {
113
+ stats.textureDisposeEvents += 1;
114
+ });
115
+ }
116
+ }
117
+ }));
118
+
119
+ globalThis.__threeRbDisposeGltf();
120
+
121
+ return {
122
+ ...stats,
123
+ disposed: globalThis.__threeRbGltfDisposed,
124
+ rootParent: root.parent?.type ?? null,
125
+ compressedRootParent: compressedRoot.parent?.type ?? null,
126
+ rootSceneStillContainsRoot: rootScene.children.includes(root),
127
+ rootSceneStillContainsCompressedRoot: rootScene.children.includes(compressedRoot)
128
+ };
129
+ });
130
+
131
+ if (
132
+ disposal.disposed !== true ||
133
+ disposal.rootParent !== null ||
134
+ disposal.compressedRootParent !== null ||
135
+ disposal.rootSceneStillContainsRoot ||
136
+ disposal.rootSceneStillContainsCompressedRoot
137
+ ) {
138
+ throw new Error(`dispose_subtree did not detach the glTF root: ${JSON.stringify(disposal)}`);
139
+ }
140
+ if (disposal.geometries < 1 || disposal.geometryDisposeEvents !== disposal.geometries) {
141
+ throw new Error(`dispose_subtree did not dispose loaded geometries: ${JSON.stringify(disposal)}`);
142
+ }
143
+ if (disposal.materials < 1 || disposal.materialDisposeEvents !== disposal.materials) {
144
+ throw new Error(`dispose_subtree did not dispose loaded materials: ${JSON.stringify(disposal)}`);
145
+ }
146
+ if (disposal.textureDisposeEvents !== disposal.textures) {
147
+ throw new Error(`dispose_subtree did not dispose loaded textures: ${JSON.stringify(disposal)}`);
148
+ }
149
+
150
+ assertNoDiagnostics(diagnostics);
151
+
152
+ console.log(`glTF smoke test passed at ${server.url}/examples/browser/gltf/`);
153
+ } finally {
154
+ await browser.close();
155
+ await new Promise((resolve) => server.instance.close(resolve));
156
+ }
157
+ }
158
+
159
+ main().catch((error) => {
160
+ console.error(error);
161
+ process.exitCode = 1;
162
+ });
@@ -0,0 +1,33 @@
1
+ # Browser Picking Example
2
+
3
+ This example uses `Three::Raycaster` to pick Ruby-authored meshes from browser click coordinates, map three.js intersections back to Ruby `Object3D` instances, and update the selected mesh material.
4
+
5
+ Install browser dependencies and serve the repository root over HTTP:
6
+
7
+ ```sh
8
+ pnpm install
9
+ ruby -run -e httpd . -p 8000
10
+ ```
11
+
12
+ Then open:
13
+
14
+ ```text
15
+ http://localhost:8000/examples/browser/picking/
16
+ ```
17
+
18
+ The repository root must be served, not only this directory, because `boot.mjs` loads browser packages from `node_modules/` and `main.rb` loads the library source from `lib/`.
19
+
20
+ ## Browser Smoke Test
21
+
22
+ Install the optional Node dependency and browser binary:
23
+
24
+ ```sh
25
+ pnpm install
26
+ pnpm exec playwright install chromium
27
+ ```
28
+
29
+ Run the browser smoke test:
30
+
31
+ ```sh
32
+ pnpm test:browser:picking
33
+ ```
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/picking/main",
5
+ clearColor: 0x101418
6
+ });
@@ -0,0 +1,142 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>three-rb picking</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ font-family:
11
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
12
+ "Segoe UI", sans-serif;
13
+ background: #101418;
14
+ color: #eef3f7;
15
+ }
16
+
17
+ * {
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ html,
22
+ body {
23
+ width: 100%;
24
+ height: 100%;
25
+ margin: 0;
26
+ }
27
+
28
+ body {
29
+ display: grid;
30
+ place-items: stretch;
31
+ overflow: hidden;
32
+ }
33
+
34
+ .viewport {
35
+ position: relative;
36
+ width: 100%;
37
+ height: 100%;
38
+ min-width: 320px;
39
+ min-height: 320px;
40
+ background:
41
+ linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
42
+ linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
43
+ linear-gradient(135deg, #101418 0%, #172026 52%, #0f1215 100%);
44
+ background-size: 32px 32px, 32px 32px, auto;
45
+ }
46
+
47
+ canvas {
48
+ display: block;
49
+ width: 100%;
50
+ height: 100%;
51
+ }
52
+
53
+ .hud {
54
+ position: absolute;
55
+ top: 16px;
56
+ left: 16px;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 8px;
60
+ max-width: calc(100% - 32px);
61
+ padding: 8px 10px;
62
+ border: 1px solid rgba(255, 255, 255, 0.14);
63
+ border-radius: 6px;
64
+ background: rgba(10, 14, 18, 0.72);
65
+ box-shadow: 0 12px 30px rgba(0, 0, 0, 0.24);
66
+ color: #dce7ef;
67
+ font-size: 13px;
68
+ line-height: 1.3;
69
+ backdrop-filter: blur(10px);
70
+ }
71
+
72
+ .status-dot {
73
+ width: 8px;
74
+ height: 8px;
75
+ flex: 0 0 auto;
76
+ border-radius: 999px;
77
+ background: #f2b84b;
78
+ box-shadow: 0 0 14px rgba(242, 184, 75, 0.72);
79
+ }
80
+
81
+ .status-dot[data-state="running"] {
82
+ background: #4ed08f;
83
+ box-shadow: 0 0 14px rgba(78, 208, 143, 0.72);
84
+ }
85
+
86
+ .status-dot[data-state="error"] {
87
+ background: #f15b5b;
88
+ box-shadow: 0 0 14px rgba(241, 91, 91, 0.72);
89
+ }
90
+ </style>
91
+ <script type="importmap">
92
+ {
93
+ "imports": {
94
+ "@bjorn3/browser_wasi_shim": "/node_modules/@bjorn3/browser_wasi_shim/dist/index.js",
95
+ "@ruby/wasm-wasi/browser": "/node_modules/@ruby/wasm-wasi/dist/esm/browser.js",
96
+ "three": "/node_modules/three/build/three.module.js",
97
+ "three/addons/": "/node_modules/three/examples/jsm/"
98
+ }
99
+ }
100
+ </script>
101
+ <script type="module">
102
+ const status = document.querySelector("#status");
103
+ const statusDot = document.querySelector("#status-dot");
104
+
105
+ globalThis.__threeRbSetStatus = (message, state) => {
106
+ if (status) status.textContent = message;
107
+ if (statusDot) statusDot.dataset.state = state;
108
+ };
109
+
110
+ globalThis.__threeRbBootFailed = (message) => {
111
+ globalThis.__threeRbSetStatus(message, "error");
112
+ };
113
+
114
+ globalThis.addEventListener("error", (event) => {
115
+ globalThis.__threeRbBootFailed(event.message || "Browser error");
116
+ });
117
+
118
+ globalThis.addEventListener("unhandledrejection", (event) => {
119
+ const reason = event.reason;
120
+ globalThis.__threeRbBootFailed(reason && reason.message ? reason.message : "Ruby boot failed");
121
+ });
122
+
123
+ globalThis.setTimeout(() => {
124
+ if (status && status.textContent === "Loading ruby.wasm") {
125
+ status.textContent = "Still loading ruby.wasm";
126
+ }
127
+ }, 15000);
128
+
129
+ globalThis.__threeRbSetStatus("Loading ruby.wasm", "loading");
130
+ </script>
131
+ <script type="module" src="./boot.mjs"></script>
132
+ </head>
133
+ <body>
134
+ <main class="viewport" id="viewport">
135
+ <canvas id="scene" data-testid="scene-canvas"></canvas>
136
+ <div class="hud" aria-live="polite">
137
+ <span class="status-dot" id="status-dot"></span>
138
+ <span id="status" data-testid="status">Loading ruby.wasm</span>
139
+ </div>
140
+ </main>
141
+ </body>
142
+ </html>
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "js"
4
+
5
+ begin
6
+ JS.global[:__threeReady].await
7
+
8
+ require_relative "../../../lib/three"
9
+
10
+ document = JS.global[:document]
11
+ window = JS.global[:window]
12
+ viewport = document.call(:querySelector, "#viewport")
13
+ status = document.call(:querySelector, "#status")
14
+ status_dot = document.call(:querySelector, "#status-dot")
15
+ status[:textContent] = "Starting picking scene"
16
+
17
+ scene = Three::Scene.new
18
+ camera = Three::PerspectiveCamera.new(60, aspect: 1.0, near: 0.1, far: 100)
19
+ camera.position.z = 4
20
+
21
+ geometry = Three::BoxGeometry.new(0.82, 0.82, 0.82)
22
+ idle_color = 0x4ed08f
23
+ picked_color = 0xffcc4d
24
+
25
+ left_material = Three::MeshBasicMaterial.new(color: idle_color)
26
+ right_material = Three::MeshBasicMaterial.new(color: idle_color)
27
+ left_cube = Three::Mesh.new(geometry, left_material)
28
+ left_cube.name = "left-cube"
29
+ left_cube.position.x = -0.75
30
+ right_cube = Three::Mesh.new(geometry, right_material)
31
+ right_cube.name = "right-cube"
32
+ right_cube.position.x = 0.75
33
+ scene.add(left_cube, right_cube)
34
+
35
+ renderer = Three::Renderers::ThreeJSRenderer.new(
36
+ canvas: "#scene",
37
+ antialias: true,
38
+ alpha: false,
39
+ preserveDrawingBuffer: true
40
+ )
41
+ renderer.set_clear_color(0x101418, 1)
42
+
43
+ resize = proc do
44
+ width = [viewport[:clientWidth].to_i, 1].max
45
+ height = [viewport[:clientHeight].to_i, 1].max
46
+
47
+ camera.aspect = width.to_f / height
48
+ camera.update_projection_matrix
49
+ renderer.set_size(width, height)
50
+ end
51
+
52
+ pointer = Three::Vector2.new
53
+ raycaster = Three::Raycaster.new(backend: renderer.backend)
54
+ pickables = [left_cube, right_cube]
55
+ selected = nil
56
+
57
+ pick = proc do |event|
58
+ rect = renderer.dom_element.call(:getBoundingClientRect)
59
+ x = ((event[:clientX].to_f - rect[:left].to_f) / rect[:width].to_f) * 2 - 1
60
+ y = -(((event[:clientY].to_f - rect[:top].to_f) / rect[:height].to_f) * 2 - 1)
61
+ pointer.set(x, y)
62
+ raycaster.set_from_camera(pointer, camera)
63
+ hits = raycaster.intersect_objects(pickables, recursive: false)
64
+ hit = hits.find(&:object)
65
+
66
+ if selected
67
+ selected.material.color.set_hex(idle_color)
68
+ selected = nil
69
+ end
70
+
71
+ if hit
72
+ selected = hit.object
73
+ selected.material.color.set_hex(picked_color)
74
+ JS.global[:__threeRbPickedName] = selected.name
75
+ JS.global[:__threeRbPickedDistance] = hit.distance
76
+ JS.global[:__threeRbPickedPoint] = hit.point.to_a
77
+ else
78
+ JS.global[:__threeRbPickedName] = nil
79
+ JS.global[:__threeRbPickedDistance] = nil
80
+ JS.global[:__threeRbPickedPoint] = nil
81
+ end
82
+
83
+ JS.global[:__threeRbPickCount] = JS.global[:__threeRbPickCount].to_i + 1
84
+ renderer.render(scene, camera)
85
+ end
86
+
87
+ resize.call
88
+ window.call(:addEventListener, "resize", resize)
89
+ renderer.dom_element.call(:addEventListener, "click", pick)
90
+ renderer.render(scene, camera)
91
+
92
+ JS.global[:__threeRbRenderer] = renderer.handle
93
+ JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
94
+ JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
95
+ JS.global[:__threeRbLeftCube] = renderer.backend.materialize(left_cube)
96
+ JS.global[:__threeRbRightCube] = renderer.backend.materialize(right_cube)
97
+ JS.global[:__threeRbRaycaster] = raycaster.handle
98
+ JS.global[:__threeRbPickCount] = 0
99
+ JS.global[:__threeRbPickingFrame] = 0
100
+
101
+ renderer.animation_loop do
102
+ JS.global[:__threeRbPickingFrame] = JS.global[:__threeRbPickingFrame].to_i + 1
103
+ left_cube.rotation.y += 0.01
104
+ right_cube.rotation.y -= 0.01
105
+ renderer.render(scene, camera)
106
+ end
107
+
108
+ status[:textContent] = "Running"
109
+ status_dot[:dataset][:state] = "running"
110
+ rescue StandardError => error
111
+ JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
112
+ raise
113
+ end
@@ -0,0 +1,78 @@
1
+ import {
2
+ assertNoDiagnostics,
3
+ assertNonBlankCanvas,
4
+ createDiagnostics,
5
+ createSmokePage,
6
+ loadPlaywright,
7
+ sampleCanvas,
8
+ startServer,
9
+ waitForRunning
10
+ } from "../shared/smoke_test_helpers.mjs";
11
+
12
+ async function main() {
13
+ const { chromium } = await loadPlaywright();
14
+ const server = await startServer();
15
+ const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
16
+ const diagnostics = createDiagnostics();
17
+
18
+ try {
19
+ const page = await createSmokePage(browser, diagnostics);
20
+
21
+ await page.goto(`${server.url}/examples/browser/picking/`, { waitUntil: "load" });
22
+ await waitForRunning(page, diagnostics);
23
+ await page.waitForTimeout(1_000);
24
+
25
+ assertNonBlankCanvas(await sampleCanvas(page));
26
+
27
+ const rect = await page.locator("[data-testid='scene-canvas']").boundingBox();
28
+ if (!rect) throw new Error("scene canvas bounding box is unavailable");
29
+
30
+ await page.mouse.click(rect.x + rect.width * 0.4, rect.y + rect.height * 0.5);
31
+ await page.waitForFunction(() => globalThis.__threeRbPickedName === "left-cube", null, { timeout: 5_000 });
32
+
33
+ const leftPick = await page.evaluate(() => ({
34
+ pickCount: globalThis.__threeRbPickCount,
35
+ pickedName: globalThis.__threeRbPickedName,
36
+ pickedDistance: globalThis.__threeRbPickedDistance,
37
+ pickedPoint: globalThis.__threeRbPickedPoint,
38
+ leftColor: globalThis.__threeRbLeftCube?.material?.color?.getHex?.(),
39
+ rightColor: globalThis.__threeRbRightCube?.material?.color?.getHex?.(),
40
+ renderInfo: globalThis.__threeRbRenderer?.info?.render
41
+ }));
42
+
43
+ if (leftPick.leftColor !== 0xffcc4d || leftPick.rightColor !== 0x4ed08f || !(leftPick.pickedDistance > 0)) {
44
+ throw new Error(`left pick did not update the expected mesh: ${JSON.stringify(leftPick)}`);
45
+ }
46
+
47
+ await page.mouse.click(rect.x + rect.width * 0.6, rect.y + rect.height * 0.5);
48
+ await page.waitForFunction(() => globalThis.__threeRbPickedName === "right-cube", null, { timeout: 5_000 });
49
+
50
+ const rightPick = await page.evaluate(() => ({
51
+ frame: globalThis.__threeRbPickingFrame,
52
+ pickCount: globalThis.__threeRbPickCount,
53
+ pickedName: globalThis.__threeRbPickedName,
54
+ leftColor: globalThis.__threeRbLeftCube?.material?.color?.getHex?.(),
55
+ rightColor: globalThis.__threeRbRightCube?.material?.color?.getHex?.(),
56
+ renderInfo: globalThis.__threeRbRenderer?.info?.render
57
+ }));
58
+
59
+ if (rightPick.leftColor !== 0x4ed08f || rightPick.rightColor !== 0xffcc4d || rightPick.pickCount < 2) {
60
+ throw new Error(`right pick did not update the expected mesh: ${JSON.stringify(rightPick)}`);
61
+ }
62
+ if (!rightPick.renderInfo || rightPick.renderInfo.triangles < 24 || !rightPick.frame) {
63
+ throw new Error(`picking scene did not render or animate: ${JSON.stringify(rightPick)}`);
64
+ }
65
+
66
+ assertNoDiagnostics(diagnostics);
67
+
68
+ console.log(`picking smoke test passed at ${server.url}/examples/browser/picking/`);
69
+ } finally {
70
+ await browser.close();
71
+ await new Promise((resolve) => server.instance.close(resolve));
72
+ }
73
+ }
74
+
75
+ main().catch((error) => {
76
+ console.error(error);
77
+ process.exitCode = 1;
78
+ });
@@ -0,0 +1,26 @@
1
+ # three-rb postprocessing browser example
2
+
3
+ This example renders a Ruby-authored scene through ruby.wasm, the JavaScript three.js renderer, and an explicit postprocessing pipeline. It focuses on `Three::Postprocessing::EffectComposer`, `RenderPass`, `UnrealBloomPass`, `DotScreenPass`, `OutputPass`, composer sizing, pass property and uniform updates, and rendering through `composer.render(scene, camera)`.
4
+
5
+ ## Run
6
+
7
+ From the repository root:
8
+
9
+ ```sh
10
+ pnpm install
11
+ ruby -run -e httpd . -p 8000
12
+ ```
13
+
14
+ Open:
15
+
16
+ ```text
17
+ http://localhost:8000/examples/browser/postprocessing/
18
+ ```
19
+
20
+ ## Smoke Test
21
+
22
+ ```sh
23
+ pnpm test:browser:postprocessing
24
+ ```
25
+
26
+ The smoke test serves the repository root, waits for the Ruby scene to reach `Running`, samples the WebGL canvas for nonblank pixels, verifies that the composer owns a render pass and bloom pass, checks bloom pass settings, and confirms animation frames are rendered through the composer path.
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/postprocessing/main",
5
+ clearColor: 0x0a0d12
6
+ });