three-rb 0.1.0 → 0.2.1

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +66 -3
  4. data/docs/browser-runtime.md +92 -24
  5. data/docs/loaded-assets-design.md +1 -1
  6. data/docs/next-work.md +9 -5
  7. data/docs/publishing.md +119 -23
  8. data/docs/release-readiness.md +5 -3
  9. data/docs/standalone-browser-app.md +106 -0
  10. data/examples/browser/README.md +8 -0
  11. data/examples/browser/composition/main.rb +44 -64
  12. data/examples/browser/cube/main.rb +4 -34
  13. data/examples/browser/cubemap/assets/checker.svg +11 -0
  14. data/examples/browser/cubemap/main.rb +17 -40
  15. data/examples/browser/gltf/main.rb +30 -50
  16. data/examples/browser/picking/main.rb +27 -53
  17. data/examples/browser/postprocessing/main.rb +23 -42
  18. data/examples/browser/primitives/assets/checker.svg +11 -0
  19. data/examples/browser/primitives/main.rb +19 -42
  20. data/examples/browser/ruby/README.md +24 -0
  21. data/examples/browser/ruby/boot.mjs +6 -0
  22. data/examples/browser/ruby/index.html +142 -0
  23. data/examples/browser/ruby/main.rb +313 -0
  24. data/examples/browser/ruby/smoke_test.mjs +126 -0
  25. data/examples/browser/serialization/assets/checker.svg +11 -0
  26. data/examples/browser/serialization/main.rb +20 -42
  27. data/examples/browser/shared/boot.mjs +37 -5
  28. data/examples/browser/textures/assets/checker.svg +11 -0
  29. data/examples/browser/textures/assets/studio.hdr +5 -0
  30. data/examples/browser/textures/main.rb +23 -41
  31. data/exe/three-rb +56 -0
  32. data/lib/three/backends/threejs/materialization.rb +6 -0
  33. data/lib/three/backends/threejs/parameters.rb +17 -0
  34. data/lib/three/backends/threejs/ruby_wasm_adapter.rb +166 -59
  35. data/lib/three/backends/threejs/synchronization.rb +38 -4
  36. data/lib/three/backends/threejs.rb +24 -0
  37. data/lib/three/browser.rb +389 -0
  38. data/lib/three/constants.rb +6 -0
  39. data/lib/three/core/buffer_attribute.rb +5 -1
  40. data/lib/three/core/buffer_geometry.rb +29 -1
  41. data/lib/three/core/object3d.rb +39 -1
  42. data/lib/three/exporters/three_json_exporter.rb +3 -0
  43. data/lib/three/generators/browser_example.rb +396 -0
  44. data/lib/three/geometries/text_geometry.rb +41 -0
  45. data/lib/three/loaders/font_loader.rb +29 -0
  46. data/lib/three/loaders/three_json_loader.rb +92 -46
  47. data/lib/three/materials/material.rb +2 -1
  48. data/lib/three/math/matrix4.rb +27 -0
  49. data/lib/three/renderers/threejs_renderer.rb +19 -0
  50. data/lib/three/scenes/fog.rb +86 -0
  51. data/lib/three/scenes/scene.rb +19 -1
  52. data/lib/three/textures/texture.rb +2 -1
  53. data/lib/three/version.rb +1 -1
  54. data/lib/three.rb +4 -0
  55. data/package.json +2 -1
  56. metadata +26 -8
  57. /data/examples/browser/{assets → composition/assets}/checker.svg +0 -0
  58. /data/examples/browser/{assets → gltf/assets}/animated_triangle.gltf +0 -0
  59. /data/examples/browser/{assets → gltf/assets}/compressed_triangle.gltf +0 -0
  60. /data/examples/browser/{assets → gltf/assets}/triangle.gltf +0 -0
  61. /data/examples/browser/{assets → ruby/assets}/studio.hdr +0 -0
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../lib/three"
4
+
5
+ def gem_ring(radius, y, sides, offset)
6
+ sides.times.map do |index|
7
+ angle = offset + (Math::PI * 2 * index / sides)
8
+ [Math.cos(angle) * radius, y, Math.sin(angle) * radius]
9
+ end
10
+ end
11
+
12
+ def face_normal(a, b, c)
13
+ ux = b[0] - a[0]
14
+ uy = b[1] - a[1]
15
+ uz = b[2] - a[2]
16
+ vx = c[0] - a[0]
17
+ vy = c[1] - a[1]
18
+ vz = c[2] - a[2]
19
+ nx = (uy * vz) - (uz * vy)
20
+ ny = (uz * vx) - (ux * vz)
21
+ nz = (ux * vy) - (uy * vx)
22
+ length = Math.sqrt((nx * nx) + (ny * ny) + (nz * nz))
23
+ return [0, 1, 0] if length.zero?
24
+
25
+ [nx / length, ny / length, nz / length]
26
+ end
27
+
28
+ def add_triangle(vertices, normals, a, b, c)
29
+ normal = face_normal(a, b, c)
30
+ [a, b, c].each do |point|
31
+ vertices.push(*point)
32
+ normals.push(*normal)
33
+ end
34
+ end
35
+
36
+ def add_polygon(vertices, normals, points)
37
+ (1...(points.length - 1)).each do |index|
38
+ add_triangle(vertices, normals, points[0], points[index], points[index + 1])
39
+ end
40
+ end
41
+
42
+ def faceted_ruby_geometry
43
+ sides = 10
44
+ step = Math::PI * 2 / sides
45
+ top = gem_ring(0.62, 0.58, sides, step / 2)
46
+ crown = gem_ring(1.02, 0.18, sides, 0)
47
+ girdle = gem_ring(1.15, -0.08, sides, step / 2)
48
+ pavilion = gem_ring(0.64, -0.48, sides, 0)
49
+ culet = [0, -1.05, 0]
50
+ vertices = []
51
+ normals = []
52
+
53
+ add_polygon(vertices, normals, top.reverse)
54
+ sides.times do |index|
55
+ next_index = (index + 1) % sides
56
+ add_polygon(vertices, normals, [top[index], top[next_index], crown[next_index], crown[index]])
57
+ add_polygon(vertices, normals, [crown[index], crown[next_index], girdle[next_index], girdle[index]])
58
+ add_polygon(vertices, normals, [girdle[index], girdle[next_index], pavilion[next_index], pavilion[index]])
59
+ add_triangle(vertices, normals, pavilion[next_index], pavilion[index], culet)
60
+ end
61
+
62
+ geometry = Three::BufferGeometry.new
63
+ geometry.set_attribute(:position, Three::Float32BufferAttribute.new(vertices, 3))
64
+ geometry.set_attribute(:normal, Three::Float32BufferAttribute.new(normals, 3))
65
+ geometry.compute_bounding_box
66
+ geometry.compute_bounding_sphere
67
+ geometry
68
+ end
69
+
70
+ def build_spark(size, material)
71
+ spark = Three::Group.new
72
+ spark.name = "ruby-spark"
73
+
74
+ diamond_vertices = [
75
+ 0, size * 0.32, 0,
76
+ size * 0.22, 0, 0,
77
+ 0, -size * 0.32, 0,
78
+ -size * 0.22, 0, 0
79
+ ]
80
+ diamond_geometry = Three::BufferGeometry.new
81
+ diamond_geometry.set_index([0, 1, 2, 0, 2, 3])
82
+ diamond_geometry.set_attribute(:position, Three::Float32BufferAttribute.new(diamond_vertices, 3))
83
+ diamond_geometry.set_attribute(:normal, Three::Float32BufferAttribute.new([0, 0, 1] * 4, 3))
84
+ diamond = Three::Mesh.new(diamond_geometry, material)
85
+ spark.add(diamond)
86
+
87
+ long_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.72, size * 0.035, size * 0.035), material)
88
+ spark.add(long_ray)
89
+
90
+ cross_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.5, size * 0.03, size * 0.03), material)
91
+ cross_ray.rotation.z = Math::PI / 2
92
+ spark.add(cross_ray)
93
+
94
+ slash_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.38, size * 0.024, size * 0.024), material)
95
+ slash_ray.rotation.z = Math::PI / 4
96
+ spark.add(slash_ray)
97
+
98
+ backslash_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.34, size * 0.02, size * 0.02), material)
99
+ backslash_ray.rotation.z = -Math::PI / 4
100
+ spark.add(backslash_ray)
101
+ spark
102
+ end
103
+
104
+ Three::Browser.run(starting: "Starting Ruby scene") do |app|
105
+ scene = Three::Scene.new
106
+ camera = Three::PerspectiveCamera.new(42, aspect: 1.0, near: 0.1, far: 100)
107
+ camera.position.set(0, 0.42, 6.9)
108
+
109
+ environment_texture = Three::Loaders::RGBELoader.new.load("/examples/browser/ruby/assets/studio.hdr")
110
+ scene.environment = environment_texture
111
+
112
+ scene.add(Three::AmbientLight.new(0xffffff, 0.82))
113
+ scene.add(Three::HemisphereLight.new(0xffffff, 0xffedf2, 1.0))
114
+
115
+ key_light = Three::DirectionalLight.new(0xffffff, 2.55)
116
+ key_light.position.set(3.2, 4.4, 4.8)
117
+ key_light.cast_shadow = true
118
+ key_light.shadow_map_size = [1024, 1024]
119
+ key_light.shadow_bias = -0.00008
120
+ key_light.set_shadow_camera(left: -3.2, right: 3.2, top: 2.6, bottom: -2.6, near: 0.2, far: 12)
121
+ scene.add(key_light)
122
+
123
+ rim_light = Three::PointLight.new(0xff89a4, 2.15, 8, 2)
124
+ rim_light.position.set(-2.2, 1.6, 2.8)
125
+ scene.add(rim_light)
126
+
127
+ cool_light = Three::PointLight.new(0xb4e6ff, 1.35, 7, 2)
128
+ cool_light.position.set(2.4, -0.8, 2.2)
129
+ scene.add(cool_light)
130
+
131
+ pin_light = Three::PointLight.new(0xffffff, 3.1, 4.4, 2)
132
+ pin_light.position.set(-1.6, 2.35, 3.25)
133
+ scene.add(pin_light)
134
+
135
+ backdrop_material = Three::MeshBasicMaterial.new(
136
+ color: 0x58c2ff,
137
+ transparent: true,
138
+ opacity: 0.1
139
+ )
140
+ backdrop = Three::Mesh.new(
141
+ Three::PlaneGeometry.new(7.2, 4.4, width_segments: 1, height_segments: 1),
142
+ backdrop_material
143
+ )
144
+ backdrop.position.z = -1.35
145
+ scene.add(backdrop)
146
+
147
+ ruby_material = Three::MeshPhysicalMaterial.new(
148
+ color: 0xff2d64,
149
+ roughness: 0.008,
150
+ metalness: 0,
151
+ opacity: 0.82,
152
+ transparent: true,
153
+ clearcoat: 1.0,
154
+ clearcoat_roughness: 0.006,
155
+ transmission: 0.98,
156
+ thickness: 0.58,
157
+ ior: 1.84,
158
+ dispersion: 0.42,
159
+ iridescence: 0.16,
160
+ iridescence_ior: 1.36,
161
+ iridescence_thickness_range: [120, 260],
162
+ specular_intensity: 1.0,
163
+ specular_color: 0xffffff,
164
+ attenuation_color: 0xff164b,
165
+ attenuation_distance: 2.05,
166
+ side: Three::DoubleSide,
167
+ vertex_colors: false
168
+ )
169
+ ruby_gem = Three::Mesh.new(faceted_ruby_geometry, ruby_material)
170
+ ruby_gem.position.set(0, 0.5, 0.1)
171
+ ruby_gem.rotation.x = -0.16
172
+ ruby_gem.rotation.y = 0.28
173
+ ruby_gem.scale.set(1.08, 1.08, 1.08)
174
+ ruby_gem.cast_shadow = true
175
+ scene.add(ruby_gem)
176
+
177
+ spark_materials = [
178
+ Three::MeshBasicMaterial.new(color: 0xffffff, transparent: true, opacity: 1.0),
179
+ Three::MeshBasicMaterial.new(color: 0xfff0a8, transparent: true, opacity: 0.98),
180
+ Three::MeshBasicMaterial.new(color: 0xdaf7ff, transparent: true, opacity: 0.94)
181
+ ]
182
+ sparkle_specs = [
183
+ [[-0.88, 1.08, 0.74], 0.2, 0.05],
184
+ [[1.0, 0.98, 0.8], 0.21, 1.15],
185
+ [[1.22, 0.36, 0.72], 0.18, 2.3],
186
+ [[-1.05, 0.18, 0.68], 0.17, 3.1],
187
+ [[0.34, 1.25, 0.74], 0.15, 4.0],
188
+ [[-0.32, 1.22, 0.7], 0.12, 5.2],
189
+ [[-0.48, 0.74, 0.96], 0.09, 0.65],
190
+ [[0.42, 0.68, 0.98], 0.095, 2.05],
191
+ [[-0.78, 0.26, 0.9], 0.08, 4.7]
192
+ ]
193
+ sparkles = sparkle_specs.each_with_index.map do |(position, size, phase), index|
194
+ sparkle = build_spark(size, spark_materials[index % spark_materials.length])
195
+ sparkle.position.set(*position)
196
+ sparkle.rotation.z = phase
197
+ scene.add(sparkle)
198
+ [sparkle, phase, size]
199
+ end
200
+
201
+ title_font = Three::Loaders::FontLoader.new.load("/node_modules/three/examples/fonts/helvetiker_regular.typeface.json")
202
+ title_geometry = Three::TextGeometry.new(
203
+ "three-rb",
204
+ font: title_font,
205
+ size: 0.48,
206
+ depth: 0.105,
207
+ curve_segments: 10,
208
+ bevel_enabled: true,
209
+ bevel_thickness: 0.018,
210
+ bevel_size: 0.01,
211
+ bevel_segments: 3
212
+ )
213
+ title_geometry.center
214
+ title_material = Three::MeshPhysicalMaterial.new(
215
+ color: 0x55ace0,
216
+ roughness: 0.18,
217
+ metalness: 0.05,
218
+ clearcoat: 0.72,
219
+ clearcoat_roughness: 0.16,
220
+ specular_intensity: 0.9,
221
+ specular_color: 0xe8fbff
222
+ )
223
+ title = Three::Mesh.new(title_geometry, title_material)
224
+ title.position.set(0, -1.18, 0.08)
225
+ title.rotation.x = -0.08
226
+ title.cast_shadow = true
227
+ scene.add(title)
228
+
229
+ accent_material = Three::MeshStandardMaterial.new(color: 0xffb3c4, roughness: 0.36, metalness: 0.08)
230
+ accent = Three::Mesh.new(Three::BoxGeometry.new(3.55, 0.035, 0.035), accent_material)
231
+ accent.position.set(0, -1.52, 0.03)
232
+ accent.rotation.z = -0.035
233
+ scene.add(accent)
234
+
235
+ renderer = Three::Renderers::ThreeJSRenderer.new(
236
+ canvas: "#scene",
237
+ antialias: true,
238
+ alpha: false,
239
+ preserveDrawingBuffer: true,
240
+ shadow_map_enabled: true,
241
+ shadow_map_type: Three::PCFSoftShadowMap
242
+ )
243
+ renderer.set_clear_color(0xffffff, 1)
244
+ renderer.tone_mapping = Three::ACESFilmicToneMapping
245
+ renderer.tone_mapping_exposure = 1.45
246
+
247
+ controls = Three::Controls::OrbitControls.new(
248
+ camera,
249
+ renderer: renderer,
250
+ enable_damping: true,
251
+ damping_factor: 0.07,
252
+ auto_rotate: true,
253
+ auto_rotate_speed: 0.72,
254
+ enable_pan: false
255
+ )
256
+ controls.target.set(0, 0.34, 0.08)
257
+
258
+ app.resize_renderer(renderer, camera)
259
+ renderer.render(scene, camera)
260
+
261
+ app.expose(
262
+ {
263
+ renderer: renderer,
264
+ controls: controls,
265
+ scene: scene,
266
+ camera: camera,
267
+ ruby_backdrop: backdrop,
268
+ ruby_backdrop_material: backdrop_material,
269
+ ruby_gem: ruby_gem,
270
+ ruby_geometry: ruby_gem.geometry,
271
+ ruby_material: ruby_material,
272
+ ruby_title: title,
273
+ ruby_title_geometry: title_geometry,
274
+ ruby_title_material: title_material,
275
+ ruby_sparkles: sparkles.map(&:first),
276
+ ruby_environment: environment_texture,
277
+ ruby_font_loaded: true,
278
+ ruby_frame: 0
279
+ },
280
+ renderer: renderer
281
+ )
282
+
283
+ frame = 0
284
+ renderer.animation_loop do
285
+ frame += 1
286
+ ruby_gem.rotation.y += 0.009
287
+ ruby_gem.rotation.z = Math.sin(frame * 0.012) * 0.045
288
+ title.rotation.x = -0.08 + (Math.sin(frame * 0.015) * 0.018)
289
+ title.rotation.y = Math.sin(frame * 0.018) * 0.055
290
+ title.position.y = -1.18 + (Math.sin(frame * 0.02) * 0.025)
291
+ title_twinkle = (Math.sin(frame * 0.048) + 1) / 2.0
292
+ title_material.color.set_rgb(0.28 + (0.12 * title_twinkle), 0.58 + (0.12 * title_twinkle), 0.78 + (0.14 * title_twinkle))
293
+ title_material.specular_intensity = 0.82 + (0.18 * title_twinkle)
294
+ title_material.clearcoat = 0.62 + (0.18 * title_twinkle)
295
+ accent.rotation.z = -0.035 + (Math.sin(frame * 0.018) * 0.008)
296
+ accent.position.y = -1.52 + (Math.sin((frame * 0.014) + 1.2) * 0.005)
297
+ accent.scale.x = 1.0 + (Math.sin(frame * 0.018) * 0.014)
298
+ sparkles.each_with_index do |(sparkle, phase, _size), index|
299
+ cycle = 156 + (index * 17)
300
+ flash_window = 22 + ((index % 3) * 3)
301
+ progress = (frame + (phase * 41).to_i) % cycle
302
+ burst = progress < flash_window ? Math.sin(Math::PI * progress / flash_window)**2.4 : 0
303
+ scale = 0.18 + (burst * 1.22)
304
+ sparkle.scale.set(scale, scale, scale)
305
+ sparkle.visible = burst > 0.08
306
+ sparkle.rotation.z += index.even? ? 0.026 : -0.021
307
+ end
308
+
309
+ app.set(:ruby_frame, frame)
310
+ controls.update
311
+ renderer.render(scene, camera)
312
+ end
313
+ end
@@ -0,0 +1,126 @@
1
+ import {
2
+ assertCanvasHasDimensions,
3
+ assertNoDiagnostics,
4
+ createDiagnostics,
5
+ createSmokePage,
6
+ loadPlaywright,
7
+ sampleCanvas,
8
+ startServer,
9
+ waitForRunning
10
+ } from "../shared/smoke_test_helpers.mjs";
11
+
12
+ async function redPixelCount(page) {
13
+ return page.evaluate(() => {
14
+ const element = document.querySelector("[data-testid='scene-canvas']");
15
+ const gl = element.getContext("webgl2") || element.getContext("webgl");
16
+ const width = element.width;
17
+ const height = element.height;
18
+ const pixels = new Uint8Array(width * height * 4);
19
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
20
+
21
+ let count = 0;
22
+ for (let index = 0; index < pixels.length; index += 4) {
23
+ const red = pixels[index];
24
+ const green = pixels[index + 1];
25
+ const blue = pixels[index + 2];
26
+ if (red > 120 && red > green * 1.18 && red > blue * 1.12) count += 1;
27
+ }
28
+ return count;
29
+ });
30
+ }
31
+
32
+ async function main() {
33
+ const { chromium } = await loadPlaywright();
34
+ const server = await startServer();
35
+ const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
36
+ const diagnostics = createDiagnostics();
37
+
38
+ try {
39
+ const page = await createSmokePage(browser, diagnostics, { viewport: { width: 1040, height: 680 } });
40
+
41
+ await page.goto(`${server.url}/examples/browser/ruby/`, { waitUntil: "load" });
42
+ await waitForRunning(page, diagnostics);
43
+ await page.waitForTimeout(1_000);
44
+
45
+ const canvas = await sampleCanvas(page);
46
+ assertCanvasHasDimensions(canvas);
47
+ if (canvas.nonBlankPixels === 0) {
48
+ throw new Error(`canvas sample is blank: ${JSON.stringify(canvas)}`);
49
+ }
50
+
51
+ const debug = await page.evaluate(() => ({
52
+ frame: globalThis.__threeRbRubyFrame,
53
+ sceneChildren: globalThis.__threeRbScene?.children?.length,
54
+ rubyType: globalThis.__threeRbRubyGem?.type,
55
+ rubyGeometryType: globalThis.__threeRbRubyGeometry?.type,
56
+ rubyPositionCount: globalThis.__threeRbRubyGeometry?.attributes?.position?.count,
57
+ rubyMaterial: {
58
+ type: globalThis.__threeRbRubyMaterial?.type,
59
+ color: globalThis.__threeRbRubyMaterial?.color?.getHex?.(),
60
+ transmission: globalThis.__threeRbRubyMaterial?.transmission,
61
+ thickness: globalThis.__threeRbRubyMaterial?.thickness,
62
+ ior: globalThis.__threeRbRubyMaterial?.ior,
63
+ clearcoat: globalThis.__threeRbRubyMaterial?.clearcoat
64
+ },
65
+ backdropMaterial: {
66
+ type: globalThis.__threeRbRubyBackdropMaterial?.type,
67
+ color: globalThis.__threeRbRubyBackdropMaterial?.color?.getHex?.(),
68
+ transparent: globalThis.__threeRbRubyBackdropMaterial?.transparent,
69
+ opacity: globalThis.__threeRbRubyBackdropMaterial?.opacity
70
+ },
71
+ sparkleCount: globalThis.__threeRbRubySparkles?.length,
72
+ titleType: globalThis.__threeRbRubyTitle?.type,
73
+ titleGeometryType: globalThis.__threeRbRubyTitleGeometry?.type,
74
+ titlePositionCount: globalThis.__threeRbRubyTitleGeometry?.attributes?.position?.count,
75
+ fontLoaded: globalThis.__threeRbRubyFontLoaded,
76
+ renderInfo: globalThis.__threeRbRenderer?.info?.render
77
+ }));
78
+
79
+ if (debug.rubyType !== "Mesh" || debug.rubyGeometryType !== "BufferGeometry") {
80
+ throw new Error(`ruby gemstone was not materialized as expected: ${JSON.stringify(debug, null, 2)}`);
81
+ }
82
+ if (debug.rubyPositionCount < 220) {
83
+ throw new Error(`ruby gemstone has too few facet vertices: ${JSON.stringify(debug, null, 2)}`);
84
+ }
85
+ if (debug.rubyMaterial.type !== "MeshPhysicalMaterial" || debug.rubyMaterial.transmission < 0.7) {
86
+ throw new Error(`ruby material is not transmissive: ${JSON.stringify(debug, null, 2)}`);
87
+ }
88
+ if (
89
+ debug.backdropMaterial.type !== "MeshBasicMaterial" ||
90
+ debug.backdropMaterial.color !== 0x58c2ff ||
91
+ !debug.backdropMaterial.transparent ||
92
+ debug.backdropMaterial.opacity > 0.12
93
+ ) {
94
+ throw new Error(`ruby backdrop is not a subtle transparent blue: ${JSON.stringify(debug, null, 2)}`);
95
+ }
96
+ if (debug.titleType !== "Mesh" || debug.titleGeometryType !== "TextGeometry" || !debug.fontLoaded) {
97
+ throw new Error(`title text geometry did not load: ${JSON.stringify(debug, null, 2)}`);
98
+ }
99
+ if (debug.sparkleCount < 5) {
100
+ throw new Error(`ruby sparkles did not materialize: ${JSON.stringify(debug, null, 2)}`);
101
+ }
102
+ if (debug.titlePositionCount < 200 || !debug.renderInfo || debug.renderInfo.triangles < 250) {
103
+ throw new Error(`title did not render enough geometry: ${JSON.stringify(debug, null, 2)}`);
104
+ }
105
+ if (debug.frame <= 0) {
106
+ throw new Error(`animation loop did not advance: ${JSON.stringify(debug, null, 2)}`);
107
+ }
108
+
109
+ const rubyPixels = await redPixelCount(page);
110
+ if (rubyPixels < 500) {
111
+ throw new Error(`ruby gemstone is not visibly red enough: redPixels=${rubyPixels}\n${JSON.stringify(debug, null, 2)}`);
112
+ }
113
+
114
+ assertNoDiagnostics(diagnostics);
115
+
116
+ console.log(`ruby smoke test passed at ${server.url}/examples/browser/ruby/`);
117
+ } finally {
118
+ await browser.close();
119
+ await new Promise((resolve) => server.instance.close(resolve));
120
+ }
121
+ }
122
+
123
+ main().catch((error) => {
124
+ console.error(error);
125
+ process.exitCode = 1;
126
+ });
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
2
+ <rect width="128" height="128" fill="#eef3f7"/>
3
+ <rect width="32" height="32" fill="#22313f"/>
4
+ <rect x="64" width="32" height="32" fill="#22313f"/>
5
+ <rect x="32" y="32" width="32" height="32" fill="#6ed69a"/>
6
+ <rect x="96" y="32" width="32" height="32" fill="#6ed69a"/>
7
+ <rect y="64" width="32" height="32" fill="#22313f"/>
8
+ <rect x="64" y="64" width="32" height="32" fill="#22313f"/>
9
+ <rect x="32" y="96" width="32" height="32" fill="#6ed69a"/>
10
+ <rect x="96" y="96" width="32" height="32" fill="#6ed69a"/>
11
+ </svg>
@@ -1,23 +1,12 @@
1
1
  # frozen_string_literal: true
2
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] = "Exporting Ruby scene"
3
+ require_relative "../../../lib/three"
16
4
 
5
+ Three::Browser.run(starting: "Exporting Ruby scene") do |app|
17
6
  source_scene = Three::Scene.new
18
7
  source_scene.name = "serialization-source"
19
8
 
20
- shared_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/assets/checker.svg")
9
+ shared_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/serialization/assets/checker.svg")
21
10
  shared_texture.wrap_s = Three::RepeatWrapping
22
11
  shared_texture.wrap_t = Three::RepeatWrapping
23
12
  shared_texture.mag_filter = Three::NearestFilter
@@ -53,45 +42,34 @@ begin
53
42
  )
54
43
  renderer.set_clear_color(0x101418, 1)
55
44
 
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)
45
+ app.resize_renderer(renderer, camera)
67
46
  renderer.render(scene, camera)
68
47
 
69
48
  left = scene.get_object_by_name("loaded-left")
70
49
  right = scene.get_object_by_name("loaded-right")
71
50
 
72
- JS.global[:__threeRbRenderer] = renderer.handle
73
- JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
74
- JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
75
- JS.global[:__threeRbSerializedJson] = json
76
- JS.global[:__threeRbLoadedLeft] = renderer.backend.materialize(left)
77
- JS.global[:__threeRbLoadedRight] = renderer.backend.materialize(right)
78
- JS.global[:__threeRbLoadedSharedGeometry] = left.geometry.equal?(right.geometry)
79
- JS.global[:__threeRbLoadedSharedMaterial] = left.material.equal?(right.material)
80
- JS.global[:__threeRbLoadedSharedTexture] = left.material.map.equal?(right.material.map)
81
- JS.global[:__threeRbSerializationFrame] = 0
51
+ app.expose(
52
+ {
53
+ renderer: renderer,
54
+ scene: scene,
55
+ camera: camera,
56
+ serialized_json: json,
57
+ loaded_left: left,
58
+ loaded_right: right,
59
+ loaded_shared_geometry: left.geometry.equal?(right.geometry),
60
+ loaded_shared_material: left.material.equal?(right.material),
61
+ loaded_shared_texture: left.material.map.equal?(right.material.map),
62
+ serialization_frame: 0
63
+ },
64
+ renderer: renderer
65
+ )
82
66
 
83
67
  renderer.animation_loop do
84
- JS.global[:__threeRbSerializationFrame] = JS.global[:__threeRbSerializationFrame].to_i + 1
68
+ app.increment(:serialization_frame)
85
69
  left.rotation.x += 0.012
86
70
  left.rotation.y += 0.018
87
71
  right.rotation.x -= 0.01
88
72
  right.rotation.y += 0.014
89
73
  renderer.render(scene, camera)
90
74
  end
91
-
92
- status[:textContent] = "Running"
93
- status_dot[:dataset][:state] = "running"
94
- rescue StandardError => error
95
- JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
96
- raise
97
75
  end
@@ -3,6 +3,8 @@ import * as THREE from "three";
3
3
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
4
4
  import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
5
5
  import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
6
+ import { FontLoader } from "three/addons/loaders/FontLoader.js";
7
+ import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
6
8
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
7
9
  import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
8
10
  import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
@@ -20,6 +22,8 @@ export async function bootRubyExample({ main, clearColor }) {
20
22
  globalThis.THREE_GLTF_LOADER = GLTFLoader;
21
23
  globalThis.THREE_DRACO_LOADER = DRACOLoader;
22
24
  globalThis.THREE_RGBE_LOADER = HDRLoader;
25
+ globalThis.THREE_FONT_LOADER = FontLoader;
26
+ globalThis.THREE_TEXT_GEOMETRY = TextGeometry;
23
27
  globalThis.THREE_ORBIT_CONTROLS = OrbitControls;
24
28
  globalThis.THREE_EFFECT_COMPOSER = EffectComposer;
25
29
  globalThis.THREE_RENDER_PASS = RenderPass;
@@ -50,17 +54,45 @@ export async function bootRubyExample({ main, clearColor }) {
50
54
  globalThis.rubyVM = vm;
51
55
 
52
56
  setStatus("Starting Ruby VM", "loading");
53
- await vm.evalAsync(`
54
- require "js/require_remote/relative_shim"
55
- JS::RequireRemote.instance.base_url = "/"
56
- JS::RequireRemote.instance.load(${JSON.stringify(main)})
57
- `);
57
+ await withNoStoreRubySourceFetch(async () => {
58
+ await vm.evalAsync(`
59
+ require "js/require_remote/relative_shim"
60
+ JS::RequireRemote.instance.base_url = "/"
61
+ JS::RequireRemote.instance.load(${JSON.stringify(main)})
62
+ `);
63
+ });
58
64
  } catch (error) {
59
65
  bootFailed(error && error.message ? error.message : "Ruby boot failed");
60
66
  throw error;
61
67
  }
62
68
  }
63
69
 
70
+ async function withNoStoreRubySourceFetch(callback) {
71
+ const originalFetch = globalThis.fetch;
72
+ globalThis.fetch = (input, init = {}) => {
73
+ if (!shouldBypassCache(input)) return originalFetch(input, init);
74
+
75
+ return originalFetch(input, { ...init, cache: "no-store" });
76
+ };
77
+
78
+ try {
79
+ return await callback();
80
+ } finally {
81
+ globalThis.fetch = originalFetch;
82
+ }
83
+ }
84
+
85
+ function shouldBypassCache(input) {
86
+ const rawUrl = typeof input === "string" ? input : input?.url;
87
+ if (!rawUrl) return false;
88
+
89
+ const { pathname } = new URL(rawUrl, globalThis.location?.href || "http://localhost/");
90
+ if (pathname.startsWith("/lib/")) return true;
91
+ if (!pathname.startsWith("/examples/browser/")) return false;
92
+
93
+ return !pathname.includes("/assets/");
94
+ }
95
+
64
96
  async function compileWasm(url) {
65
97
  const response = fetch(url);
66
98
  if (WebAssembly.compileStreaming) {
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
2
+ <rect width="128" height="128" fill="#eef3f7"/>
3
+ <rect width="32" height="32" fill="#22313f"/>
4
+ <rect x="64" width="32" height="32" fill="#22313f"/>
5
+ <rect x="32" y="32" width="32" height="32" fill="#6ed69a"/>
6
+ <rect x="96" y="32" width="32" height="32" fill="#6ed69a"/>
7
+ <rect y="64" width="32" height="32" fill="#22313f"/>
8
+ <rect x="64" y="64" width="32" height="32" fill="#22313f"/>
9
+ <rect x="32" y="96" width="32" height="32" fill="#6ed69a"/>
10
+ <rect x="96" y="96" width="32" height="32" fill="#6ed69a"/>
11
+ </svg>
@@ -0,0 +1,5 @@
1
+ #?RADIANCE
2
+ FORMAT=32-bit_rle_rgbe
3
+
4
+ -Y 2 +X 2
5
+ ~~~~~~~~~~~~~~~~