three-rb 0.1.0 → 0.2.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -1
  3. data/README.md +64 -3
  4. data/docs/browser-runtime.md +90 -24
  5. data/docs/next-work.md +9 -5
  6. data/docs/publishing.md +116 -23
  7. data/docs/release-readiness.md +5 -3
  8. data/docs/standalone-browser-app.md +102 -0
  9. data/examples/browser/README.md +6 -0
  10. data/examples/browser/composition/main.rb +41 -61
  11. data/examples/browser/cube/main.rb +4 -34
  12. data/examples/browser/cubemap/main.rb +16 -39
  13. data/examples/browser/gltf/main.rb +28 -48
  14. data/examples/browser/picking/main.rb +27 -53
  15. data/examples/browser/postprocessing/main.rb +23 -42
  16. data/examples/browser/primitives/main.rb +18 -41
  17. data/examples/browser/ruby/README.md +22 -0
  18. data/examples/browser/ruby/boot.mjs +6 -0
  19. data/examples/browser/ruby/index.html +142 -0
  20. data/examples/browser/ruby/main.rb +313 -0
  21. data/examples/browser/ruby/smoke_test.mjs +126 -0
  22. data/examples/browser/serialization/main.rb +19 -41
  23. data/examples/browser/shared/boot.mjs +37 -5
  24. data/examples/browser/textures/main.rb +21 -39
  25. data/exe/three-rb +55 -0
  26. data/lib/three/backends/threejs/materialization.rb +6 -0
  27. data/lib/three/backends/threejs/parameters.rb +17 -0
  28. data/lib/three/backends/threejs/ruby_wasm_adapter.rb +166 -59
  29. data/lib/three/backends/threejs/synchronization.rb +38 -4
  30. data/lib/three/backends/threejs.rb +24 -0
  31. data/lib/three/browser.rb +389 -0
  32. data/lib/three/constants.rb +6 -0
  33. data/lib/three/core/buffer_attribute.rb +5 -1
  34. data/lib/three/core/buffer_geometry.rb +29 -1
  35. data/lib/three/core/object3d.rb +39 -1
  36. data/lib/three/exporters/three_json_exporter.rb +3 -0
  37. data/lib/three/generators/browser_example.rb +315 -0
  38. data/lib/three/geometries/text_geometry.rb +41 -0
  39. data/lib/three/loaders/font_loader.rb +29 -0
  40. data/lib/three/loaders/three_json_loader.rb +92 -46
  41. data/lib/three/materials/material.rb +2 -1
  42. data/lib/three/math/matrix4.rb +27 -0
  43. data/lib/three/renderers/threejs_renderer.rb +19 -0
  44. data/lib/three/scenes/fog.rb +86 -0
  45. data/lib/three/scenes/scene.rb +19 -1
  46. data/lib/three/textures/texture.rb +2 -1
  47. data/lib/three/version.rb +1 -1
  48. data/lib/three.rb +4 -0
  49. data/package.json +2 -1
  50. metadata +16 -3
@@ -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
@@ -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
@@ -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,22 @@
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
+ Run it from the repository root:
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/ruby/
16
+ ```
17
+
18
+ Run its smoke test:
19
+
20
+ ```sh
21
+ pnpm test:browser:ruby
22
+ ```
@@ -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>
@@ -0,0 +1,313 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../lib/three"
4
+
5
+ def gem_ring(radius, y, sides, offset)
6
+ sides.times.map do |index|
7
+ angle = offset + (Math::PI * 2 * index / sides)
8
+ [Math.cos(angle) * radius, y, Math.sin(angle) * radius]
9
+ end
10
+ end
11
+
12
+ def face_normal(a, b, c)
13
+ ux = b[0] - a[0]
14
+ uy = b[1] - a[1]
15
+ uz = b[2] - a[2]
16
+ vx = c[0] - a[0]
17
+ vy = c[1] - a[1]
18
+ vz = c[2] - a[2]
19
+ nx = (uy * vz) - (uz * vy)
20
+ ny = (uz * vx) - (ux * vz)
21
+ nz = (ux * vy) - (uy * vx)
22
+ length = Math.sqrt((nx * nx) + (ny * ny) + (nz * nz))
23
+ return [0, 1, 0] if length.zero?
24
+
25
+ [nx / length, ny / length, nz / length]
26
+ end
27
+
28
+ def add_triangle(vertices, normals, a, b, c)
29
+ normal = face_normal(a, b, c)
30
+ [a, b, c].each do |point|
31
+ vertices.push(*point)
32
+ normals.push(*normal)
33
+ end
34
+ end
35
+
36
+ def add_polygon(vertices, normals, points)
37
+ (1...(points.length - 1)).each do |index|
38
+ add_triangle(vertices, normals, points[0], points[index], points[index + 1])
39
+ end
40
+ end
41
+
42
+ def faceted_ruby_geometry
43
+ sides = 10
44
+ step = Math::PI * 2 / sides
45
+ top = gem_ring(0.62, 0.58, sides, step / 2)
46
+ crown = gem_ring(1.02, 0.18, sides, 0)
47
+ girdle = gem_ring(1.15, -0.08, sides, step / 2)
48
+ pavilion = gem_ring(0.64, -0.48, sides, 0)
49
+ culet = [0, -1.05, 0]
50
+ vertices = []
51
+ normals = []
52
+
53
+ add_polygon(vertices, normals, top.reverse)
54
+ sides.times do |index|
55
+ next_index = (index + 1) % sides
56
+ add_polygon(vertices, normals, [top[index], top[next_index], crown[next_index], crown[index]])
57
+ add_polygon(vertices, normals, [crown[index], crown[next_index], girdle[next_index], girdle[index]])
58
+ add_polygon(vertices, normals, [girdle[index], girdle[next_index], pavilion[next_index], pavilion[index]])
59
+ add_triangle(vertices, normals, pavilion[next_index], pavilion[index], culet)
60
+ end
61
+
62
+ geometry = Three::BufferGeometry.new
63
+ geometry.set_attribute(:position, Three::Float32BufferAttribute.new(vertices, 3))
64
+ geometry.set_attribute(:normal, Three::Float32BufferAttribute.new(normals, 3))
65
+ geometry.compute_bounding_box
66
+ geometry.compute_bounding_sphere
67
+ geometry
68
+ end
69
+
70
+ def build_spark(size, material)
71
+ spark = Three::Group.new
72
+ spark.name = "ruby-spark"
73
+
74
+ diamond_vertices = [
75
+ 0, size * 0.32, 0,
76
+ size * 0.22, 0, 0,
77
+ 0, -size * 0.32, 0,
78
+ -size * 0.22, 0, 0
79
+ ]
80
+ diamond_geometry = Three::BufferGeometry.new
81
+ diamond_geometry.set_index([0, 1, 2, 0, 2, 3])
82
+ diamond_geometry.set_attribute(:position, Three::Float32BufferAttribute.new(diamond_vertices, 3))
83
+ diamond_geometry.set_attribute(:normal, Three::Float32BufferAttribute.new([0, 0, 1] * 4, 3))
84
+ diamond = Three::Mesh.new(diamond_geometry, material)
85
+ spark.add(diamond)
86
+
87
+ long_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.72, size * 0.035, size * 0.035), material)
88
+ spark.add(long_ray)
89
+
90
+ cross_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.5, size * 0.03, size * 0.03), material)
91
+ cross_ray.rotation.z = Math::PI / 2
92
+ spark.add(cross_ray)
93
+
94
+ slash_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.38, size * 0.024, size * 0.024), material)
95
+ slash_ray.rotation.z = Math::PI / 4
96
+ spark.add(slash_ray)
97
+
98
+ backslash_ray = Three::Mesh.new(Three::BoxGeometry.new(size * 0.34, size * 0.02, size * 0.02), material)
99
+ backslash_ray.rotation.z = -Math::PI / 4
100
+ spark.add(backslash_ray)
101
+ spark
102
+ end
103
+
104
+ Three::Browser.run(starting: "Starting Ruby scene") do |app|
105
+ scene = Three::Scene.new
106
+ camera = Three::PerspectiveCamera.new(42, aspect: 1.0, near: 0.1, far: 100)
107
+ camera.position.set(0, 0.42, 6.9)
108
+
109
+ environment_texture = Three::Loaders::RGBELoader.new.load("/examples/browser/assets/studio.hdr")
110
+ scene.environment = environment_texture
111
+
112
+ scene.add(Three::AmbientLight.new(0xffffff, 0.82))
113
+ scene.add(Three::HemisphereLight.new(0xffffff, 0xffedf2, 1.0))
114
+
115
+ key_light = Three::DirectionalLight.new(0xffffff, 2.55)
116
+ key_light.position.set(3.2, 4.4, 4.8)
117
+ key_light.cast_shadow = true
118
+ key_light.shadow_map_size = [1024, 1024]
119
+ key_light.shadow_bias = -0.00008
120
+ key_light.set_shadow_camera(left: -3.2, right: 3.2, top: 2.6, bottom: -2.6, near: 0.2, far: 12)
121
+ scene.add(key_light)
122
+
123
+ rim_light = Three::PointLight.new(0xff89a4, 2.15, 8, 2)
124
+ rim_light.position.set(-2.2, 1.6, 2.8)
125
+ scene.add(rim_light)
126
+
127
+ cool_light = Three::PointLight.new(0xb4e6ff, 1.35, 7, 2)
128
+ cool_light.position.set(2.4, -0.8, 2.2)
129
+ scene.add(cool_light)
130
+
131
+ pin_light = Three::PointLight.new(0xffffff, 3.1, 4.4, 2)
132
+ pin_light.position.set(-1.6, 2.35, 3.25)
133
+ scene.add(pin_light)
134
+
135
+ backdrop_material = Three::MeshBasicMaterial.new(
136
+ color: 0x58c2ff,
137
+ transparent: true,
138
+ opacity: 0.1
139
+ )
140
+ backdrop = Three::Mesh.new(
141
+ Three::PlaneGeometry.new(7.2, 4.4, width_segments: 1, height_segments: 1),
142
+ backdrop_material
143
+ )
144
+ backdrop.position.z = -1.35
145
+ scene.add(backdrop)
146
+
147
+ ruby_material = Three::MeshPhysicalMaterial.new(
148
+ color: 0xff2d64,
149
+ roughness: 0.008,
150
+ metalness: 0,
151
+ opacity: 0.82,
152
+ transparent: true,
153
+ clearcoat: 1.0,
154
+ clearcoat_roughness: 0.006,
155
+ transmission: 0.98,
156
+ thickness: 0.58,
157
+ ior: 1.84,
158
+ dispersion: 0.42,
159
+ iridescence: 0.16,
160
+ iridescence_ior: 1.36,
161
+ iridescence_thickness_range: [120, 260],
162
+ specular_intensity: 1.0,
163
+ specular_color: 0xffffff,
164
+ attenuation_color: 0xff164b,
165
+ attenuation_distance: 2.05,
166
+ side: Three::DoubleSide,
167
+ vertex_colors: false
168
+ )
169
+ ruby_gem = Three::Mesh.new(faceted_ruby_geometry, ruby_material)
170
+ ruby_gem.position.set(0, 0.5, 0.1)
171
+ ruby_gem.rotation.x = -0.16
172
+ ruby_gem.rotation.y = 0.28
173
+ ruby_gem.scale.set(1.08, 1.08, 1.08)
174
+ ruby_gem.cast_shadow = true
175
+ scene.add(ruby_gem)
176
+
177
+ spark_materials = [
178
+ Three::MeshBasicMaterial.new(color: 0xffffff, transparent: true, opacity: 1.0),
179
+ Three::MeshBasicMaterial.new(color: 0xfff0a8, transparent: true, opacity: 0.98),
180
+ Three::MeshBasicMaterial.new(color: 0xdaf7ff, transparent: true, opacity: 0.94)
181
+ ]
182
+ sparkle_specs = [
183
+ [[-0.88, 1.08, 0.74], 0.2, 0.05],
184
+ [[1.0, 0.98, 0.8], 0.21, 1.15],
185
+ [[1.22, 0.36, 0.72], 0.18, 2.3],
186
+ [[-1.05, 0.18, 0.68], 0.17, 3.1],
187
+ [[0.34, 1.25, 0.74], 0.15, 4.0],
188
+ [[-0.32, 1.22, 0.7], 0.12, 5.2],
189
+ [[-0.48, 0.74, 0.96], 0.09, 0.65],
190
+ [[0.42, 0.68, 0.98], 0.095, 2.05],
191
+ [[-0.78, 0.26, 0.9], 0.08, 4.7]
192
+ ]
193
+ sparkles = sparkle_specs.each_with_index.map do |(position, size, phase), index|
194
+ sparkle = build_spark(size, spark_materials[index % spark_materials.length])
195
+ sparkle.position.set(*position)
196
+ sparkle.rotation.z = phase
197
+ scene.add(sparkle)
198
+ [sparkle, phase, size]
199
+ end
200
+
201
+ title_font = Three::Loaders::FontLoader.new.load("/node_modules/three/examples/fonts/helvetiker_regular.typeface.json")
202
+ title_geometry = Three::TextGeometry.new(
203
+ "three-rb",
204
+ font: title_font,
205
+ size: 0.48,
206
+ depth: 0.105,
207
+ curve_segments: 10,
208
+ bevel_enabled: true,
209
+ bevel_thickness: 0.018,
210
+ bevel_size: 0.01,
211
+ bevel_segments: 3
212
+ )
213
+ title_geometry.center
214
+ title_material = Three::MeshPhysicalMaterial.new(
215
+ color: 0x55ace0,
216
+ roughness: 0.18,
217
+ metalness: 0.05,
218
+ clearcoat: 0.72,
219
+ clearcoat_roughness: 0.16,
220
+ specular_intensity: 0.9,
221
+ specular_color: 0xe8fbff
222
+ )
223
+ title = Three::Mesh.new(title_geometry, title_material)
224
+ title.position.set(0, -1.18, 0.08)
225
+ title.rotation.x = -0.08
226
+ title.cast_shadow = true
227
+ scene.add(title)
228
+
229
+ accent_material = Three::MeshStandardMaterial.new(color: 0xffb3c4, roughness: 0.36, metalness: 0.08)
230
+ accent = Three::Mesh.new(Three::BoxGeometry.new(3.55, 0.035, 0.035), accent_material)
231
+ accent.position.set(0, -1.52, 0.03)
232
+ accent.rotation.z = -0.035
233
+ scene.add(accent)
234
+
235
+ renderer = Three::Renderers::ThreeJSRenderer.new(
236
+ canvas: "#scene",
237
+ antialias: true,
238
+ alpha: false,
239
+ preserveDrawingBuffer: true,
240
+ shadow_map_enabled: true,
241
+ shadow_map_type: Three::PCFSoftShadowMap
242
+ )
243
+ renderer.set_clear_color(0xffffff, 1)
244
+ renderer.tone_mapping = Three::ACESFilmicToneMapping
245
+ renderer.tone_mapping_exposure = 1.45
246
+
247
+ controls = Three::Controls::OrbitControls.new(
248
+ camera,
249
+ renderer: renderer,
250
+ enable_damping: true,
251
+ damping_factor: 0.07,
252
+ auto_rotate: true,
253
+ auto_rotate_speed: 0.72,
254
+ enable_pan: false
255
+ )
256
+ controls.target.set(0, 0.34, 0.08)
257
+
258
+ app.resize_renderer(renderer, camera)
259
+ renderer.render(scene, camera)
260
+
261
+ app.expose(
262
+ {
263
+ renderer: renderer,
264
+ controls: controls,
265
+ scene: scene,
266
+ camera: camera,
267
+ ruby_backdrop: backdrop,
268
+ ruby_backdrop_material: backdrop_material,
269
+ ruby_gem: ruby_gem,
270
+ ruby_geometry: ruby_gem.geometry,
271
+ ruby_material: ruby_material,
272
+ ruby_title: title,
273
+ ruby_title_geometry: title_geometry,
274
+ ruby_title_material: title_material,
275
+ ruby_sparkles: sparkles.map(&:first),
276
+ ruby_environment: environment_texture,
277
+ ruby_font_loaded: true,
278
+ ruby_frame: 0
279
+ },
280
+ renderer: renderer
281
+ )
282
+
283
+ frame = 0
284
+ renderer.animation_loop do
285
+ frame += 1
286
+ ruby_gem.rotation.y += 0.009
287
+ ruby_gem.rotation.z = Math.sin(frame * 0.012) * 0.045
288
+ title.rotation.x = -0.08 + (Math.sin(frame * 0.015) * 0.018)
289
+ title.rotation.y = Math.sin(frame * 0.018) * 0.055
290
+ title.position.y = -1.18 + (Math.sin(frame * 0.02) * 0.025)
291
+ title_twinkle = (Math.sin(frame * 0.048) + 1) / 2.0
292
+ title_material.color.set_rgb(0.28 + (0.12 * title_twinkle), 0.58 + (0.12 * title_twinkle), 0.78 + (0.14 * title_twinkle))
293
+ title_material.specular_intensity = 0.82 + (0.18 * title_twinkle)
294
+ title_material.clearcoat = 0.62 + (0.18 * title_twinkle)
295
+ accent.rotation.z = -0.035 + (Math.sin(frame * 0.018) * 0.008)
296
+ accent.position.y = -1.52 + (Math.sin((frame * 0.014) + 1.2) * 0.005)
297
+ accent.scale.x = 1.0 + (Math.sin(frame * 0.018) * 0.014)
298
+ sparkles.each_with_index do |(sparkle, phase, _size), index|
299
+ cycle = 156 + (index * 17)
300
+ flash_window = 22 + ((index % 3) * 3)
301
+ progress = (frame + (phase * 41).to_i) % cycle
302
+ burst = progress < flash_window ? Math.sin(Math::PI * progress / flash_window)**2.4 : 0
303
+ scale = 0.18 + (burst * 1.22)
304
+ sparkle.scale.set(scale, scale, scale)
305
+ sparkle.visible = burst > 0.08
306
+ sparkle.rotation.z += index.even? ? 0.026 : -0.021
307
+ end
308
+
309
+ app.set(:ruby_frame, frame)
310
+ controls.update
311
+ renderer.render(scene, camera)
312
+ end
313
+ end