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,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "js"
4
+
5
+ begin
6
+ JS.global[:__threeReady].await
7
+
8
+ require_relative "../../../lib/three"
9
+
10
+ document = JS.global[:document]
11
+ window = JS.global[:window]
12
+ viewport = document.call(:querySelector, "#viewport")
13
+ status = document.call(:querySelector, "#status")
14
+ status_dot = document.call(:querySelector, "#status-dot")
15
+ status[:textContent] = "Starting Ruby scene"
16
+
17
+ scene = Three::Scene.new
18
+ camera = Three::PerspectiveCamera.new(70, aspect: 1.0, near: 0.1, far: 100)
19
+ camera.position.z = 3
20
+
21
+ geometry = Three::BoxGeometry.new(1, 1, 1)
22
+ material = Three::MeshBasicMaterial.new(color: 0x4ed08f)
23
+ cube = Three::Mesh.new(geometry, material)
24
+ scene.add(cube)
25
+
26
+ renderer = Three::Renderers::ThreeJSRenderer.new(
27
+ canvas: "#scene",
28
+ antialias: true,
29
+ alpha: false,
30
+ preserveDrawingBuffer: true
31
+ )
32
+ renderer.set_clear_color(0x101418, 1)
33
+
34
+ resize = proc do
35
+ width = [viewport[:clientWidth].to_i, 1].max
36
+ height = [viewport[:clientHeight].to_i, 1].max
37
+
38
+ camera.aspect = width.to_f / height
39
+ camera.update_projection_matrix
40
+ renderer.set_size(width, height)
41
+ end
42
+
43
+ resize.call
44
+ window.call(:addEventListener, "resize", resize)
45
+ renderer.render(scene, camera)
46
+ JS.global[:__threeRbRenderer] = renderer.handle
47
+ JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
48
+ JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
49
+ JS.global[:__threeRbCube] = renderer.backend.materialize(cube)
50
+
51
+ renderer.animation_loop do
52
+ cube.rotation.x += 0.01
53
+ cube.rotation.y += 0.015
54
+ renderer.render(scene, camera)
55
+ end
56
+
57
+ status[:textContent] = "Running"
58
+ status_dot[:dataset][:state] = "running"
59
+ rescue StandardError => error
60
+ JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
61
+ raise
62
+ end
@@ -0,0 +1,99 @@
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 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/cube/`, { waitUntil: "load" });
22
+ await waitForRunning(page, diagnostics);
23
+ await page.waitForTimeout(1_000);
24
+
25
+ const canvas = await sampleCanvas(page);
26
+ assertCanvasHasDimensions(canvas);
27
+ if (canvas.nonBlankPixels === 0) {
28
+ const debug = await page.evaluate(() => ({
29
+ sceneChildren: globalThis.__threeRbScene?.children?.length,
30
+ cubeType: globalThis.__threeRbCube?.type,
31
+ cubeVisible: globalThis.__threeRbCube?.visible,
32
+ cubeParentType: globalThis.__threeRbCube?.parent?.type,
33
+ cubePosition: globalThis.__threeRbCube?.position?.toArray?.(),
34
+ cubeScale: globalThis.__threeRbCube?.scale?.toArray?.(),
35
+ geometryType: globalThis.__threeRbCube?.geometry?.type,
36
+ geometryBoundingSphere: globalThis.__threeRbCube?.geometry?.boundingSphere,
37
+ materialType: globalThis.__threeRbCube?.material?.type,
38
+ materialKeys: globalThis.__threeRbCube?.material ? Object.keys(globalThis.__threeRbCube.material).slice(0, 20) : undefined,
39
+ materialColorType: globalThis.__threeRbCube?.material?.color?.constructor?.name,
40
+ materialColor: globalThis.__threeRbCube?.material?.color?.getHex?.(),
41
+ materialSide: globalThis.__threeRbCube?.material?.side,
42
+ materialOpacity: globalThis.__threeRbCube?.material?.opacity,
43
+ materialTransparent: globalThis.__threeRbCube?.material?.transparent,
44
+ materialVisible: globalThis.__threeRbCube?.material?.visible,
45
+ cameraType: globalThis.__threeRbCamera?.type,
46
+ cameraPosition: globalThis.__threeRbCamera?.position?.toArray?.(),
47
+ cameraFov: globalThis.__threeRbCamera?.fov,
48
+ cameraAspect: globalThis.__threeRbCamera?.aspect,
49
+ cameraNear: globalThis.__threeRbCamera?.near,
50
+ cameraFar: globalThis.__threeRbCamera?.far,
51
+ domElementSize: globalThis.__threeRbRenderer?.domElement && {
52
+ width: globalThis.__threeRbRenderer.domElement.width,
53
+ height: globalThis.__threeRbRenderer.domElement.height,
54
+ clientWidth: globalThis.__threeRbRenderer.domElement.clientWidth,
55
+ clientHeight: globalThis.__threeRbRenderer.domElement.clientHeight
56
+ },
57
+ rendererInfo: globalThis.__threeRbRenderer?.info?.render,
58
+ renderHelperType: typeof globalThis.__threeRbRender,
59
+ renderHelperCount: globalThis.__threeRbRenderCount,
60
+ renderHelperFrameCount: globalThis.__threeRbRenderFrameCount,
61
+ currentSceneChildren: globalThis.__threeRbCurrentScene?.children?.length,
62
+ currentCameraPosition: globalThis.__threeRbCurrentCamera?.position?.toArray?.(),
63
+ currentRendererMatches: globalThis.__threeRbCurrentRenderer === globalThis.__threeRbRenderer,
64
+ currentSceneMatches: globalThis.__threeRbCurrentScene === globalThis.__threeRbScene,
65
+ currentCameraMatches: globalThis.__threeRbCurrentCamera === globalThis.__threeRbCamera
66
+ }));
67
+ const direct = await page.evaluate(() => {
68
+ const renderer = globalThis.__threeRbRenderer;
69
+ renderer.setClearColor(0xff0000, 1);
70
+ renderer.clear(true, true, true);
71
+ renderer.render(globalThis.__threeRbScene, globalThis.__threeRbCamera);
72
+
73
+ const gl = renderer.domElement.getContext("webgl2") || renderer.domElement.getContext("webgl");
74
+ const corner = new Uint8Array(4);
75
+ const center = new Uint8Array(4);
76
+ gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, corner);
77
+ gl.readPixels(Math.floor(renderer.domElement.width / 2), Math.floor(renderer.domElement.height / 2), 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, center);
78
+ return { corner: Array.from(corner), center: Array.from(center), info: renderer.info.render };
79
+ });
80
+ throw new Error(`canvas sample is blank: ${JSON.stringify(canvas)} directClearPixel=${JSON.stringify(direct)}\n${JSON.stringify(debug, null, 2)}`);
81
+ }
82
+
83
+ const renderInfo = await page.evaluate(() => globalThis.__threeRbRenderer?.info?.render);
84
+ if (!renderInfo || renderInfo.triangles < 12) {
85
+ throw new Error(`renderer did not draw the cube triangles: ${JSON.stringify(renderInfo)}`);
86
+ }
87
+ assertNoDiagnostics(diagnostics);
88
+
89
+ console.log(`cube smoke test passed at ${server.url}/examples/browser/cube/`);
90
+ } finally {
91
+ await browser.close();
92
+ await new Promise((resolve) => server.instance.close(resolve));
93
+ }
94
+ }
95
+
96
+ main().catch((error) => {
97
+ console.error(error);
98
+ process.exitCode = 1;
99
+ });
@@ -0,0 +1,23 @@
1
+ # Browser Cubemap Example
2
+
3
+ This example renders a Ruby-authored scene through ruby.wasm and the JavaScript three.js renderer. It focuses on `CubeTextureLoader`, `CubeTexture`, and scene `background`/`environment` synchronization.
4
+
5
+ ## Run
6
+
7
+ From the repository root:
8
+
9
+ ```sh
10
+ ruby -run -e httpd . -p 8000
11
+ ```
12
+
13
+ Open:
14
+
15
+ ```text
16
+ http://127.0.0.1:8000/examples/browser/cubemap/
17
+ ```
18
+
19
+ ## Smoke Test
20
+
21
+ ```sh
22
+ pnpm test:browser:cubemap
23
+ ```
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/cubemap/main",
5
+ clearColor: 0x10151b
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 cubemap</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: #10151b;
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.04) 1px, transparent 1px),
42
+ linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
43
+ linear-gradient(135deg, #10151b 0%, #1b2430 45%, #151719 100%);
44
+ background-size: 34px 34px, 34px 34px, 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(8, 12, 16, 0.74);
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: #60d394;
83
+ box-shadow: 0 0 14px rgba(96, 211, 148, 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
+ </head>
132
+ <body>
133
+ <main id="viewport" class="viewport">
134
+ <canvas id="scene" data-testid="scene-canvas"></canvas>
135
+ <div class="hud" aria-live="polite">
136
+ <span id="status-dot" class="status-dot" data-state="loading"></span>
137
+ <span id="status" data-testid="status">Loading ruby.wasm</span>
138
+ </div>
139
+ </main>
140
+ <script type="module" src="./boot.mjs"></script>
141
+ </body>
142
+ </html>
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "js"
4
+
5
+ begin
6
+ JS.global[:__threeReady].await
7
+
8
+ require_relative "../../../lib/three"
9
+
10
+ document = JS.global[:document]
11
+ window = JS.global[:window]
12
+ viewport = document.call(:querySelector, "#viewport")
13
+ status = document.call(:querySelector, "#status")
14
+ status_dot = document.call(:querySelector, "#status-dot")
15
+ status[:textContent] = "Starting Ruby scene"
16
+
17
+ scene = Three::Scene.new
18
+ camera = Three::PerspectiveCamera.new(45, aspect: 1.0, near: 0.1, far: 100)
19
+ camera.position.set(0, 0.7, 4.0)
20
+
21
+ cube_sources = Array.new(6, "/examples/browser/assets/checker.svg")
22
+ cube_texture = Three::Loaders::CubeTextureLoader.new.load(cube_sources)
23
+ scene.background = cube_texture
24
+ scene.environment = cube_texture
25
+
26
+ scene.add(Three::AmbientLight.new(0xffffff, 0.3))
27
+
28
+ key_light = Three::DirectionalLight.new(0xffffff, 1.2)
29
+ key_light.position.set(3.0, 4.0, 5.0)
30
+ scene.add(key_light)
31
+
32
+ material = Three::MeshStandardMaterial.new(
33
+ color: 0xf4f1e8,
34
+ roughness: 0.18,
35
+ metalness: 0.38
36
+ )
37
+ mesh = Three::Mesh.new(Three::SphereGeometry.new(1.05, width_segments: 32, height_segments: 18), material)
38
+ mesh.rotation.y = 0.4
39
+ scene.add(mesh)
40
+
41
+ renderer = Three::Renderers::ThreeJSRenderer.new(
42
+ canvas: "#scene",
43
+ antialias: true,
44
+ alpha: false,
45
+ preserveDrawingBuffer: true
46
+ )
47
+ renderer.set_clear_color(0x10151b, 1)
48
+
49
+ resize = proc do
50
+ width = [viewport[:clientWidth].to_i, 1].max
51
+ height = [viewport[:clientHeight].to_i, 1].max
52
+
53
+ camera.aspect = width.to_f / height
54
+ camera.update_projection_matrix
55
+ renderer.set_size(width, height)
56
+ end
57
+
58
+ resize.call
59
+ window.call(:addEventListener, "resize", resize)
60
+ renderer.render(scene, camera)
61
+
62
+ JS.global[:__threeRbRenderer] = renderer.handle
63
+ JS.global[:__threeRbCubemapScene] = renderer.backend.materialize(scene)
64
+ JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
65
+ JS.global[:__threeRbCubemapMesh] = renderer.backend.materialize(mesh)
66
+ JS.global[:__threeRbCubemapMaterial] = renderer.backend.materialize(material)
67
+ JS.global[:__threeRbCubeTexture] = renderer.backend.materialize(cube_texture)
68
+ JS.global[:__threeRbCubemapFrame] = 0
69
+
70
+ frame = 0
71
+ renderer.animation_loop do
72
+ frame += 1
73
+ mesh.rotation.y += 0.01
74
+ mesh.rotation.x = Math.sin(frame * 0.02) * 0.08
75
+ JS.global[:__threeRbCubemapFrame] = frame
76
+ renderer.render(scene, camera)
77
+ end
78
+
79
+ status[:textContent] = "Running"
80
+ status_dot[:dataset][:state] = "running"
81
+ rescue StandardError => error
82
+ JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
83
+ raise
84
+ end
@@ -0,0 +1,91 @@
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/cubemap/`, { waitUntil: "load" });
22
+ await waitForRunning(page, diagnostics);
23
+ await page.waitForFunction(
24
+ () => {
25
+ const texture = globalThis.__threeRbCubeTexture;
26
+ return texture?.isCubeTexture === true &&
27
+ Array.isArray(texture.image) &&
28
+ texture.image.length === 6 &&
29
+ texture.image.every((image) => image?.complete === true);
30
+ },
31
+ null,
32
+ { timeout: 10_000 }
33
+ );
34
+ await page.waitForTimeout(1_000);
35
+
36
+ assertNonBlankCanvas(await sampleCanvas(page));
37
+
38
+ const scene = await page.evaluate(() => ({
39
+ frame: globalThis.__threeRbCubemapFrame,
40
+ renderInfo: globalThis.__threeRbRenderer?.info?.render,
41
+ cameraType: globalThis.__threeRbCamera?.type,
42
+ sceneChildren: globalThis.__threeRbCubemapScene?.children?.length,
43
+ meshType: globalThis.__threeRbCubemapMesh?.type,
44
+ geometryType: globalThis.__threeRbCubemapMesh?.geometry?.type,
45
+ materialType: globalThis.__threeRbCubemapMesh?.material?.type,
46
+ materialRoughness: globalThis.__threeRbCubemapMaterial?.roughness,
47
+ materialMetalness: globalThis.__threeRbCubemapMaterial?.metalness,
48
+ cubeTextureType: globalThis.__threeRbCubeTexture?.isCubeTexture,
49
+ cubeTextureImageCount: globalThis.__threeRbCubeTexture?.image?.length,
50
+ backgroundType: globalThis.__threeRbCubemapScene?.background?.isCubeTexture,
51
+ environmentType: globalThis.__threeRbCubemapScene?.environment?.isCubeTexture
52
+ }));
53
+
54
+ if (scene.cameraType !== "PerspectiveCamera") {
55
+ throw new Error(`expected a PerspectiveCamera cubemap view: ${JSON.stringify(scene)}`);
56
+ }
57
+ if (scene.meshType !== "Mesh" || scene.geometryType !== "SphereGeometry") {
58
+ throw new Error(`expected a sphere mesh: ${JSON.stringify(scene)}`);
59
+ }
60
+ if (scene.materialType !== "MeshStandardMaterial") {
61
+ throw new Error(`expected MeshStandardMaterial: ${JSON.stringify(scene)}`);
62
+ }
63
+ if (scene.materialRoughness !== 0.18 || scene.materialMetalness !== 0.38) {
64
+ throw new Error(`expected configured material values: ${JSON.stringify(scene)}`);
65
+ }
66
+ if (scene.cubeTextureType !== true || scene.cubeTextureImageCount !== 6) {
67
+ throw new Error(`expected a loaded cube texture: ${JSON.stringify(scene)}`);
68
+ }
69
+ if (scene.backgroundType !== true || scene.environmentType !== true) {
70
+ throw new Error(`expected scene background and environment cube textures: ${JSON.stringify(scene)}`);
71
+ }
72
+ if (!scene.renderInfo || scene.renderInfo.triangles < 500) {
73
+ throw new Error(`renderer did not draw the cubemap scene: ${JSON.stringify(scene)}`);
74
+ }
75
+ if (!scene.frame) {
76
+ throw new Error(`cubemap example animation did not advance: ${JSON.stringify(scene)}`);
77
+ }
78
+
79
+ assertNoDiagnostics(diagnostics);
80
+
81
+ console.log(`cubemap smoke test passed at ${server.url}/examples/browser/cubemap/`);
82
+ } finally {
83
+ await browser.close();
84
+ await new Promise((resolve) => server.instance.close(resolve));
85
+ }
86
+ }
87
+
88
+ main().catch((error) => {
89
+ console.error(error);
90
+ process.exitCode = 1;
91
+ });
@@ -0,0 +1,23 @@
1
+ # Browser glTF Example
2
+
3
+ This example renders small glTF assets through ruby.wasm and the JavaScript three.js renderer. It focuses on `GLTFLoader`, optional `DRACOLoader` decoder configuration for compressed geometry, adding loaded external scenes to the Ruby-authored scene graph, playing loaded clips through `AnimationMixer`, and disposing loaded subtrees.
4
+
5
+ ## Run
6
+
7
+ From the repository root:
8
+
9
+ ```sh
10
+ ruby -run -e httpd . -p 8000
11
+ ```
12
+
13
+ Open:
14
+
15
+ ```text
16
+ http://127.0.0.1:8000/examples/browser/gltf/
17
+ ```
18
+
19
+ ## Smoke Test
20
+
21
+ ```sh
22
+ pnpm test:browser:gltf
23
+ ```
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/gltf/main",
5
+ clearColor: 0x11151a
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 glTF</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: #11151a;
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.04) 1px, transparent 1px),
42
+ linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
43
+ linear-gradient(135deg, #11151a 0%, #182125 48%, #171719 100%);
44
+ background-size: 34px 34px, 34px 34px, 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(8, 12, 16, 0.74);
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: #60d394;
83
+ box-shadow: 0 0 14px rgba(96, 211, 148, 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
+ </head>
132
+ <body>
133
+ <main id="viewport" class="viewport">
134
+ <canvas id="scene" data-testid="scene-canvas"></canvas>
135
+ <div class="hud" aria-live="polite">
136
+ <span id="status-dot" class="status-dot" data-state="loading"></span>
137
+ <span id="status" data-testid="status">Loading ruby.wasm</span>
138
+ </div>
139
+ </main>
140
+ <script type="module" src="./boot.mjs"></script>
141
+ </body>
142
+ </html>