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,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 postprocessing</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: #0a0d12;
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, #0a0d12 0%, #101820 52%, #0d1118 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,117 @@
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 postprocessing scene"
16
+
17
+ scene = Three::Scene.new
18
+ camera = Three::PerspectiveCamera.new(52, aspect: 1.0, near: 0.1, far: 100)
19
+ camera.position.set(0, 0.15, 5.2)
20
+
21
+ renderer = Three::Renderers::ThreeJSRenderer.new(
22
+ canvas: "#scene",
23
+ antialias: true,
24
+ alpha: false,
25
+ preserveDrawingBuffer: true
26
+ )
27
+ renderer.set_clear_color(0x0a0d12, 1)
28
+
29
+ composer = Three::Postprocessing::EffectComposer.new(renderer: renderer)
30
+ render_pass = Three::Postprocessing::RenderPass.new(scene, camera, composer: composer)
31
+ bloom_pass = Three::Postprocessing::UnrealBloomPass.new(
32
+ resolution: [1, 1],
33
+ strength: 1.15,
34
+ radius: 0.42,
35
+ threshold: 0.22,
36
+ composer: composer
37
+ )
38
+ dot_screen_pass = Three::Postprocessing::DotScreenPass.new(
39
+ center: [0.5, 0.5],
40
+ angle: 0.72,
41
+ scale: 1.4,
42
+ composer: composer
43
+ )
44
+ output_pass = Three::Postprocessing::OutputPass.new(composer: composer)
45
+ composer.add_pass(render_pass)
46
+ composer.add_pass(bloom_pass)
47
+ composer.add_pass(dot_screen_pass)
48
+ composer.add_pass(output_pass)
49
+
50
+ core_material = Three::MeshBasicMaterial.new(color: 0xf8fbff)
51
+ core = Three::Mesh.new(Three::SphereGeometry.new(0.48, width_segments: 32, height_segments: 16), core_material)
52
+ scene.add(core)
53
+
54
+ ring_material = Three::MeshBasicMaterial.new(color: 0x4ed08f)
55
+ ring = Three::Mesh.new(Three::BoxGeometry.new(2.15, 0.035, 0.035), ring_material)
56
+ ring.position.z = -0.05
57
+ scene.add(ring)
58
+
59
+ accent_material = Three::MeshBasicMaterial.new(color: 0xffcc4d)
60
+ left_accent = Three::Mesh.new(Three::BoxGeometry.new(0.18, 0.18, 0.18), accent_material)
61
+ left_accent.position.set(-1.25, -0.52, 0.15)
62
+ scene.add(left_accent)
63
+
64
+ right_accent = Three::Mesh.new(Three::BoxGeometry.new(0.18, 0.18, 0.18), accent_material)
65
+ right_accent.position.set(1.25, 0.52, 0.15)
66
+ scene.add(right_accent)
67
+
68
+ resize = proc do
69
+ width = [viewport[:clientWidth].to_i, 1].max
70
+ height = [viewport[:clientHeight].to_i, 1].max
71
+
72
+ camera.aspect = width.to_f / height
73
+ camera.update_projection_matrix
74
+ renderer.set_size(width, height)
75
+ composer.set_size(width, height)
76
+ end
77
+
78
+ resize.call
79
+ window.call(:addEventListener, "resize", resize)
80
+ composer.render(scene, camera)
81
+
82
+ JS.global[:__threeRbRenderer] = renderer.handle
83
+ JS.global[:__threeRbPostComposer] = composer.handle
84
+ JS.global[:__threeRbPostRenderPass] = render_pass.handle
85
+ JS.global[:__threeRbPostBloomPass] = bloom_pass.handle
86
+ JS.global[:__threeRbPostDotScreenPass] = dot_screen_pass.handle
87
+ JS.global[:__threeRbPostOutputPass] = output_pass.handle
88
+ JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
89
+ JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
90
+ JS.global[:__threeRbPostCore] = renderer.backend.materialize(core)
91
+ JS.global[:__threeRbPostRing] = renderer.backend.materialize(ring)
92
+ JS.global[:__threeRbPostLeftAccent] = renderer.backend.materialize(left_accent)
93
+ JS.global[:__threeRbPostRightAccent] = renderer.backend.materialize(right_accent)
94
+ JS.global[:__threeRbPostFrame] = 0
95
+
96
+ renderer.animation_loop do
97
+ frame = JS.global[:__threeRbPostFrame].to_i + 1
98
+ JS.global[:__threeRbPostFrame] = frame
99
+
100
+ core.rotation.y += 0.014
101
+ ring.rotation.z += 0.01
102
+ left_accent.rotation.x += 0.018
103
+ left_accent.rotation.y += 0.013
104
+ right_accent.rotation.x -= 0.014
105
+ right_accent.rotation.y += 0.017
106
+ bloom_pass.strength = 0.95 + (0.22 * ((Math.sin(frame * 0.035) + 1) / 2.0))
107
+ dot_screen_pass.scale = 1.35 + (0.18 * ((Math.sin(frame * 0.025) + 1) / 2.0))
108
+
109
+ composer.render(scene, camera)
110
+ end
111
+
112
+ status[:textContent] = "Running"
113
+ status_dot[:dataset][:state] = "running"
114
+ rescue StandardError => error
115
+ JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
116
+ raise
117
+ end
@@ -0,0 +1,121 @@
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/postprocessing/`, { 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.__threeRbPostFrame,
29
+ renderCount: globalThis.__threeRbRenderCount,
30
+ renderFrameCount: globalThis.__threeRbRenderFrameCount,
31
+ renderInfo: globalThis.__threeRbRenderer?.info?.render,
32
+ composerPasses: globalThis.__threeRbPostComposer?.passes?.map((pass) => pass?.constructor?.name),
33
+ composerWidth: globalThis.__threeRbPostComposer?.renderTarget1?.width,
34
+ composerHeight: globalThis.__threeRbPostComposer?.renderTarget1?.height,
35
+ renderPassEnabled: globalThis.__threeRbPostRenderPass?.enabled,
36
+ renderPassSceneSame: globalThis.__threeRbPostRenderPass?.scene === globalThis.__threeRbScene,
37
+ renderPassCameraSame: globalThis.__threeRbPostRenderPass?.camera === globalThis.__threeRbCamera,
38
+ bloomPassEnabled: globalThis.__threeRbPostBloomPass?.enabled,
39
+ bloomStrength: globalThis.__threeRbPostBloomPass?.strength,
40
+ bloomRadius: globalThis.__threeRbPostBloomPass?.radius,
41
+ bloomThreshold: globalThis.__threeRbPostBloomPass?.threshold,
42
+ dotScreenPassEnabled: globalThis.__threeRbPostDotScreenPass?.enabled,
43
+ dotScreenPassType: globalThis.__threeRbPostDotScreenPass?.constructor?.name,
44
+ dotScreenCenter: globalThis.__threeRbPostDotScreenPass?.uniforms?.center?.value?.toArray?.(),
45
+ dotScreenAngle: globalThis.__threeRbPostDotScreenPass?.uniforms?.angle?.value,
46
+ dotScreenScale: globalThis.__threeRbPostDotScreenPass?.uniforms?.scale?.value,
47
+ outputPassEnabled: globalThis.__threeRbPostOutputPass?.enabled,
48
+ outputPassType: globalThis.__threeRbPostOutputPass?.constructor?.name,
49
+ outputPassFlag: globalThis.__threeRbPostOutputPass?.isOutputPass,
50
+ coreType: globalThis.__threeRbPostCore?.type,
51
+ coreGeometryType: globalThis.__threeRbPostCore?.geometry?.type,
52
+ ringType: globalThis.__threeRbPostRing?.type,
53
+ leftAccentType: globalThis.__threeRbPostLeftAccent?.type,
54
+ rightAccentType: globalThis.__threeRbPostRightAccent?.type
55
+ }));
56
+
57
+ if (!Array.isArray(state.composerPasses) || state.composerPasses.length !== 4) {
58
+ throw new Error(`expected four postprocessing passes: ${JSON.stringify(state)}`);
59
+ }
60
+ if (
61
+ state.composerPasses[0] !== "RenderPass" ||
62
+ state.composerPasses[1] !== "UnrealBloomPass" ||
63
+ state.composerPasses[2] !== "DotScreenPass" ||
64
+ state.composerPasses[3] !== "OutputPass"
65
+ ) {
66
+ throw new Error(`expected RenderPass, UnrealBloomPass, DotScreenPass, then OutputPass: ${JSON.stringify(state)}`);
67
+ }
68
+ if (state.composerWidth <= 0 || state.composerHeight <= 0) {
69
+ throw new Error(`expected composer render targets to be sized: ${JSON.stringify(state)}`);
70
+ }
71
+ if (state.renderPassEnabled !== true || state.renderPassSceneSame !== true || state.renderPassCameraSame !== true) {
72
+ throw new Error(`expected render pass to target the Ruby-authored scene and camera: ${JSON.stringify(state)}`);
73
+ }
74
+ if (
75
+ state.bloomPassEnabled !== true ||
76
+ state.bloomStrength < 0.9 ||
77
+ Math.abs(state.bloomRadius - 0.42) > 1e-12 ||
78
+ Math.abs(state.bloomThreshold - 0.22) > 1e-12
79
+ ) {
80
+ throw new Error(`expected configured bloom pass values: ${JSON.stringify(state)}`);
81
+ }
82
+ if (
83
+ state.dotScreenPassEnabled !== true ||
84
+ state.dotScreenPassType !== "DotScreenPass" ||
85
+ !Array.isArray(state.dotScreenCenter) ||
86
+ state.dotScreenCenter[0] !== 0.5 ||
87
+ state.dotScreenCenter[1] !== 0.5 ||
88
+ Math.abs(state.dotScreenAngle - 0.72) > 1e-12 ||
89
+ state.dotScreenScale < 1.3
90
+ ) {
91
+ throw new Error(`expected configured dot screen pass values: ${JSON.stringify(state)}`);
92
+ }
93
+ if (state.outputPassEnabled !== true || state.outputPassType !== "OutputPass" || state.outputPassFlag !== true) {
94
+ throw new Error(`expected configured output pass values: ${JSON.stringify(state)}`);
95
+ }
96
+ if (
97
+ state.coreType !== "Mesh" ||
98
+ state.coreGeometryType !== "SphereGeometry" ||
99
+ state.ringType !== "Mesh" ||
100
+ state.leftAccentType !== "Mesh" ||
101
+ state.rightAccentType !== "Mesh"
102
+ ) {
103
+ throw new Error(`postprocessing scene objects did not materialize: ${JSON.stringify(state)}`);
104
+ }
105
+ if (!state.renderInfo || state.renderInfo.calls < 1 || !state.frame || state.renderCount < 2 || state.renderFrameCount < 2) {
106
+ throw new Error(`postprocessing scene did not render or animate through composer: ${JSON.stringify(state)}`);
107
+ }
108
+
109
+ assertNoDiagnostics(diagnostics);
110
+
111
+ console.log(`postprocessing smoke test passed at ${server.url}/examples/browser/postprocessing/`);
112
+ } finally {
113
+ await browser.close();
114
+ await new Promise((resolve) => server.instance.close(resolve));
115
+ }
116
+ }
117
+
118
+ main().catch((error) => {
119
+ console.error(error);
120
+ process.exitCode = 1;
121
+ });
@@ -0,0 +1,33 @@
1
+ # Browser Primitives Example
2
+
3
+ This example renders Ruby-authored `Three::Line`, `Three::Points`, and `Three::Sprite` objects through the Three.js browser backend. It covers generic `BufferGeometry` attributes, line/points materials, and a textured billboard sprite marker.
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/primitives/
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:primitives
33
+ ```
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/primitives/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 primitives</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,116 @@
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 primitives scene"
16
+
17
+ scene = Three::Scene.new
18
+ camera = Three::PerspectiveCamera.new(55, aspect: 1.0, near: 0.1, far: 100)
19
+ camera.position.z = 4
20
+
21
+ line_geometry = Three::BufferGeometry.new
22
+ line_geometry.set_attribute(
23
+ :position,
24
+ Three::Float32BufferAttribute.new(
25
+ [
26
+ -1.8, -0.8, 0,
27
+ -0.9, 0.75, 0,
28
+ 0.0, -0.15, 0,
29
+ 0.9, 0.75, 0,
30
+ 1.8, -0.8, 0
31
+ ],
32
+ 3
33
+ )
34
+ )
35
+ line = Three::Line.new(line_geometry, Three::LineBasicMaterial.new(color: 0x66ddff, linewidth: 2))
36
+
37
+ points_geometry = Three::BufferGeometry.new
38
+ points_geometry.set_attribute(
39
+ :position,
40
+ Three::Float32BufferAttribute.new(
41
+ [
42
+ -1.3, 0.25, 0.15,
43
+ -0.65, -0.35, 0.25,
44
+ 0.0, 0.45, -0.2,
45
+ 0.65, -0.35, 0.25,
46
+ 1.3, 0.25, 0.15,
47
+ 0.0, -0.9, 0
48
+ ],
49
+ 3
50
+ )
51
+ )
52
+ points = Three::Points.new(
53
+ points_geometry,
54
+ Three::PointsMaterial.new(color: 0xffcc4d, size: 12, size_attenuation: false)
55
+ )
56
+
57
+ sprite_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/assets/checker.svg")
58
+ sprite_material = Three::SpriteMaterial.new(
59
+ color: 0xffffff,
60
+ map: sprite_texture,
61
+ rotation: 0.2,
62
+ size_attenuation: false,
63
+ opacity: 0.82
64
+ )
65
+ sprite = Three::Sprite.new(sprite_material)
66
+ sprite.center = [0.5, 0.5]
67
+ sprite.position.set(1.45, -0.95, 0.2)
68
+ sprite.scale.set(0.42, 0.42, 1)
69
+
70
+ scene.add(line, points, sprite)
71
+
72
+ renderer = Three::Renderers::ThreeJSRenderer.new(
73
+ canvas: "#scene",
74
+ antialias: true,
75
+ alpha: false,
76
+ preserveDrawingBuffer: true
77
+ )
78
+ renderer.set_clear_color(0x101418, 1)
79
+
80
+ resize = proc do
81
+ width = [viewport[:clientWidth].to_i, 1].max
82
+ height = [viewport[:clientHeight].to_i, 1].max
83
+
84
+ camera.aspect = width.to_f / height
85
+ camera.update_projection_matrix
86
+ renderer.set_size(width, height)
87
+ end
88
+
89
+ resize.call
90
+ window.call(:addEventListener, "resize", resize)
91
+ renderer.render(scene, camera)
92
+
93
+ JS.global[:__threeRbRenderer] = renderer.handle
94
+ JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
95
+ JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
96
+ JS.global[:__threeRbLine] = renderer.backend.materialize(line)
97
+ JS.global[:__threeRbPoints] = renderer.backend.materialize(points)
98
+ JS.global[:__threeRbSprite] = renderer.backend.materialize(sprite)
99
+ JS.global[:__threeRbSpriteMaterial] = renderer.backend.materialize(sprite_material)
100
+ JS.global[:__threeRbSpriteTexture] = renderer.backend.materialize(sprite_texture)
101
+ JS.global[:__threeRbPrimitivesFrame] = 0
102
+
103
+ renderer.animation_loop do
104
+ JS.global[:__threeRbPrimitivesFrame] = JS.global[:__threeRbPrimitivesFrame].to_i + 1
105
+ line.rotation.z += 0.004
106
+ points.rotation.y += 0.012
107
+ sprite_material.rotation += 0.01
108
+ renderer.render(scene, camera)
109
+ end
110
+
111
+ status[:textContent] = "Running"
112
+ status_dot[:dataset][:state] = "running"
113
+ rescue StandardError => error
114
+ JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
115
+ raise
116
+ end