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,113 @@
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/primitives/`, { waitUntil: "load" });
22
+ await waitForRunning(page, diagnostics);
23
+ await page.waitForFunction(
24
+ () => globalThis.__threeRbSpriteMaterial?.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 state = await page.evaluate(() => ({
33
+ frame: globalThis.__threeRbPrimitivesFrame,
34
+ line: {
35
+ type: globalThis.__threeRbLine?.type,
36
+ materialType: globalThis.__threeRbLine?.material?.type,
37
+ positionCount: globalThis.__threeRbLine?.geometry?.attributes?.position?.count,
38
+ color: globalThis.__threeRbLine?.material?.color?.getHex?.()
39
+ },
40
+ points: {
41
+ type: globalThis.__threeRbPoints?.type,
42
+ materialType: globalThis.__threeRbPoints?.material?.type,
43
+ positionCount: globalThis.__threeRbPoints?.geometry?.attributes?.position?.count,
44
+ color: globalThis.__threeRbPoints?.material?.color?.getHex?.(),
45
+ size: globalThis.__threeRbPoints?.material?.size,
46
+ sizeAttenuation: globalThis.__threeRbPoints?.material?.sizeAttenuation
47
+ },
48
+ sprite: {
49
+ type: globalThis.__threeRbSprite?.type,
50
+ materialType: globalThis.__threeRbSprite?.material?.type,
51
+ center: globalThis.__threeRbSprite?.center?.toArray?.(),
52
+ scale: globalThis.__threeRbSprite?.scale?.toArray?.(),
53
+ color: globalThis.__threeRbSpriteMaterial?.color?.getHex?.(),
54
+ opacity: globalThis.__threeRbSpriteMaterial?.opacity,
55
+ rotation: globalThis.__threeRbSpriteMaterial?.rotation,
56
+ sizeAttenuation: globalThis.__threeRbSpriteMaterial?.sizeAttenuation,
57
+ materialMapType: globalThis.__threeRbSpriteMaterial?.map?.isTexture,
58
+ mapSame: globalThis.__threeRbSpriteMaterial?.map === globalThis.__threeRbSpriteTexture,
59
+ textureWidth: globalThis.__threeRbSpriteTexture?.source?.data?.naturalWidth
60
+ },
61
+ renderInfo: globalThis.__threeRbRenderer?.info?.render
62
+ }));
63
+
64
+ if (state.line.type !== "Line" || state.line.materialType !== "LineBasicMaterial" || state.line.positionCount !== 5) {
65
+ throw new Error(`line primitive did not materialize correctly: ${JSON.stringify(state)}`);
66
+ }
67
+ if (
68
+ state.points.type !== "Points" ||
69
+ state.points.materialType !== "PointsMaterial" ||
70
+ state.points.positionCount !== 6 ||
71
+ state.points.size !== 12 ||
72
+ state.points.sizeAttenuation !== false
73
+ ) {
74
+ throw new Error(`points primitive did not materialize correctly: ${JSON.stringify(state)}`);
75
+ }
76
+ if (
77
+ state.sprite.type !== "Sprite" ||
78
+ state.sprite.materialType !== "SpriteMaterial" ||
79
+ state.sprite.color !== 0xffffff ||
80
+ state.sprite.opacity !== 0.82 ||
81
+ state.sprite.sizeAttenuation !== false ||
82
+ state.sprite.materialMapType !== true ||
83
+ state.sprite.mapSame !== true ||
84
+ state.sprite.textureWidth <= 0
85
+ ) {
86
+ throw new Error(`sprite primitive did not materialize correctly: ${JSON.stringify(state)}`);
87
+ }
88
+ if (!Array.isArray(state.sprite.center) || state.sprite.center[0] !== 0.5 || state.sprite.center[1] !== 0.5) {
89
+ throw new Error(`sprite center did not sync correctly: ${JSON.stringify(state)}`);
90
+ }
91
+ if (!Array.isArray(state.sprite.scale) || state.sprite.scale[0] !== 0.42 || state.sprite.scale[1] !== 0.42) {
92
+ throw new Error(`sprite transform did not sync correctly: ${JSON.stringify(state)}`);
93
+ }
94
+ if (state.sprite.rotation <= 0.2) {
95
+ throw new Error(`sprite material rotation did not animate: ${JSON.stringify(state)}`);
96
+ }
97
+ if (!state.renderInfo || state.renderInfo.calls < 2 || !state.frame) {
98
+ throw new Error(`primitives scene did not render or animate: ${JSON.stringify(state)}`);
99
+ }
100
+
101
+ assertNoDiagnostics(diagnostics);
102
+
103
+ console.log(`primitives smoke test passed at ${server.url}/examples/browser/primitives/`);
104
+ } finally {
105
+ await browser.close();
106
+ await new Promise((resolve) => server.instance.close(resolve));
107
+ }
108
+ }
109
+
110
+ main().catch((error) => {
111
+ console.error(error);
112
+ process.exitCode = 1;
113
+ });
@@ -0,0 +1,33 @@
1
+ # Browser Serialization Example
2
+
3
+ This example exports a Ruby-authored scene with shared geometry, material, and texture resources to the `ThreeJSONExporter` format, parses it back with `ThreeJSONLoader`, and renders the loaded scene through the three.js backend.
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/serialization/
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:serialization
33
+ ```
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/serialization/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 serialization</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,97 @@
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] = "Exporting Ruby scene"
16
+
17
+ source_scene = Three::Scene.new
18
+ source_scene.name = "serialization-source"
19
+
20
+ shared_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/assets/checker.svg")
21
+ shared_texture.wrap_s = Three::RepeatWrapping
22
+ shared_texture.wrap_t = Three::RepeatWrapping
23
+ shared_texture.mag_filter = Three::NearestFilter
24
+ shared_texture.min_filter = Three::NearestMipmapNearestFilter
25
+ shared_texture.repeat.set(2, 2)
26
+
27
+ geometry = Three::BoxGeometry.new(0.82, 0.82, 0.82)
28
+ material = Three::MeshBasicMaterial.new(color: 0xffffff, map: shared_texture)
29
+ first = Three::Mesh.new(geometry, material)
30
+ first.name = "loaded-left"
31
+ first.position.x = -0.68
32
+
33
+ second = Three::Mesh.new(geometry, material)
34
+ second.name = "loaded-right"
35
+ second.position.x = 0.68
36
+ second.scale.set(0.72, 0.72, 0.72)
37
+
38
+ source_scene.add(first, second)
39
+
40
+ exported = Three::Exporters::ThreeJSONExporter.new(deterministic_ids: true).export(source_scene)
41
+ json = JSON.generate(exported)
42
+ scene = Three::Loaders::ThreeJSONLoader.new.parse(json)
43
+ scene.name = "serialization-loaded"
44
+
45
+ camera = Three::PerspectiveCamera.new(62, aspect: 1.0, near: 0.1, far: 100)
46
+ camera.position.z = 3.4
47
+
48
+ renderer = Three::Renderers::ThreeJSRenderer.new(
49
+ canvas: "#scene",
50
+ antialias: true,
51
+ alpha: false,
52
+ preserveDrawingBuffer: true
53
+ )
54
+ renderer.set_clear_color(0x101418, 1)
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
+ left = scene.get_object_by_name("loaded-left")
70
+ right = scene.get_object_by_name("loaded-right")
71
+
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
82
+
83
+ renderer.animation_loop do
84
+ JS.global[:__threeRbSerializationFrame] = JS.global[:__threeRbSerializationFrame].to_i + 1
85
+ left.rotation.x += 0.012
86
+ left.rotation.y += 0.018
87
+ right.rotation.x -= 0.01
88
+ right.rotation.y += 0.014
89
+ renderer.render(scene, camera)
90
+ 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
+ end
@@ -0,0 +1,67 @@
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/serialization/`, { waitUntil: "load" });
22
+ await waitForRunning(page, diagnostics);
23
+ await page.waitForTimeout(1_000);
24
+
25
+ assertNonBlankCanvas(await sampleCanvas(page));
26
+
27
+ const state = await page.evaluate(() => ({
28
+ frame: globalThis.__threeRbSerializationFrame,
29
+ sceneChildren: globalThis.__threeRbScene?.children?.length,
30
+ leftType: globalThis.__threeRbLoadedLeft?.type,
31
+ rightType: globalThis.__threeRbLoadedRight?.type,
32
+ sharedGeometry: globalThis.__threeRbLoadedSharedGeometry,
33
+ sharedMaterial: globalThis.__threeRbLoadedSharedMaterial,
34
+ sharedTexture: globalThis.__threeRbLoadedSharedTexture,
35
+ serialized: JSON.parse(globalThis.__threeRbSerializedJson),
36
+ renderInfo: globalThis.__threeRbRenderer?.info?.render
37
+ }));
38
+
39
+ if (state.sceneChildren !== 2 || state.leftType !== "Mesh" || state.rightType !== "Mesh") {
40
+ throw new Error(`loaded scene graph is unexpected: ${JSON.stringify(state)}`);
41
+ }
42
+ if (!state.sharedGeometry || !state.sharedMaterial || !state.sharedTexture) {
43
+ throw new Error(`loaded resources were not shared: ${JSON.stringify(state)}`);
44
+ }
45
+ if (state.serialized.geometries?.length !== 1 || state.serialized.materials?.length !== 1 || state.serialized.textures?.length !== 1) {
46
+ throw new Error(`export did not deduplicate resources: ${JSON.stringify(state.serialized)}`);
47
+ }
48
+ if (!state.renderInfo || state.renderInfo.triangles < 24) {
49
+ throw new Error(`renderer did not draw the loaded meshes: ${JSON.stringify(state)}`);
50
+ }
51
+ if (!state.frame) {
52
+ throw new Error(`serialization example animation did not advance: ${JSON.stringify(state)}`);
53
+ }
54
+
55
+ assertNoDiagnostics(diagnostics);
56
+
57
+ console.log(`serialization smoke test passed at ${server.url}/examples/browser/serialization/`);
58
+ } finally {
59
+ await browser.close();
60
+ await new Promise((resolve) => server.instance.close(resolve));
61
+ }
62
+ }
63
+
64
+ main().catch((error) => {
65
+ console.error(error);
66
+ process.exitCode = 1;
67
+ });
@@ -0,0 +1,79 @@
1
+ import { DefaultRubyVM } from "@ruby/wasm-wasi/browser";
2
+ import * as THREE from "three";
3
+ import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
4
+ import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
5
+ import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
6
+ import { OrbitControls } from "three/addons/controls/OrbitControls.js";
7
+ import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
8
+ import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
9
+ import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
10
+ import { DotScreenPass } from "three/addons/postprocessing/DotScreenPass.js";
11
+ import { OutputPass } from "three/addons/postprocessing/OutputPass.js";
12
+
13
+ export async function bootRubyExample({ main, clearColor }) {
14
+ const setStatus = globalThis.__threeRbSetStatus || (() => {});
15
+ const bootFailed = globalThis.__threeRbBootFailed || ((message) => console.error(message));
16
+
17
+ try {
18
+ setStatus("Loading ruby.wasm", "loading");
19
+ globalThis.THREE = THREE;
20
+ globalThis.THREE_GLTF_LOADER = GLTFLoader;
21
+ globalThis.THREE_DRACO_LOADER = DRACOLoader;
22
+ globalThis.THREE_RGBE_LOADER = HDRLoader;
23
+ globalThis.THREE_ORBIT_CONTROLS = OrbitControls;
24
+ globalThis.THREE_EFFECT_COMPOSER = EffectComposer;
25
+ globalThis.THREE_RENDER_PASS = RenderPass;
26
+ globalThis.THREE_UNREAL_BLOOM_PASS = UnrealBloomPass;
27
+ globalThis.THREE_DOT_SCREEN_PASS = DotScreenPass;
28
+ globalThis.THREE_OUTPUT_PASS = OutputPass;
29
+ globalThis.__threeReady = Promise.resolve(THREE);
30
+ globalThis.__threeRbRenderCount = 0;
31
+ globalThis.__threeRbRenderFrameCount = 0;
32
+ globalThis.__threeRbRender = () => {
33
+ globalThis.__threeRbRenderCount += 1;
34
+ const renderer = globalThis.__threeRbCurrentRenderer;
35
+ const scene = globalThis.__threeRbCurrentScene;
36
+ const camera = globalThis.__threeRbCurrentCamera;
37
+ globalThis.__threeRbRenderFrameCount += 1;
38
+ renderer.setClearColor(clearColor, 1);
39
+ renderer.render(scene, camera);
40
+ };
41
+ globalThis.__threeRbRenderComposer = () => {
42
+ globalThis.__threeRbRenderCount += 1;
43
+ const composer = globalThis.__threeRbCurrentComposer;
44
+ globalThis.__threeRbRenderFrameCount += 1;
45
+ composer.render();
46
+ };
47
+
48
+ const rubyModule = await compileWasm("/node_modules/@ruby/3.4-wasm-wasi/dist/ruby+stdlib.wasm");
49
+ const { vm } = await DefaultRubyVM(rubyModule);
50
+ globalThis.rubyVM = vm;
51
+
52
+ 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
+ `);
58
+ } catch (error) {
59
+ bootFailed(error && error.message ? error.message : "Ruby boot failed");
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ async function compileWasm(url) {
65
+ const response = fetch(url);
66
+ if (WebAssembly.compileStreaming) {
67
+ try {
68
+ return await WebAssembly.compileStreaming(response);
69
+ } catch (_error) {
70
+ // Fall back when a static server does not provide application/wasm.
71
+ }
72
+ }
73
+
74
+ const fallbackResponse = await fetch(url);
75
+ if (!fallbackResponse.ok) {
76
+ throw new Error(`Failed to load ruby.wasm: ${fallbackResponse.status} ${fallbackResponse.statusText}`);
77
+ }
78
+ return WebAssembly.compile(await fallbackResponse.arrayBuffer());
79
+ }
@@ -0,0 +1,151 @@
1
+ import { createServer } from "node:http";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { extname, join, normalize, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const root = fileURLToPath(new URL("../../..", import.meta.url));
7
+ const mimeTypes = new Map([
8
+ [".html", "text/html; charset=utf-8"],
9
+ [".js", "text/javascript; charset=utf-8"],
10
+ [".mjs", "text/javascript; charset=utf-8"],
11
+ [".rb", "text/plain; charset=utf-8"],
12
+ [".css", "text/css; charset=utf-8"],
13
+ [".svg", "image/svg+xml"],
14
+ [".gltf", "model/gltf+json"],
15
+ [".hdr", "image/vnd.radiance"],
16
+ [".wasm", "application/wasm"]
17
+ ]);
18
+
19
+ export function createDiagnostics() {
20
+ return { errors: [], requests: [] };
21
+ }
22
+
23
+ export async function loadPlaywright() {
24
+ try {
25
+ return await import("playwright");
26
+ } catch (error) {
27
+ throw new Error(
28
+ "Playwright is required for the browser smoke test. Run `pnpm install` and `pnpm exec playwright install chromium` first.",
29
+ { cause: error }
30
+ );
31
+ }
32
+ }
33
+
34
+ export async function createSmokePage(browser, diagnostics, options = {}) {
35
+ const page = await browser.newPage({ viewport: options.viewport || { width: 960, height: 540 } });
36
+ page.on("console", (message) => {
37
+ if (message.type() === "error") diagnostics.errors.push(message.text());
38
+ });
39
+ page.on("pageerror", (error) => diagnostics.errors.push(error.message));
40
+ page.on("requestfailed", (request) => {
41
+ const failure = request.failure();
42
+ diagnostics.requests.push(`request failed: ${request.url()} ${failure ? failure.errorText : ""}`.trim());
43
+ });
44
+ page.on("response", (response) => {
45
+ if (response.status() >= 400) diagnostics.requests.push(`response ${response.status()}: ${response.url()}`);
46
+ });
47
+ return page;
48
+ }
49
+
50
+ export async function waitForRunning(page, diagnostics, timeout = 120_000) {
51
+ await page.getByTestId("status").waitFor({ state: "visible", timeout: 5_000 });
52
+ try {
53
+ await page.waitForFunction(
54
+ () => document.querySelector("[data-testid='status']")?.textContent === "Running",
55
+ null,
56
+ { timeout }
57
+ );
58
+ } catch (error) {
59
+ const status = await page.getByTestId("status").textContent();
60
+ throw new Error(diagnosticMessage(`timed out waiting for Running; current status: ${status}`, diagnostics), {
61
+ cause: error
62
+ });
63
+ }
64
+ }
65
+
66
+ export async function sampleCanvas(page) {
67
+ return page.evaluate(() => {
68
+ const element = document.querySelector("[data-testid='scene-canvas']");
69
+ if (!element) return { width: 0, height: 0, nonBlankPixels: 0, nonTransparentPixels: 0, maxRgb: 0 };
70
+
71
+ const gl = element.getContext("webgl2") || element.getContext("webgl");
72
+ if (!gl) throw new Error("canvas WebGL context is not available");
73
+
74
+ const width = element.width;
75
+ const height = element.height;
76
+ const pixels = new Uint8Array(width * height * 4);
77
+
78
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
79
+
80
+ let nonBlankPixels = 0;
81
+ let nonTransparentPixels = 0;
82
+ let maxRgb = 0;
83
+ for (let index = 0; index < pixels.length; index += 4) {
84
+ const rgb = pixels[index] + pixels[index + 1] + pixels[index + 2];
85
+ if (rgb > 12) nonBlankPixels += 1;
86
+ if (pixels[index + 3] > 0) nonTransparentPixels += 1;
87
+ if (rgb > maxRgb) maxRgb = rgb;
88
+ }
89
+
90
+ return { width, height, nonBlankPixels, nonTransparentPixels, maxRgb };
91
+ });
92
+ }
93
+
94
+ export function assertCanvasHasDimensions(canvas) {
95
+ if (canvas.width <= 0 || canvas.height <= 0) {
96
+ throw new Error(`canvas has invalid dimensions: ${canvas.width}x${canvas.height}`);
97
+ }
98
+ }
99
+
100
+ export function assertNonBlankCanvas(canvas) {
101
+ assertCanvasHasDimensions(canvas);
102
+ if (canvas.nonBlankPixels === 0) {
103
+ throw new Error(`canvas sample is blank: ${JSON.stringify(canvas)}`);
104
+ }
105
+ }
106
+
107
+ export function assertNoDiagnostics(diagnostics) {
108
+ if (diagnostics.errors.length > 0 || diagnostics.requests.length > 0) {
109
+ throw new Error(diagnosticMessage("browser diagnostics", diagnostics));
110
+ }
111
+ }
112
+
113
+ export function diagnosticMessage(message, diagnostics) {
114
+ const sections = [message];
115
+ if (diagnostics.errors.length > 0) sections.push(`console/page errors:\n${diagnostics.errors.join("\n")}`);
116
+ if (diagnostics.requests.length > 0) sections.push(`network diagnostics:\n${diagnostics.requests.join("\n")}`);
117
+ return sections.join("\n\n");
118
+ }
119
+
120
+ export function startServer() {
121
+ const server = createServer(async (request, response) => {
122
+ try {
123
+ const pathname = decodeURIComponent(new URL(request.url, "http://127.0.0.1").pathname);
124
+ const filePath = await resolveFile(pathname);
125
+ response.setHeader("Content-Type", mimeTypes.get(extname(filePath)) || "application/octet-stream");
126
+ response.end(await readFile(filePath));
127
+ } catch (error) {
128
+ response.statusCode = error.code === "ENOENT" ? 404 : 500;
129
+ response.end(error.message);
130
+ }
131
+ });
132
+
133
+ return new Promise((resolve, reject) => {
134
+ server.on("error", reject);
135
+ server.listen(0, "127.0.0.1", () => {
136
+ const { port } = server.address();
137
+ resolve({ instance: server, url: `http://127.0.0.1:${port}` });
138
+ });
139
+ });
140
+ }
141
+
142
+ async function resolveFile(pathname) {
143
+ const requested = pathname.endsWith("/") ? `${pathname}index.html` : pathname;
144
+ const filePath = normalize(join(root, requested));
145
+ const rel = relative(root, filePath);
146
+ if (rel.startsWith("..")) throw new Error("request escaped repository root");
147
+
148
+ const info = await stat(filePath);
149
+ if (info.isDirectory()) return join(filePath, "index.html");
150
+ return filePath;
151
+ }