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
@@ -0,0 +1,126 @@
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 redPixelCount(page) {
13
+ return page.evaluate(() => {
14
+ const element = document.querySelector("[data-testid='scene-canvas']");
15
+ const gl = element.getContext("webgl2") || element.getContext("webgl");
16
+ const width = element.width;
17
+ const height = element.height;
18
+ const pixels = new Uint8Array(width * height * 4);
19
+ gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
20
+
21
+ let count = 0;
22
+ for (let index = 0; index < pixels.length; index += 4) {
23
+ const red = pixels[index];
24
+ const green = pixels[index + 1];
25
+ const blue = pixels[index + 2];
26
+ if (red > 120 && red > green * 1.18 && red > blue * 1.12) count += 1;
27
+ }
28
+ return count;
29
+ });
30
+ }
31
+
32
+ async function main() {
33
+ const { chromium } = await loadPlaywright();
34
+ const server = await startServer();
35
+ const browser = await chromium.launch({ headless: process.env.HEADLESS !== "0" });
36
+ const diagnostics = createDiagnostics();
37
+
38
+ try {
39
+ const page = await createSmokePage(browser, diagnostics, { viewport: { width: 1040, height: 680 } });
40
+
41
+ await page.goto(`${server.url}/examples/browser/ruby/`, { waitUntil: "load" });
42
+ await waitForRunning(page, diagnostics);
43
+ await page.waitForTimeout(1_000);
44
+
45
+ const canvas = await sampleCanvas(page);
46
+ assertCanvasHasDimensions(canvas);
47
+ if (canvas.nonBlankPixels === 0) {
48
+ throw new Error(`canvas sample is blank: ${JSON.stringify(canvas)}`);
49
+ }
50
+
51
+ const debug = await page.evaluate(() => ({
52
+ frame: globalThis.__threeRbRubyFrame,
53
+ sceneChildren: globalThis.__threeRbScene?.children?.length,
54
+ rubyType: globalThis.__threeRbRubyGem?.type,
55
+ rubyGeometryType: globalThis.__threeRbRubyGeometry?.type,
56
+ rubyPositionCount: globalThis.__threeRbRubyGeometry?.attributes?.position?.count,
57
+ rubyMaterial: {
58
+ type: globalThis.__threeRbRubyMaterial?.type,
59
+ color: globalThis.__threeRbRubyMaterial?.color?.getHex?.(),
60
+ transmission: globalThis.__threeRbRubyMaterial?.transmission,
61
+ thickness: globalThis.__threeRbRubyMaterial?.thickness,
62
+ ior: globalThis.__threeRbRubyMaterial?.ior,
63
+ clearcoat: globalThis.__threeRbRubyMaterial?.clearcoat
64
+ },
65
+ backdropMaterial: {
66
+ type: globalThis.__threeRbRubyBackdropMaterial?.type,
67
+ color: globalThis.__threeRbRubyBackdropMaterial?.color?.getHex?.(),
68
+ transparent: globalThis.__threeRbRubyBackdropMaterial?.transparent,
69
+ opacity: globalThis.__threeRbRubyBackdropMaterial?.opacity
70
+ },
71
+ sparkleCount: globalThis.__threeRbRubySparkles?.length,
72
+ titleType: globalThis.__threeRbRubyTitle?.type,
73
+ titleGeometryType: globalThis.__threeRbRubyTitleGeometry?.type,
74
+ titlePositionCount: globalThis.__threeRbRubyTitleGeometry?.attributes?.position?.count,
75
+ fontLoaded: globalThis.__threeRbRubyFontLoaded,
76
+ renderInfo: globalThis.__threeRbRenderer?.info?.render
77
+ }));
78
+
79
+ if (debug.rubyType !== "Mesh" || debug.rubyGeometryType !== "BufferGeometry") {
80
+ throw new Error(`ruby gemstone was not materialized as expected: ${JSON.stringify(debug, null, 2)}`);
81
+ }
82
+ if (debug.rubyPositionCount < 220) {
83
+ throw new Error(`ruby gemstone has too few facet vertices: ${JSON.stringify(debug, null, 2)}`);
84
+ }
85
+ if (debug.rubyMaterial.type !== "MeshPhysicalMaterial" || debug.rubyMaterial.transmission < 0.7) {
86
+ throw new Error(`ruby material is not transmissive: ${JSON.stringify(debug, null, 2)}`);
87
+ }
88
+ if (
89
+ debug.backdropMaterial.type !== "MeshBasicMaterial" ||
90
+ debug.backdropMaterial.color !== 0x58c2ff ||
91
+ !debug.backdropMaterial.transparent ||
92
+ debug.backdropMaterial.opacity > 0.12
93
+ ) {
94
+ throw new Error(`ruby backdrop is not a subtle transparent blue: ${JSON.stringify(debug, null, 2)}`);
95
+ }
96
+ if (debug.titleType !== "Mesh" || debug.titleGeometryType !== "TextGeometry" || !debug.fontLoaded) {
97
+ throw new Error(`title text geometry did not load: ${JSON.stringify(debug, null, 2)}`);
98
+ }
99
+ if (debug.sparkleCount < 5) {
100
+ throw new Error(`ruby sparkles did not materialize: ${JSON.stringify(debug, null, 2)}`);
101
+ }
102
+ if (debug.titlePositionCount < 200 || !debug.renderInfo || debug.renderInfo.triangles < 250) {
103
+ throw new Error(`title did not render enough geometry: ${JSON.stringify(debug, null, 2)}`);
104
+ }
105
+ if (debug.frame <= 0) {
106
+ throw new Error(`animation loop did not advance: ${JSON.stringify(debug, null, 2)}`);
107
+ }
108
+
109
+ const rubyPixels = await redPixelCount(page);
110
+ if (rubyPixels < 500) {
111
+ throw new Error(`ruby gemstone is not visibly red enough: redPixels=${rubyPixels}\n${JSON.stringify(debug, null, 2)}`);
112
+ }
113
+
114
+ assertNoDiagnostics(diagnostics);
115
+
116
+ console.log(`ruby smoke test passed at ${server.url}/examples/browser/ruby/`);
117
+ } finally {
118
+ await browser.close();
119
+ await new Promise((resolve) => server.instance.close(resolve));
120
+ }
121
+ }
122
+
123
+ main().catch((error) => {
124
+ console.error(error);
125
+ process.exitCode = 1;
126
+ });
@@ -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] = "Exporting Ruby scene"
3
+ require_relative "../../../lib/three"
16
4
 
5
+ Three::Browser.run(starting: "Exporting Ruby scene") do |app|
17
6
  source_scene = Three::Scene.new
18
7
  source_scene.name = "serialization-source"
19
8
 
@@ -53,45 +42,34 @@ begin
53
42
  )
54
43
  renderer.set_clear_color(0x101418, 1)
55
44
 
56
- resize = proc do
57
- width = [viewport[:clientWidth].to_i, 1].max
58
- height = [viewport[:clientHeight].to_i, 1].max
59
-
60
- camera.aspect = width.to_f / height
61
- camera.update_projection_matrix
62
- renderer.set_size(width, height)
63
- end
64
-
65
- resize.call
66
- window.call(:addEventListener, "resize", resize)
45
+ app.resize_renderer(renderer, camera)
67
46
  renderer.render(scene, camera)
68
47
 
69
48
  left = scene.get_object_by_name("loaded-left")
70
49
  right = scene.get_object_by_name("loaded-right")
71
50
 
72
- JS.global[:__threeRbRenderer] = renderer.handle
73
- JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
74
- JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
75
- JS.global[:__threeRbSerializedJson] = json
76
- JS.global[:__threeRbLoadedLeft] = renderer.backend.materialize(left)
77
- JS.global[:__threeRbLoadedRight] = renderer.backend.materialize(right)
78
- JS.global[:__threeRbLoadedSharedGeometry] = left.geometry.equal?(right.geometry)
79
- JS.global[:__threeRbLoadedSharedMaterial] = left.material.equal?(right.material)
80
- JS.global[:__threeRbLoadedSharedTexture] = left.material.map.equal?(right.material.map)
81
- JS.global[:__threeRbSerializationFrame] = 0
51
+ app.expose(
52
+ {
53
+ renderer: renderer,
54
+ scene: scene,
55
+ camera: camera,
56
+ serialized_json: json,
57
+ loaded_left: left,
58
+ loaded_right: right,
59
+ loaded_shared_geometry: left.geometry.equal?(right.geometry),
60
+ loaded_shared_material: left.material.equal?(right.material),
61
+ loaded_shared_texture: left.material.map.equal?(right.material.map),
62
+ serialization_frame: 0
63
+ },
64
+ renderer: renderer
65
+ )
82
66
 
83
67
  renderer.animation_loop do
84
- JS.global[:__threeRbSerializationFrame] = JS.global[:__threeRbSerializationFrame].to_i + 1
68
+ app.increment(:serialization_frame)
85
69
  left.rotation.x += 0.012
86
70
  left.rotation.y += 0.018
87
71
  right.rotation.x -= 0.01
88
72
  right.rotation.y += 0.014
89
73
  renderer.render(scene, camera)
90
74
  end
91
-
92
- status[:textContent] = "Running"
93
- status_dot[:dataset][:state] = "running"
94
- rescue StandardError => error
95
- JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
96
- raise
97
75
  end
@@ -3,6 +3,8 @@ import * as THREE from "three";
3
3
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
4
4
  import { DRACOLoader } from "three/addons/loaders/DRACOLoader.js";
5
5
  import { HDRLoader } from "three/addons/loaders/HDRLoader.js";
6
+ import { FontLoader } from "three/addons/loaders/FontLoader.js";
7
+ import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
6
8
  import { OrbitControls } from "three/addons/controls/OrbitControls.js";
7
9
  import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
8
10
  import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
@@ -20,6 +22,8 @@ export async function bootRubyExample({ main, clearColor }) {
20
22
  globalThis.THREE_GLTF_LOADER = GLTFLoader;
21
23
  globalThis.THREE_DRACO_LOADER = DRACOLoader;
22
24
  globalThis.THREE_RGBE_LOADER = HDRLoader;
25
+ globalThis.THREE_FONT_LOADER = FontLoader;
26
+ globalThis.THREE_TEXT_GEOMETRY = TextGeometry;
23
27
  globalThis.THREE_ORBIT_CONTROLS = OrbitControls;
24
28
  globalThis.THREE_EFFECT_COMPOSER = EffectComposer;
25
29
  globalThis.THREE_RENDER_PASS = RenderPass;
@@ -50,17 +54,45 @@ export async function bootRubyExample({ main, clearColor }) {
50
54
  globalThis.rubyVM = vm;
51
55
 
52
56
  setStatus("Starting Ruby VM", "loading");
53
- await vm.evalAsync(`
54
- require "js/require_remote/relative_shim"
55
- JS::RequireRemote.instance.base_url = "/"
56
- JS::RequireRemote.instance.load(${JSON.stringify(main)})
57
- `);
57
+ await withNoStoreRubySourceFetch(async () => {
58
+ await vm.evalAsync(`
59
+ require "js/require_remote/relative_shim"
60
+ JS::RequireRemote.instance.base_url = "/"
61
+ JS::RequireRemote.instance.load(${JSON.stringify(main)})
62
+ `);
63
+ });
58
64
  } catch (error) {
59
65
  bootFailed(error && error.message ? error.message : "Ruby boot failed");
60
66
  throw error;
61
67
  }
62
68
  }
63
69
 
70
+ async function withNoStoreRubySourceFetch(callback) {
71
+ const originalFetch = globalThis.fetch;
72
+ globalThis.fetch = (input, init = {}) => {
73
+ if (!shouldBypassCache(input)) return originalFetch(input, init);
74
+
75
+ return originalFetch(input, { ...init, cache: "no-store" });
76
+ };
77
+
78
+ try {
79
+ return await callback();
80
+ } finally {
81
+ globalThis.fetch = originalFetch;
82
+ }
83
+ }
84
+
85
+ function shouldBypassCache(input) {
86
+ const rawUrl = typeof input === "string" ? input : input?.url;
87
+ if (!rawUrl) return false;
88
+
89
+ const { pathname } = new URL(rawUrl, globalThis.location?.href || "http://localhost/");
90
+ if (pathname.startsWith("/lib/")) return true;
91
+ if (!pathname.startsWith("/examples/browser/")) return false;
92
+
93
+ return !pathname.startsWith("/examples/browser/assets/");
94
+ }
95
+
64
96
  async function compileWasm(url) {
65
97
  const response = fetch(url);
66
98
  if (WebAssembly.compileStreaming) {
@@ -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 Ruby scene"
3
+ require_relative "../../../lib/three"
16
4
 
5
+ Three::Browser.run(starting: "Starting Ruby scene") do |app|
17
6
  scene = Three::Scene.new
18
7
  camera = Three::OrthographicCamera.new(-2.5, 2.5, 1.6, -1.6, near: 0.1, far: 100)
19
8
  camera.position.z = 5
@@ -91,9 +80,7 @@ begin
91
80
  )
92
81
  renderer.set_clear_color(0x11161a, 1)
93
82
 
94
- resize = proc do
95
- width = [viewport[:clientWidth].to_i, 1].max
96
- height = [viewport[:clientHeight].to_i, 1].max
83
+ app.resize_renderer(renderer, camera) do |width, height, _aspect|
97
84
  view_height = 3.4
98
85
  view_width = view_height * width.to_f / height
99
86
 
@@ -102,25 +89,26 @@ begin
102
89
  camera.top = view_height / 2
103
90
  camera.bottom = -view_height / 2
104
91
  camera.update_projection_matrix
105
- renderer.set_size(width, height)
106
92
  end
107
-
108
- resize.call
109
- window.call(:addEventListener, "resize", resize)
110
93
  renderer.render(scene, camera)
111
94
 
112
- JS.global[:__threeRbRenderer] = renderer.handle
113
- JS.global[:__threeRbScene] = renderer.backend.materialize(scene)
114
- JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
115
- JS.global[:__threeRbTexturedMesh] = renderer.backend.materialize(mesh)
116
- JS.global[:__threeRbTextureMaterial] = renderer.backend.materialize(material)
117
- JS.global[:__threeRbMatcapMesh] = renderer.backend.materialize(matcap_mesh)
118
- JS.global[:__threeRbMatcapMaterial] = renderer.backend.materialize(matcap_material)
119
- JS.global[:__threeRbToonMesh] = renderer.backend.materialize(toon_mesh)
120
- JS.global[:__threeRbToonMaterial] = renderer.backend.materialize(toon_material)
121
- JS.global[:__threeRbTextureExampleTexture] = renderer.backend.materialize(texture)
122
- JS.global[:__threeRbTextureExampleEnvironment] = renderer.backend.materialize(environment_texture)
123
- JS.global[:__threeRbTextureExampleFrame] = 0
95
+ app.expose(
96
+ {
97
+ renderer: renderer,
98
+ scene: scene,
99
+ camera: camera,
100
+ textured_mesh: mesh,
101
+ texture_material: material,
102
+ matcap_mesh: matcap_mesh,
103
+ matcap_material: matcap_material,
104
+ toon_mesh: toon_mesh,
105
+ toon_material: toon_material,
106
+ texture_example_texture: texture,
107
+ texture_example_environment: environment_texture,
108
+ texture_example_frame: 0
109
+ },
110
+ renderer: renderer
111
+ )
124
112
 
125
113
  frame = 0
126
114
  renderer.animation_loop do
@@ -130,13 +118,7 @@ begin
130
118
  matcap_mesh.rotation.x += 0.005
131
119
  matcap_mesh.rotation.y -= 0.009
132
120
  toon_mesh.rotation.y += 0.012
133
- JS.global[:__threeRbTextureExampleFrame] = frame
121
+ app.set(:texture_example_frame, frame)
134
122
  renderer.render(scene, camera)
135
123
  end
136
-
137
- status[:textContent] = "Running"
138
- status_dot[:dataset][:state] = "running"
139
- rescue StandardError => error
140
- JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
141
- raise
142
124
  end
data/exe/three-rb ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "three/generators/browser_example"
6
+
7
+ def usage
8
+ <<~TEXT
9
+ Usage:
10
+ three-rb browser PATH [--force]
11
+
12
+ Commands:
13
+ browser PATH Generate a Ruby-only browser example at PATH.
14
+
15
+ Example:
16
+ three-rb browser examples/browser/quickstart
17
+ TEXT
18
+ end
19
+
20
+ command = ARGV.shift
21
+
22
+ case command
23
+ when "browser", "browser-example"
24
+ options = { force: false }
25
+ parser = OptionParser.new do |opts|
26
+ opts.banner = "Usage: three-rb #{command} PATH [--force]"
27
+ opts.on("--force", "Overwrite generated example files") { options[:force] = true }
28
+ opts.on("-h", "--help", "Show help") do
29
+ puts opts
30
+ exit 0
31
+ end
32
+ end
33
+ begin
34
+ parser.parse!(ARGV)
35
+ rescue OptionParser::ParseError => error
36
+ warn error.message
37
+ warn parser
38
+ exit 1
39
+ end
40
+ target = ARGV.shift
41
+
42
+ unless target
43
+ warn parser
44
+ exit 1
45
+ end
46
+
47
+ Three::Generators::BrowserExample.new(target: target, force: options[:force]).call
48
+ when "-h", "--help", nil
49
+ puts usage
50
+ exit(command ? 0 : 1)
51
+ else
52
+ warn "Unknown command: #{command}"
53
+ warn usage
54
+ exit 1
55
+ end
@@ -30,6 +30,10 @@ module Three
30
30
  @adapter.load_rgbe_texture(object.source, texture_parameters(object))
31
31
  when Texture
32
32
  @adapter.load_texture(object.source, texture_parameters(object))
33
+ when FogExp2
34
+ @adapter.new_fog_exp2(object.color.hex, object.density)
35
+ when Fog
36
+ @adapter.new_fog(object.color.hex, object.near, object.far)
33
37
  when AmbientLight
34
38
  @adapter.new_ambient_light(object.color.hex, object.intensity)
35
39
  when DirectionalLight
@@ -67,6 +71,8 @@ module Three
67
71
  parameters[:theta_start],
68
72
  parameters[:theta_length]
69
73
  )
74
+ when TextGeometry
75
+ @adapter.new_text_geometry(object.text, text_geometry_parameters(object))
70
76
  when BufferGeometry
71
77
  build_buffer_geometry(object)
72
78
  when LineBasicMaterial
@@ -89,6 +89,23 @@ module Three
89
89
  matrix: texture.matrix.to_a
90
90
  }
91
91
  end
92
+
93
+ def text_geometry_parameters(geometry)
94
+ parameters = geometry.parameters
95
+ {
96
+ font: parameters[:font].handle,
97
+ size: parameters[:size],
98
+ depth: parameters[:depth],
99
+ curveSegments: parameters[:curve_segments],
100
+ steps: parameters[:steps],
101
+ bevelEnabled: parameters[:bevel_enabled],
102
+ bevelThickness: parameters[:bevel_thickness],
103
+ bevelSize: parameters[:bevel_size],
104
+ bevelOffset: parameters[:bevel_offset],
105
+ bevelSegments: parameters[:bevel_segments],
106
+ direction: parameters[:direction]
107
+ }
108
+ end
92
109
  end
93
110
 
94
111
  include Parameters