three-rb 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -1
  3. data/README.md +66 -3
  4. data/docs/browser-runtime.md +92 -24
  5. data/docs/loaded-assets-design.md +1 -1
  6. data/docs/next-work.md +9 -5
  7. data/docs/publishing.md +119 -23
  8. data/docs/release-readiness.md +5 -3
  9. data/docs/standalone-browser-app.md +106 -0
  10. data/examples/browser/README.md +8 -0
  11. data/examples/browser/composition/main.rb +44 -64
  12. data/examples/browser/cube/main.rb +4 -34
  13. data/examples/browser/cubemap/assets/checker.svg +11 -0
  14. data/examples/browser/cubemap/main.rb +17 -40
  15. data/examples/browser/gltf/main.rb +30 -50
  16. data/examples/browser/picking/main.rb +27 -53
  17. data/examples/browser/postprocessing/main.rb +23 -42
  18. data/examples/browser/primitives/assets/checker.svg +11 -0
  19. data/examples/browser/primitives/main.rb +19 -42
  20. data/examples/browser/ruby/README.md +24 -0
  21. data/examples/browser/ruby/boot.mjs +6 -0
  22. data/examples/browser/ruby/index.html +142 -0
  23. data/examples/browser/ruby/main.rb +313 -0
  24. data/examples/browser/ruby/smoke_test.mjs +126 -0
  25. data/examples/browser/serialization/assets/checker.svg +11 -0
  26. data/examples/browser/serialization/main.rb +20 -42
  27. data/examples/browser/shared/boot.mjs +37 -5
  28. data/examples/browser/textures/assets/checker.svg +11 -0
  29. data/examples/browser/textures/assets/studio.hdr +5 -0
  30. data/examples/browser/textures/main.rb +23 -41
  31. data/exe/three-rb +56 -0
  32. data/lib/three/backends/threejs/materialization.rb +6 -0
  33. data/lib/three/backends/threejs/parameters.rb +17 -0
  34. data/lib/three/backends/threejs/ruby_wasm_adapter.rb +166 -59
  35. data/lib/three/backends/threejs/synchronization.rb +38 -4
  36. data/lib/three/backends/threejs.rb +24 -0
  37. data/lib/three/browser.rb +389 -0
  38. data/lib/three/constants.rb +6 -0
  39. data/lib/three/core/buffer_attribute.rb +5 -1
  40. data/lib/three/core/buffer_geometry.rb +29 -1
  41. data/lib/three/core/object3d.rb +39 -1
  42. data/lib/three/exporters/three_json_exporter.rb +3 -0
  43. data/lib/three/generators/browser_example.rb +396 -0
  44. data/lib/three/geometries/text_geometry.rb +41 -0
  45. data/lib/three/loaders/font_loader.rb +29 -0
  46. data/lib/three/loaders/three_json_loader.rb +92 -46
  47. data/lib/three/materials/material.rb +2 -1
  48. data/lib/three/math/matrix4.rb +27 -0
  49. data/lib/three/renderers/threejs_renderer.rb +19 -0
  50. data/lib/three/scenes/fog.rb +86 -0
  51. data/lib/three/scenes/scene.rb +19 -1
  52. data/lib/three/textures/texture.rb +2 -1
  53. data/lib/three/version.rb +1 -1
  54. data/lib/three.rb +4 -0
  55. data/package.json +2 -1
  56. metadata +26 -8
  57. /data/examples/browser/{assets → composition/assets}/checker.svg +0 -0
  58. /data/examples/browser/{assets → gltf/assets}/animated_triangle.gltf +0 -0
  59. /data/examples/browser/{assets → gltf/assets}/compressed_triangle.gltf +0 -0
  60. /data/examples/browser/{assets → gltf/assets}/triangle.gltf +0 -0
  61. /data/examples/browser/{assets → ruby/assets}/studio.hdr +0 -0
@@ -1,19 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "js"
4
-
5
- begin
6
- JS.global[:__threeReady].await
7
-
8
- require_relative "../../../lib/three"
9
-
10
- document = JS.global[:document]
11
- window = JS.global[:window]
12
- viewport = document.call(:querySelector, "#viewport")
13
- status = document.call(:querySelector, "#status")
14
- status_dot = document.call(:querySelector, "#status-dot")
15
- status[:textContent] = "Starting picking scene"
3
+ require_relative "../../../lib/three"
16
4
 
5
+ Three::Browser.run(starting: "Starting picking scene") do |app|
17
6
  scene = Three::Scene.new
18
7
  camera = Three::PerspectiveCamera.new(60, aspect: 1.0, near: 0.1, far: 100)
19
8
  camera.position.z = 4
@@ -40,25 +29,15 @@ begin
40
29
  )
41
30
  renderer.set_clear_color(0x101418, 1)
42
31
 
43
- resize = proc do
44
- width = [viewport[:clientWidth].to_i, 1].max
45
- height = [viewport[:clientHeight].to_i, 1].max
46
-
47
- camera.aspect = width.to_f / height
48
- camera.update_projection_matrix
49
- renderer.set_size(width, height)
50
- end
51
-
52
32
  pointer = Three::Vector2.new
53
33
  raycaster = Three::Raycaster.new(backend: renderer.backend)
54
34
  pickables = [left_cube, right_cube]
55
35
  selected = nil
56
36
 
57
- pick = proc do |event|
58
- rect = renderer.dom_element.call(:getBoundingClientRect)
59
- x = ((event[:clientX].to_f - rect[:left].to_f) / rect[:width].to_f) * 2 - 1
60
- y = -(((event[:clientY].to_f - rect[:top].to_f) / rect[:height].to_f) * 2 - 1)
61
- pointer.set(x, y)
37
+ app.resize_renderer(renderer, camera)
38
+ canvas = app.element(renderer.dom_element)
39
+ canvas.on("click") do |event|
40
+ pointer.set(*canvas.pointer_ndc(event))
62
41
  raycaster.set_from_camera(pointer, camera)
63
42
  hits = raycaster.intersect_objects(pickables, recursive: false)
64
43
  hit = hits.find(&:object)
@@ -71,43 +50,38 @@ begin
71
50
  if hit
72
51
  selected = hit.object
73
52
  selected.material.color.set_hex(picked_color)
74
- JS.global[:__threeRbPickedName] = selected.name
75
- JS.global[:__threeRbPickedDistance] = hit.distance
76
- JS.global[:__threeRbPickedPoint] = hit.point.to_a
53
+ app.set(:picked_name, selected.name)
54
+ app.set(:picked_distance, hit.distance)
55
+ app.set(:picked_point, hit.point.to_a)
77
56
  else
78
- JS.global[:__threeRbPickedName] = nil
79
- JS.global[:__threeRbPickedDistance] = nil
80
- JS.global[:__threeRbPickedPoint] = nil
57
+ app.set(:picked_name, nil)
58
+ app.set(:picked_distance, nil)
59
+ app.set(:picked_point, nil)
81
60
  end
82
61
 
83
- JS.global[:__threeRbPickCount] = JS.global[:__threeRbPickCount].to_i + 1
62
+ app.increment(:pick_count)
84
63
  renderer.render(scene, camera)
85
64
  end
86
-
87
- resize.call
88
- window.call(:addEventListener, "resize", resize)
89
- renderer.dom_element.call(:addEventListener, "click", pick)
90
65
  renderer.render(scene, camera)
91
66
 
92
- JS.global[:__threeRbRenderer] = renderer.handle
93
- JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
94
- JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
95
- JS.global[:__threeRbLeftCube] = renderer.backend.materialize(left_cube)
96
- JS.global[:__threeRbRightCube] = renderer.backend.materialize(right_cube)
97
- JS.global[:__threeRbRaycaster] = raycaster.handle
98
- JS.global[:__threeRbPickCount] = 0
99
- JS.global[:__threeRbPickingFrame] = 0
67
+ app.expose(
68
+ {
69
+ renderer: renderer,
70
+ scene: scene,
71
+ camera: camera,
72
+ left_cube: left_cube,
73
+ right_cube: right_cube,
74
+ raycaster: raycaster,
75
+ pick_count: 0,
76
+ picking_frame: 0
77
+ },
78
+ renderer: renderer
79
+ )
100
80
 
101
81
  renderer.animation_loop do
102
- JS.global[:__threeRbPickingFrame] = JS.global[:__threeRbPickingFrame].to_i + 1
82
+ app.increment(:picking_frame)
103
83
  left_cube.rotation.y += 0.01
104
84
  right_cube.rotation.y -= 0.01
105
85
  renderer.render(scene, camera)
106
86
  end
107
-
108
- status[:textContent] = "Running"
109
- status_dot[:dataset][:state] = "running"
110
- rescue StandardError => error
111
- JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
112
- raise
113
87
  end
@@ -1,19 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "js"
4
-
5
- begin
6
- JS.global[:__threeReady].await
7
-
8
- require_relative "../../../lib/three"
9
-
10
- document = JS.global[:document]
11
- window = JS.global[:window]
12
- viewport = document.call(:querySelector, "#viewport")
13
- status = document.call(:querySelector, "#status")
14
- status_dot = document.call(:querySelector, "#status-dot")
15
- status[:textContent] = "Starting postprocessing scene"
3
+ require_relative "../../../lib/three"
16
4
 
5
+ Three::Browser.run(starting: "Starting postprocessing scene") do |app|
17
6
  scene = Three::Scene.new
18
7
  camera = Three::PerspectiveCamera.new(52, aspect: 1.0, near: 0.1, far: 100)
19
8
  camera.position.set(0, 0.15, 5.2)
@@ -65,37 +54,35 @@ begin
65
54
  right_accent.position.set(1.25, 0.52, 0.15)
66
55
  scene.add(right_accent)
67
56
 
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
57
+ app.on_resize do |width, height, aspect|
58
+ camera.aspect = aspect
73
59
  camera.update_projection_matrix
74
60
  renderer.set_size(width, height)
75
61
  composer.set_size(width, height)
76
62
  end
77
-
78
- resize.call
79
- window.call(:addEventListener, "resize", resize)
80
63
  composer.render(scene, camera)
81
64
 
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
65
+ app.expose(
66
+ {
67
+ renderer: renderer,
68
+ post_composer: composer,
69
+ post_render_pass: render_pass,
70
+ post_bloom_pass: bloom_pass,
71
+ post_dot_screen_pass: dot_screen_pass,
72
+ post_output_pass: output_pass,
73
+ scene: scene,
74
+ camera: camera,
75
+ post_core: core,
76
+ post_ring: ring,
77
+ post_left_accent: left_accent,
78
+ post_right_accent: right_accent,
79
+ post_frame: 0
80
+ },
81
+ renderer: renderer
82
+ )
95
83
 
96
84
  renderer.animation_loop do
97
- frame = JS.global[:__threeRbPostFrame].to_i + 1
98
- JS.global[:__threeRbPostFrame] = frame
85
+ frame = app.increment(:post_frame)
99
86
 
100
87
  core.rotation.y += 0.014
101
88
  ring.rotation.z += 0.01
@@ -108,10 +95,4 @@ begin
108
95
 
109
96
  composer.render(scene, camera)
110
97
  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
98
  end
@@ -0,0 +1,11 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
2
+ <rect width="128" height="128" fill="#eef3f7"/>
3
+ <rect width="32" height="32" fill="#22313f"/>
4
+ <rect x="64" width="32" height="32" fill="#22313f"/>
5
+ <rect x="32" y="32" width="32" height="32" fill="#6ed69a"/>
6
+ <rect x="96" y="32" width="32" height="32" fill="#6ed69a"/>
7
+ <rect y="64" width="32" height="32" fill="#22313f"/>
8
+ <rect x="64" y="64" width="32" height="32" fill="#22313f"/>
9
+ <rect x="32" y="96" width="32" height="32" fill="#6ed69a"/>
10
+ <rect x="96" y="96" width="32" height="32" fill="#6ed69a"/>
11
+ </svg>
@@ -1,19 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "js"
4
-
5
- begin
6
- JS.global[:__threeReady].await
7
-
8
- require_relative "../../../lib/three"
9
-
10
- document = JS.global[:document]
11
- window = JS.global[:window]
12
- viewport = document.call(:querySelector, "#viewport")
13
- status = document.call(:querySelector, "#status")
14
- status_dot = document.call(:querySelector, "#status-dot")
15
- status[:textContent] = "Starting primitives scene"
3
+ require_relative "../../../lib/three"
16
4
 
5
+ Three::Browser.run(starting: "Starting primitives scene") do |app|
17
6
  scene = Three::Scene.new
18
7
  camera = Three::PerspectiveCamera.new(55, aspect: 1.0, near: 0.1, far: 100)
19
8
  camera.position.z = 4
@@ -54,7 +43,7 @@ begin
54
43
  Three::PointsMaterial.new(color: 0xffcc4d, size: 12, size_attenuation: false)
55
44
  )
56
45
 
57
- sprite_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/assets/checker.svg")
46
+ sprite_texture = Three::Loaders::TextureLoader.new.load("/examples/browser/primitives/assets/checker.svg")
58
47
  sprite_material = Three::SpriteMaterial.new(
59
48
  color: 0xffffff,
60
49
  map: sprite_texture,
@@ -77,40 +66,28 @@ begin
77
66
  )
78
67
  renderer.set_clear_color(0x101418, 1)
79
68
 
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)
69
+ app.resize_renderer(renderer, camera)
91
70
  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
71
+ app.expose(
72
+ {
73
+ renderer: renderer,
74
+ scene: scene,
75
+ camera: camera,
76
+ line: line,
77
+ points: points,
78
+ sprite: sprite,
79
+ sprite_material: sprite_material,
80
+ sprite_texture: sprite_texture,
81
+ primitives_frame: 0
82
+ },
83
+ renderer: renderer
84
+ )
102
85
 
103
86
  renderer.animation_loop do
104
- JS.global[:__threeRbPrimitivesFrame] = JS.global[:__threeRbPrimitivesFrame].to_i + 1
87
+ app.increment(:primitives_frame)
105
88
  line.rotation.z += 0.004
106
89
  points.rotation.y += 0.012
107
90
  sprite_material.rotation += 0.01
108
91
  renderer.render(scene, camera)
109
92
  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
93
  end
@@ -0,0 +1,24 @@
1
+ # three-rb Ruby Example
2
+
3
+ This browser example is the first visual sample for three-rb. It builds a faceted red gemstone in Ruby with `BufferGeometry`, renders it with `MeshPhysicalMaterial`, and adds an extruded `three-rb` title through the three.js `TextGeometry` addon.
4
+
5
+ Its HDR environment file lives under `examples/browser/ruby/assets/` with the sample.
6
+
7
+ Run it from the repository root:
8
+
9
+ ```sh
10
+ pnpm install
11
+ ruby -run -e httpd . -p 8000
12
+ ```
13
+
14
+ Then open:
15
+
16
+ ```text
17
+ http://localhost:8000/examples/browser/ruby/
18
+ ```
19
+
20
+ Run its smoke test:
21
+
22
+ ```sh
23
+ pnpm test:browser:ruby
24
+ ```
@@ -0,0 +1,6 @@
1
+ import { bootRubyExample } from "../shared/boot.mjs";
2
+
3
+ await bootRubyExample({
4
+ main: "examples/browser/ruby/main",
5
+ clearColor: 0xf8fbff
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 ruby</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light;
10
+ font-family:
11
+ Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
12
+ "Segoe UI", sans-serif;
13
+ background: #f8fbff;
14
+ color: #243142;
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(104, 143, 168, 0.045) 1px, transparent 1px),
42
+ linear-gradient(90deg, rgba(104, 143, 168, 0.045) 1px, transparent 1px),
43
+ linear-gradient(155deg, #ffffff 0%, #f8fdff 44%, #fff9fb 74%, #f5fbf2 100%);
44
+ background-size: 36px 36px, 36px 36px, 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(84, 105, 130, 0.18);
63
+ border-radius: 6px;
64
+ background: rgba(255, 255, 255, 0.78);
65
+ box-shadow: 0 14px 34px rgba(53, 72, 94, 0.16);
66
+ color: #243142;
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: #f0b83e;
78
+ box-shadow: 0 0 14px rgba(240, 184, 62, 0.72);
79
+ }
80
+
81
+ .status-dot[data-state="running"] {
82
+ background: #cf1748;
83
+ box-shadow: 0 0 14px rgba(207, 23, 72, 0.72);
84
+ }
85
+
86
+ .status-dot[data-state="error"] {
87
+ background: #d64040;
88
+ box-shadow: 0 0 14px rgba(214, 64, 64, 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>