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,216 @@
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::OrthographicCamera.new(-3, 3, 1.8, -1.8, near: 0.1, far: 100)
19
+ camera.position.z = 5
20
+
21
+ ambient_light = Three::AmbientLight.new(0xffffff, 0.45)
22
+ scene.add(ambient_light)
23
+
24
+ key_light = Three::DirectionalLight.new(0xffffff, 1.35)
25
+ key_light.position.set(2.5, 3.0, 4.5)
26
+ key_light.cast_shadow = true
27
+ key_light.shadow_map_size = [1024, 1024]
28
+ key_light.shadow_bias = -0.0001
29
+ key_light.set_shadow_camera(left: -3.2, right: 3.2, top: 2.4, bottom: -2.4, near: 0.2, far: 12)
30
+ scene.add(key_light)
31
+
32
+ point_light = Three::PointLight.new(0x77a8ff, 0.8, 7, 2)
33
+ point_light.position.set(-1.6, 1.7, 2.4)
34
+ scene.add(point_light)
35
+
36
+ hemisphere_light = Three::HemisphereLight.new(0xdceeff, 0x1d2a20, 0.28)
37
+ scene.add(hemisphere_light)
38
+
39
+ backdrop_material = Three::MeshLambertMaterial.new(color: 0x243141)
40
+ backdrop = Three::Mesh.new(Three::PlaneGeometry.new(5.8, 3.4, width_segments: 2, height_segments: 2), backdrop_material)
41
+ backdrop.position.z = -1.25
42
+ backdrop.receive_shadow = true
43
+ scene.add(backdrop)
44
+
45
+ shadow_material = Three::ShadowMaterial.new(color: 0x000000, opacity: 0.32)
46
+ shadow_catcher = Three::Mesh.new(Three::PlaneGeometry.new(5.4, 3.0, width_segments: 1, height_segments: 1), shadow_material)
47
+ shadow_catcher.position.z = -1.18
48
+ shadow_catcher.receive_shadow = true
49
+ scene.add(shadow_catcher)
50
+
51
+ rig = Three::Group.new
52
+ rig.name = "composition-rig"
53
+ scene.add(rig)
54
+
55
+ primary_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/assets/checker.svg")
56
+ primary_texture.wrap_s = Three::RepeatWrapping
57
+ primary_texture.wrap_t = Three::RepeatWrapping
58
+ primary_texture.mag_filter = Three::NearestFilter
59
+ primary_texture.min_filter = Three::NearestMipmapNearestFilter
60
+ primary_texture.repeat.set(2, 2)
61
+ primary_material = Three::MeshLambertMaterial.new(color: 0xffffff, map: primary_texture)
62
+ primary = Three::Mesh.new(Three::BoxGeometry.new(0.85, 0.85, 0.85), primary_material)
63
+ primary.position.x = -0.55
64
+ primary.position.z = 0.15
65
+ primary.cast_shadow = true
66
+ rig.add(primary)
67
+
68
+ satellite_material = Three::MeshNormalMaterial.new(flat_shading: true)
69
+ satellite = Three::Mesh.new(Three::BoxGeometry.new(0.42, 0.42, 0.42), satellite_material)
70
+ satellite.position.x = 1.35
71
+ satellite.position.y = -0.7
72
+ satellite.position.z = 0.45
73
+ satellite.cast_shadow = true
74
+ rig.add(satellite)
75
+
76
+ orb_material = Three::MeshStandardMaterial.new(color: 0x77a8ff, roughness: 0.38, metalness: 0.45)
77
+ orb = Three::Mesh.new(Three::SphereGeometry.new(0.24, width_segments: 16, height_segments: 8), orb_material)
78
+ orb.position.x = 0.25
79
+ orb.position.y = 0.9
80
+ orb.position.z = 0.35
81
+ orb.cast_shadow = true
82
+ rig.add(orb)
83
+
84
+ highlight_material = Three::MeshPhongMaterial.new(
85
+ color: 0xdce7ff,
86
+ specular: 0xffffff,
87
+ shininess: 72,
88
+ specular_map: primary_texture
89
+ )
90
+ highlight = Three::Mesh.new(Three::SphereGeometry.new(0.18, width_segments: 12, height_segments: 8), highlight_material)
91
+ highlight.position.x = -1.25
92
+ highlight.position.y = 0.55
93
+ highlight.position.z = 0.55
94
+ highlight.cast_shadow = true
95
+ rig.add(highlight)
96
+
97
+ instance_count = 1000
98
+ instance_columns = 50
99
+ instanced_material = Three::MeshLambertMaterial.new(color: 0xffffff, opacity: 0.42, transparent: true)
100
+ instanced_field = Three::InstancedMesh.new(Three::BoxGeometry.new(0.032, 0.032, 0.032), instanced_material, instance_count)
101
+ instance_matrix = Three::Matrix4.new
102
+ instance_count.times do |index|
103
+ column = index % instance_columns
104
+ row = index / instance_columns
105
+ column_t = column.to_f / (instance_columns - 1)
106
+ row_t = row.to_f / ((instance_count / instance_columns) - 1)
107
+ x = (column - ((instance_columns - 1) / 2.0)) * 0.11
108
+ y = (row - ((instance_count / instance_columns - 1) / 2.0)) * 0.12
109
+ z = -0.85 - ((index % 7) * 0.012)
110
+ instanced_field.set_matrix_at(index, instance_matrix.make_translation(x, y, z))
111
+ instanced_field.set_color_at(index, [0.35 + (0.45 * column_t), 0.55 + (0.25 * row_t), 0.9 - (0.35 * column_t)])
112
+ end
113
+ scene.add(instanced_field)
114
+
115
+ renderer = Three::Renderers::ThreeJSRenderer.new(
116
+ canvas: "#scene",
117
+ antialias: true,
118
+ alpha: false,
119
+ preserveDrawingBuffer: true,
120
+ shadow_map_enabled: true,
121
+ shadow_map_type: Three::PCFShadowMap
122
+ )
123
+ renderer.set_clear_color(0x0f1419, 1)
124
+
125
+ controls = Three::Controls::OrbitControls.new(
126
+ camera,
127
+ renderer: renderer,
128
+ enable_damping: true,
129
+ damping_factor: 0.08,
130
+ enable_pan: false
131
+ )
132
+ controls.target.set(0, 0, 0)
133
+
134
+ resize = proc do
135
+ width = [viewport[:clientWidth].to_i, 1].max
136
+ height = [viewport[:clientHeight].to_i, 1].max
137
+ view_height = 3.8
138
+ view_width = view_height * width.to_f / height
139
+
140
+ camera.left = -view_width / 2
141
+ camera.right = view_width / 2
142
+ camera.top = view_height / 2
143
+ camera.bottom = -view_height / 2
144
+ camera.update_projection_matrix
145
+ renderer.set_size(width, height)
146
+ end
147
+
148
+ resize.call
149
+ window.call(:addEventListener, "resize", resize)
150
+ renderer.render(scene, camera)
151
+
152
+ JS.global[:__threeRbRenderer] = renderer.handle
153
+ JS.global[:__threeRbControls] = controls.handle
154
+ JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
155
+ JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
156
+ JS.global[:__threeRbAmbientLight] = renderer.backend.materialize(ambient_light)
157
+ JS.global[:__threeRbDirectionalLight] = renderer.backend.materialize(key_light)
158
+ JS.global[:__threeRbPointLight] = renderer.backend.materialize(point_light)
159
+ JS.global[:__threeRbHemisphereLight] = renderer.backend.materialize(hemisphere_light)
160
+ JS.global[:__threeRbPlane] = renderer.backend.materialize(backdrop)
161
+ JS.global[:__threeRbShadowCatcher] = renderer.backend.materialize(shadow_catcher)
162
+ JS.global[:__threeRbShadowMaterial] = renderer.backend.materialize(shadow_material)
163
+ JS.global[:__threeRbRig] = renderer.backend.materialize(rig)
164
+ JS.global[:__threeRbPrimaryMesh] = renderer.backend.materialize(primary)
165
+ JS.global[:__threeRbSatelliteMesh] = renderer.backend.materialize(satellite)
166
+ JS.global[:__threeRbSphereMesh] = renderer.backend.materialize(orb)
167
+ JS.global[:__threeRbPhongMesh] = renderer.backend.materialize(highlight)
168
+ JS.global[:__threeRbInstancedMesh] = renderer.backend.materialize(instanced_field)
169
+ JS.global[:__threeRbInstancedMaterial] = renderer.backend.materialize(instanced_material)
170
+ JS.global[:__threeRbTexture] = renderer.backend.materialize(primary_texture)
171
+ JS.global[:__threeRbChangingMaterial] = renderer.backend.materialize(primary_material)
172
+ JS.global[:__threeRbLambertMaterial] = renderer.backend.materialize(primary_material)
173
+ JS.global[:__threeRbNormalMaterial] = renderer.backend.materialize(satellite_material)
174
+ JS.global[:__threeRbStandardMaterial] = renderer.backend.materialize(orb_material)
175
+ JS.global[:__threeRbPhongMaterial] = renderer.backend.materialize(highlight_material)
176
+ JS.global[:__threeRbMaterialDisposeEvent] = false
177
+ JS.global[:__threeRbTextureDisposeEvent] = false
178
+
179
+ disposable_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/assets/checker.svg")
180
+ disposable_material = Three::MeshBasicMaterial.new(map: disposable_texture)
181
+ disposable_texture_handle = renderer.backend.materialize(disposable_texture)
182
+ disposable_material_handle = renderer.backend.materialize(disposable_material)
183
+ disposable_texture_handle.call(:addEventListener, "dispose", proc { JS.global[:__threeRbTextureDisposeEvent] = true })
184
+ disposable_material_handle.call(:addEventListener, "dispose", proc { JS.global[:__threeRbMaterialDisposeEvent] = true })
185
+ renderer.dispose(disposable_material, dispose_textures: true)
186
+ JS.global[:__threeRbMaterialHandleCachedAfterDispose] = renderer.backend.handles.key?(disposable_material.uuid)
187
+ JS.global[:__threeRbTextureHandleCachedAfterDispose] = renderer.backend.handles.key?(disposable_texture.uuid)
188
+ JS.global[:__threeRbInitialMaterialColor] = primary_material.color.hex
189
+ JS.global[:__threeRbCompositionFrame] = 0
190
+
191
+ frame = 0
192
+ renderer.animation_loop do
193
+ frame += 1
194
+ rig.rotation.z += 0.008
195
+ primary.rotation.x += 0.017
196
+ primary.rotation.y += 0.009
197
+ satellite.rotation.y -= 0.025
198
+ orb.rotation.x += 0.018
199
+ orb.rotation.y -= 0.013
200
+ highlight.rotation.y += 0.021
201
+ instanced_field.rotation.z -= 0.0015
202
+
203
+ pulse = (Math.sin(frame * 0.045) + 1) / 2.0
204
+ primary_material.color.set_rgb(0.25 + (0.35 * pulse), 0.55 + (0.25 * pulse), 0.42)
205
+
206
+ JS.global[:__threeRbCompositionFrame] = frame
207
+ controls.update
208
+ renderer.render(scene, camera)
209
+ end
210
+
211
+ status[:textContent] = "Running"
212
+ status_dot[:dataset][:state] = "running"
213
+ rescue StandardError => error
214
+ JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
215
+ raise
216
+ end
@@ -0,0 +1,266 @@
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/composition/`, { waitUntil: "load" });
22
+ await waitForRunning(page, diagnostics);
23
+ await page.waitForFunction(
24
+ () => globalThis.__threeRbLambertMaterial?.map?.source?.data?.complete === true,
25
+ null,
26
+ { timeout: 10_000 }
27
+ );
28
+ await page.waitForTimeout(1_000);
29
+
30
+ assertNonBlankCanvas(await sampleCanvas(page));
31
+
32
+ const scene = await page.evaluate(() => ({
33
+ frame: globalThis.__threeRbCompositionFrame,
34
+ renderInfo: globalThis.__threeRbRenderer?.info?.render,
35
+ cameraType: globalThis.__threeRbCamera?.type,
36
+ controlsType: globalThis.__threeRbControls?.constructor?.name,
37
+ controlsEnableDamping: globalThis.__threeRbControls?.enableDamping,
38
+ controlsEnablePan: globalThis.__threeRbControls?.enablePan,
39
+ controlsTarget: globalThis.__threeRbControls?.target?.toArray?.(),
40
+ shadowMapEnabled: globalThis.__threeRbRenderer?.shadowMap?.enabled,
41
+ shadowMapType: globalThis.__threeRbRenderer?.shadowMap?.type,
42
+ textureType: globalThis.__threeRbTexture?.isTexture,
43
+ materialTextureType: globalThis.__threeRbLambertMaterial?.map?.isTexture,
44
+ materialTextureWidth: globalThis.__threeRbLambertMaterial?.map?.source?.data?.naturalWidth,
45
+ materialTextureWrapS: globalThis.__threeRbLambertMaterial?.map?.wrapS,
46
+ materialTextureWrapT: globalThis.__threeRbLambertMaterial?.map?.wrapT,
47
+ materialTextureMagFilter: globalThis.__threeRbLambertMaterial?.map?.magFilter,
48
+ materialTextureMinFilter: globalThis.__threeRbLambertMaterial?.map?.minFilter,
49
+ materialTextureRepeat: globalThis.__threeRbLambertMaterial?.map?.repeat?.toArray?.(),
50
+ sceneChildren: globalThis.__threeRbScene?.children?.length,
51
+ ambientLightType: globalThis.__threeRbAmbientLight?.type,
52
+ directionalLightType: globalThis.__threeRbDirectionalLight?.type,
53
+ directionalLightCastShadow: globalThis.__threeRbDirectionalLight?.castShadow,
54
+ directionalShadowMapWidth: globalThis.__threeRbDirectionalLight?.shadow?.mapSize?.width,
55
+ directionalShadowMapHeight: globalThis.__threeRbDirectionalLight?.shadow?.mapSize?.height,
56
+ directionalShadowBias: globalThis.__threeRbDirectionalLight?.shadow?.bias,
57
+ directionalShadowCameraLeft: globalThis.__threeRbDirectionalLight?.shadow?.camera?.left,
58
+ directionalShadowCameraFar: globalThis.__threeRbDirectionalLight?.shadow?.camera?.far,
59
+ directionalShadowMapReady: Boolean(globalThis.__threeRbDirectionalLight?.shadow?.map),
60
+ pointLightType: globalThis.__threeRbPointLight?.type,
61
+ pointLightDistance: globalThis.__threeRbPointLight?.distance,
62
+ pointLightDecay: globalThis.__threeRbPointLight?.decay,
63
+ hemisphereLightType: globalThis.__threeRbHemisphereLight?.type,
64
+ hemisphereLightGroundColor: globalThis.__threeRbHemisphereLight?.groundColor?.getHex?.(),
65
+ hemisphereLightIntensity: globalThis.__threeRbHemisphereLight?.intensity,
66
+ planeGeometryType: globalThis.__threeRbPlane?.geometry?.type,
67
+ planeReceiveShadow: globalThis.__threeRbPlane?.receiveShadow,
68
+ shadowCatcherType: globalThis.__threeRbShadowCatcher?.type,
69
+ shadowCatcherGeometryType: globalThis.__threeRbShadowCatcher?.geometry?.type,
70
+ shadowCatcherReceiveShadow: globalThis.__threeRbShadowCatcher?.receiveShadow,
71
+ shadowMaterialType: globalThis.__threeRbShadowMaterial?.type,
72
+ shadowMaterialColor: globalThis.__threeRbShadowMaterial?.color?.getHex?.(),
73
+ shadowMaterialOpacity: globalThis.__threeRbShadowMaterial?.opacity,
74
+ shadowMaterialTransparent: globalThis.__threeRbShadowMaterial?.transparent,
75
+ shadowMaterialFog: globalThis.__threeRbShadowMaterial?.fog,
76
+ rigType: globalThis.__threeRbRig?.type,
77
+ primaryParentType: globalThis.__threeRbPrimaryMesh?.parent?.type,
78
+ primaryCastShadow: globalThis.__threeRbPrimaryMesh?.castShadow,
79
+ primaryMaterialType: globalThis.__threeRbPrimaryMesh?.material?.type,
80
+ satelliteParentType: globalThis.__threeRbSatelliteMesh?.parent?.type,
81
+ sphereParentType: globalThis.__threeRbSphereMesh?.parent?.type,
82
+ sphereGeometryType: globalThis.__threeRbSphereMesh?.geometry?.type,
83
+ sphereMaterialType: globalThis.__threeRbSphereMesh?.material?.type,
84
+ phongParentType: globalThis.__threeRbPhongMesh?.parent?.type,
85
+ phongGeometryType: globalThis.__threeRbPhongMesh?.geometry?.type,
86
+ phongMaterialType: globalThis.__threeRbPhongMesh?.material?.type,
87
+ phongSpecular: globalThis.__threeRbPhongMaterial?.specular?.getHex?.(),
88
+ phongShininess: globalThis.__threeRbPhongMaterial?.shininess,
89
+ phongSpecularMapType: globalThis.__threeRbPhongMaterial?.specularMap?.isTexture,
90
+ instancedType: globalThis.__threeRbInstancedMesh?.type,
91
+ instancedIsInstancedMesh: globalThis.__threeRbInstancedMesh?.isInstancedMesh,
92
+ instancedCount: globalThis.__threeRbInstancedMesh?.count,
93
+ instancedMaterialType: globalThis.__threeRbInstancedMaterial?.type,
94
+ instancedMaterialTransparent: globalThis.__threeRbInstancedMaterial?.transparent,
95
+ instancedMatrixNeedsUpdate: globalThis.__threeRbInstancedMesh?.instanceMatrix?.needsUpdate,
96
+ instancedHasInstanceColor: Boolean(globalThis.__threeRbInstancedMesh?.instanceColor),
97
+ instancedColorItemSize: globalThis.__threeRbInstancedMesh?.instanceColor?.itemSize,
98
+ instancedColorCount: globalThis.__threeRbInstancedMesh?.instanceColor?.count,
99
+ instancedFirstColor: (() => {
100
+ if (!globalThis.__threeRbInstancedMesh || !globalThis.THREE?.Color) return undefined;
101
+ const color = new globalThis.THREE.Color();
102
+ globalThis.__threeRbInstancedMesh.getColorAt(0, color);
103
+ return color.toArray();
104
+ })(),
105
+ instancedLastColor: (() => {
106
+ if (!globalThis.__threeRbInstancedMesh || !globalThis.THREE?.Color) return undefined;
107
+ const color = new globalThis.THREE.Color();
108
+ globalThis.__threeRbInstancedMesh.getColorAt(999, color);
109
+ return color.toArray();
110
+ })(),
111
+ instancedLastMatrix: (() => {
112
+ if (!globalThis.__threeRbInstancedMesh || !globalThis.THREE?.Matrix4) return undefined;
113
+ const matrix = new globalThis.THREE.Matrix4();
114
+ globalThis.__threeRbInstancedMesh.getMatrixAt(999, matrix);
115
+ return matrix.toArray();
116
+ })(),
117
+ satelliteMaterialType: globalThis.__threeRbSatelliteMesh?.material?.type,
118
+ normalMaterialFlatShading: globalThis.__threeRbNormalMaterial?.flatShading,
119
+ standardMaterialRoughness: globalThis.__threeRbStandardMaterial?.roughness,
120
+ standardMaterialMetalness: globalThis.__threeRbStandardMaterial?.metalness,
121
+ materialDisposeEvent: globalThis.__threeRbMaterialDisposeEvent,
122
+ textureDisposeEvent: globalThis.__threeRbTextureDisposeEvent,
123
+ materialHandleCachedAfterDispose: globalThis.__threeRbMaterialHandleCachedAfterDispose,
124
+ textureHandleCachedAfterDispose: globalThis.__threeRbTextureHandleCachedAfterDispose,
125
+ currentMaterialColor: globalThis.__threeRbChangingMaterial?.color?.getHex?.(),
126
+ initialMaterialColor: globalThis.__threeRbInitialMaterialColor
127
+ }));
128
+
129
+ if (scene.planeGeometryType !== "PlaneGeometry") {
130
+ throw new Error(`expected a PlaneGeometry backdrop: ${JSON.stringify(scene)}`);
131
+ }
132
+ if (
133
+ scene.shadowCatcherType !== "Mesh" ||
134
+ scene.shadowCatcherGeometryType !== "PlaneGeometry" ||
135
+ scene.shadowCatcherReceiveShadow !== true ||
136
+ scene.shadowMaterialType !== "ShadowMaterial" ||
137
+ scene.shadowMaterialColor !== 0x000000 ||
138
+ scene.shadowMaterialOpacity !== 0.32 ||
139
+ scene.shadowMaterialTransparent !== true ||
140
+ scene.shadowMaterialFog !== true
141
+ ) {
142
+ throw new Error(`expected a configured ShadowMaterial catcher: ${JSON.stringify(scene)}`);
143
+ }
144
+ if (scene.cameraType !== "OrthographicCamera") {
145
+ throw new Error(`expected an OrthographicCamera composition view: ${JSON.stringify(scene)}`);
146
+ }
147
+ if (scene.controlsType !== "OrbitControls" || scene.controlsEnableDamping !== true || scene.controlsEnablePan !== false) {
148
+ throw new Error(`expected configured OrbitControls: ${JSON.stringify(scene)}`);
149
+ }
150
+ if (scene.shadowMapEnabled !== true || scene.shadowMapType !== 1) {
151
+ throw new Error(`expected enabled PCF shadow mapping: ${JSON.stringify(scene)}`);
152
+ }
153
+ if (scene.textureType !== true || scene.materialTextureType !== true || scene.materialTextureWidth <= 0) {
154
+ throw new Error(`expected a loaded material texture: ${JSON.stringify(scene)}`);
155
+ }
156
+ if (scene.materialTextureWrapS !== 1000 || scene.materialTextureWrapT !== 1000 || scene.materialTextureMagFilter !== 1003 || scene.materialTextureMinFilter !== 1004) {
157
+ throw new Error(`expected configured texture wrapping and filters: ${JSON.stringify(scene)}`);
158
+ }
159
+ if (!Array.isArray(scene.materialTextureRepeat) || scene.materialTextureRepeat[0] !== 2 || scene.materialTextureRepeat[1] !== 2) {
160
+ throw new Error(`expected configured texture repeat: ${JSON.stringify(scene)}`);
161
+ }
162
+ if (scene.ambientLightType !== "AmbientLight" || scene.directionalLightType !== "DirectionalLight" || scene.pointLightType !== "PointLight" || scene.hemisphereLightType !== "HemisphereLight") {
163
+ throw new Error(`expected ambient, directional, point, and hemisphere lights: ${JSON.stringify(scene)}`);
164
+ }
165
+ if (scene.directionalLightCastShadow !== true || scene.directionalShadowMapWidth !== 1024 || scene.directionalShadowMapHeight !== 1024) {
166
+ throw new Error(`expected configured DirectionalLight shadow map: ${JSON.stringify(scene)}`);
167
+ }
168
+ if (scene.directionalShadowBias !== -0.0001 || scene.directionalShadowCameraLeft !== -3.2 || scene.directionalShadowCameraFar !== 12) {
169
+ throw new Error(`expected configured DirectionalLight shadow camera: ${JSON.stringify(scene)}`);
170
+ }
171
+ if (scene.directionalShadowMapReady !== true) {
172
+ throw new Error(`expected rendered DirectionalLight shadow map: ${JSON.stringify(scene)}`);
173
+ }
174
+ if (scene.pointLightDistance !== 7 || scene.pointLightDecay !== 2) {
175
+ throw new Error(`expected configured PointLight falloff: ${JSON.stringify(scene)}`);
176
+ }
177
+ if (scene.hemisphereLightGroundColor !== 0x1d2a20 || scene.hemisphereLightIntensity !== 0.28) {
178
+ throw new Error(`expected configured HemisphereLight: ${JSON.stringify(scene)}`);
179
+ }
180
+ if (scene.rigType !== "Group" || scene.primaryParentType !== "Group" || scene.satelliteParentType !== "Group" || scene.sphereParentType !== "Group" || scene.phongParentType !== "Group") {
181
+ throw new Error(`expected grouped child meshes: ${JSON.stringify(scene)}`);
182
+ }
183
+ if (scene.planeReceiveShadow !== true || scene.primaryCastShadow !== true) {
184
+ throw new Error(`expected mesh shadow flags: ${JSON.stringify(scene)}`);
185
+ }
186
+ if (scene.primaryMaterialType !== "MeshLambertMaterial") {
187
+ throw new Error(`expected a light-reactive MeshLambertMaterial primary mesh: ${JSON.stringify(scene)}`);
188
+ }
189
+ if (scene.sphereGeometryType !== "SphereGeometry") {
190
+ throw new Error(`expected a SphereGeometry child mesh: ${JSON.stringify(scene)}`);
191
+ }
192
+ if (scene.sphereMaterialType !== "MeshStandardMaterial" || scene.standardMaterialRoughness !== 0.38 || scene.standardMaterialMetalness !== 0.45) {
193
+ throw new Error(`expected a configured MeshStandardMaterial sphere: ${JSON.stringify(scene)}`);
194
+ }
195
+ if (scene.phongGeometryType !== "SphereGeometry" || scene.phongMaterialType !== "MeshPhongMaterial") {
196
+ throw new Error(`expected a MeshPhongMaterial highlight sphere: ${JSON.stringify(scene)}`);
197
+ }
198
+ if (scene.phongSpecular !== 0xffffff || scene.phongShininess !== 72 || scene.phongSpecularMapType !== true) {
199
+ throw new Error(`expected configured MeshPhongMaterial specular settings: ${JSON.stringify(scene)}`);
200
+ }
201
+ if (scene.instancedIsInstancedMesh !== true || scene.instancedCount !== 1000) {
202
+ throw new Error(`expected a 1000-count InstancedMesh: ${JSON.stringify(scene)}`);
203
+ }
204
+ if (scene.instancedMaterialType !== "MeshLambertMaterial" || scene.instancedMaterialTransparent !== true) {
205
+ throw new Error(`expected a transparent MeshLambertMaterial on InstancedMesh: ${JSON.stringify(scene)}`);
206
+ }
207
+ if (!Array.isArray(scene.instancedLastMatrix) || Math.abs(scene.instancedLastMatrix[12] - 2.695) > 0.001 || Math.abs(scene.instancedLastMatrix[13] - 1.14) > 0.001) {
208
+ throw new Error(`expected InstancedMesh matrix data to sync: ${JSON.stringify(scene)}`);
209
+ }
210
+ if (scene.instancedHasInstanceColor !== true || scene.instancedColorItemSize !== 3 || scene.instancedColorCount !== 1000) {
211
+ throw new Error(`expected InstancedMesh color attribute data: ${JSON.stringify(scene)}`);
212
+ }
213
+ if (!colorClose(scene.instancedFirstColor, [0.35, 0.55, 0.9]) || !colorClose(scene.instancedLastColor, [0.8, 0.8, 0.55])) {
214
+ throw new Error(`expected InstancedMesh color data to sync: ${JSON.stringify(scene)}`);
215
+ }
216
+ if (scene.satelliteMaterialType !== "MeshNormalMaterial" || scene.normalMaterialFlatShading !== true) {
217
+ throw new Error(`expected a flat-shaded MeshNormalMaterial satellite: ${JSON.stringify(scene)}`);
218
+ }
219
+ if (scene.materialDisposeEvent !== true || scene.textureDisposeEvent !== true) {
220
+ throw new Error(`expected material and texture dispose events: ${JSON.stringify(scene)}`);
221
+ }
222
+ if (scene.materialHandleCachedAfterDispose !== false || scene.textureHandleCachedAfterDispose !== false) {
223
+ throw new Error(`expected disposed material and texture handles to leave the backend cache: ${JSON.stringify(scene)}`);
224
+ }
225
+ if (!scene.renderInfo || scene.renderInfo.triangles < 10000) {
226
+ throw new Error(`renderer did not draw the composition triangles: ${JSON.stringify(scene)}`);
227
+ }
228
+ if (!scene.frame || scene.currentMaterialColor === scene.initialMaterialColor) {
229
+ throw new Error(`material color did not change after animation frames: ${JSON.stringify(scene)}`);
230
+ }
231
+
232
+ const beforeDrag = await page.evaluate(() => globalThis.__threeRbCamera?.position?.toArray?.());
233
+ await page.mouse.move(480, 270);
234
+ await page.mouse.down();
235
+ await page.mouse.move(620, 300, { steps: 12 });
236
+ await page.mouse.up();
237
+ await page.waitForTimeout(500);
238
+ const afterDrag = await page.evaluate(() => globalThis.__threeRbCamera?.position?.toArray?.());
239
+ if (!cameraPositionChanged(beforeDrag, afterDrag)) {
240
+ throw new Error(`OrbitControls drag did not move the camera: ${JSON.stringify({ beforeDrag, afterDrag })}`);
241
+ }
242
+ assertNoDiagnostics(diagnostics);
243
+
244
+ console.log(`composition smoke test passed at ${server.url}/examples/browser/composition/`);
245
+ } finally {
246
+ await browser.close();
247
+ await new Promise((resolve) => server.instance.close(resolve));
248
+ }
249
+ }
250
+
251
+ main().catch((error) => {
252
+ console.error(error);
253
+ process.exitCode = 1;
254
+ });
255
+
256
+ function cameraPositionChanged(before, after) {
257
+ if (!Array.isArray(before) || !Array.isArray(after)) return false;
258
+
259
+ return before.some((value, index) => Math.abs(value - after[index]) > 0.001);
260
+ }
261
+
262
+ function colorClose(actual, expected) {
263
+ if (!Array.isArray(actual)) return false;
264
+
265
+ return expected.every((value, index) => Math.abs(actual[index] - value) < 0.001);
266
+ }
@@ -0,0 +1,41 @@
1
+ # Browser Cube Example
2
+
3
+ This example renders a Ruby-authored cube through ruby.wasm and the JavaScript three.js renderer.
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/cube/
16
+ ```
17
+
18
+ The page uses these pnpm-managed browser dependencies:
19
+
20
+ - `@ruby/3.4-wasm-wasi@2.9.4-2026-05-11-a`
21
+ - `@ruby/wasm-wasi@2.9.4-2026-05-11-a`
22
+ - `three@0.184.0`
23
+
24
+ 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/`.
25
+
26
+ ## Browser Smoke Test
27
+
28
+ Install the optional Node dependency and browser binary:
29
+
30
+ ```sh
31
+ pnpm install
32
+ pnpm exec playwright install chromium
33
+ ```
34
+
35
+ Run the browser smoke test:
36
+
37
+ ```sh
38
+ pnpm test:browser:cube
39
+ ```
40
+
41
+ The smoke test serves the repository root, waits for the Ruby scene to reach `Running`, samples the WebGL canvas for nonblank pixels, and verifies that the renderer drew the cube triangles.
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/cube/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 cube</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>