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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +153 -0
- data/docs/browser-runtime.md +153 -0
- data/docs/implementation-plan.md +874 -0
- data/docs/loaded-assets-design.md +400 -0
- data/docs/next-work.md +107 -0
- data/docs/publishing.md +64 -0
- data/docs/release-readiness.md +83 -0
- data/examples/browser/README.md +54 -0
- data/examples/browser/assets/animated_triangle.gltf +123 -0
- data/examples/browser/assets/checker.svg +11 -0
- data/examples/browser/assets/compressed_triangle.gltf +74 -0
- data/examples/browser/assets/studio.hdr +5 -0
- data/examples/browser/assets/triangle.gltf +67 -0
- data/examples/browser/composition/README.md +35 -0
- data/examples/browser/composition/boot.mjs +6 -0
- data/examples/browser/composition/index.html +136 -0
- data/examples/browser/composition/main.rb +216 -0
- data/examples/browser/composition/smoke_test.mjs +266 -0
- data/examples/browser/cube/README.md +41 -0
- data/examples/browser/cube/boot.mjs +6 -0
- data/examples/browser/cube/index.html +142 -0
- data/examples/browser/cube/main.rb +62 -0
- data/examples/browser/cube/smoke_test.mjs +99 -0
- data/examples/browser/cubemap/README.md +23 -0
- data/examples/browser/cubemap/boot.mjs +6 -0
- data/examples/browser/cubemap/index.html +142 -0
- data/examples/browser/cubemap/main.rb +84 -0
- data/examples/browser/cubemap/smoke_test.mjs +91 -0
- data/examples/browser/gltf/README.md +23 -0
- data/examples/browser/gltf/boot.mjs +6 -0
- data/examples/browser/gltf/index.html +142 -0
- data/examples/browser/gltf/main.rb +105 -0
- data/examples/browser/gltf/smoke_test.mjs +162 -0
- data/examples/browser/picking/README.md +33 -0
- data/examples/browser/picking/boot.mjs +6 -0
- data/examples/browser/picking/index.html +142 -0
- data/examples/browser/picking/main.rb +113 -0
- data/examples/browser/picking/smoke_test.mjs +78 -0
- data/examples/browser/postprocessing/README.md +26 -0
- data/examples/browser/postprocessing/boot.mjs +6 -0
- data/examples/browser/postprocessing/index.html +142 -0
- data/examples/browser/postprocessing/main.rb +117 -0
- data/examples/browser/postprocessing/smoke_test.mjs +121 -0
- data/examples/browser/primitives/README.md +33 -0
- data/examples/browser/primitives/boot.mjs +6 -0
- data/examples/browser/primitives/index.html +142 -0
- data/examples/browser/primitives/main.rb +116 -0
- data/examples/browser/primitives/smoke_test.mjs +113 -0
- data/examples/browser/serialization/README.md +33 -0
- data/examples/browser/serialization/boot.mjs +6 -0
- data/examples/browser/serialization/index.html +142 -0
- data/examples/browser/serialization/main.rb +97 -0
- data/examples/browser/serialization/smoke_test.mjs +67 -0
- data/examples/browser/shared/boot.mjs +79 -0
- data/examples/browser/shared/smoke_test_helpers.mjs +151 -0
- data/examples/browser/textures/README.md +35 -0
- data/examples/browser/textures/boot.mjs +6 -0
- data/examples/browser/textures/index.html +142 -0
- data/examples/browser/textures/main.rb +142 -0
- data/examples/browser/textures/smoke_test.mjs +189 -0
- data/lib/three/animation/animation_action.rb +57 -0
- data/lib/three/animation/animation_clip.rb +22 -0
- data/lib/three/animation/animation_mixer.rb +43 -0
- data/lib/three/backends/base.rb +87 -0
- data/lib/three/backends/threejs/materialization.rb +143 -0
- data/lib/three/backends/threejs/parameters.rb +97 -0
- data/lib/three/backends/threejs/resource_management.rb +69 -0
- data/lib/three/backends/threejs/ruby_wasm_adapter.rb +873 -0
- data/lib/three/backends/threejs/synchronization.rb +224 -0
- data/lib/three/backends/threejs.rb +365 -0
- data/lib/three/cameras/camera.rb +39 -0
- data/lib/three/cameras/orthographic_camera.rb +107 -0
- data/lib/three/cameras/perspective_camera.rb +137 -0
- data/lib/three/constants.rb +40 -0
- data/lib/three/controls/orbit_controls.rb +118 -0
- data/lib/three/core/buffer_attribute.rb +151 -0
- data/lib/three/core/buffer_geometry.rb +181 -0
- data/lib/three/core/clock.rb +58 -0
- data/lib/three/core/event_dispatcher.rb +57 -0
- data/lib/three/core/layers.rb +75 -0
- data/lib/three/core/object3d.rb +331 -0
- data/lib/three/core/raycaster.rb +73 -0
- data/lib/three/dirty.rb +58 -0
- data/lib/three/exporters/three_json_exporter.rb +187 -0
- data/lib/three/geometries/box_geometry.rb +97 -0
- data/lib/three/geometries/plane_geometry.rb +70 -0
- data/lib/three/geometries/sphere_geometry.rb +107 -0
- data/lib/three/lights/ambient_light.rb +12 -0
- data/lib/three/lights/directional_light.rb +38 -0
- data/lib/three/lights/hemisphere_light.rb +34 -0
- data/lib/three/lights/light.rb +85 -0
- data/lib/three/lights/point_light.rb +33 -0
- data/lib/three/loaders/cube_texture_loader.rb +13 -0
- data/lib/three/loaders/gltf_loader.rb +48 -0
- data/lib/three/loaders/rgbe_loader.rb +15 -0
- data/lib/three/loaders/texture_loader.rb +13 -0
- data/lib/three/loaders/three_json_loader.rb +409 -0
- data/lib/three/materials/line_basic_material.rb +65 -0
- data/lib/three/materials/material.rb +158 -0
- data/lib/three/materials/mesh_basic_material.rb +64 -0
- data/lib/three/materials/mesh_lambert_material.rb +71 -0
- data/lib/three/materials/mesh_matcap_material.rb +86 -0
- data/lib/three/materials/mesh_normal_material.rb +42 -0
- data/lib/three/materials/mesh_phong_material.rb +119 -0
- data/lib/three/materials/mesh_physical_material.rb +155 -0
- data/lib/three/materials/mesh_standard_material.rb +149 -0
- data/lib/three/materials/mesh_toon_material.rb +98 -0
- data/lib/three/materials/points_material.rb +74 -0
- data/lib/three/materials/shadow_material.rb +45 -0
- data/lib/three/materials/sprite_material.rb +75 -0
- data/lib/three/math/color.rb +133 -0
- data/lib/three/math/euler.rb +197 -0
- data/lib/three/math/math_utils.rb +36 -0
- data/lib/three/math/matrix3.rb +255 -0
- data/lib/three/math/matrix4.rb +448 -0
- data/lib/three/math/quaternion.rb +277 -0
- data/lib/three/math/vector2.rb +95 -0
- data/lib/three/math/vector3.rb +396 -0
- data/lib/three/objects/external_object3d.rb +28 -0
- data/lib/three/objects/group.rb +12 -0
- data/lib/three/objects/instanced_mesh.rb +110 -0
- data/lib/three/objects/line.rb +41 -0
- data/lib/three/objects/mesh.rb +45 -0
- data/lib/three/objects/points.rb +41 -0
- data/lib/three/objects/sprite.rb +57 -0
- data/lib/three/postprocessing/dot_screen_pass.rb +83 -0
- data/lib/three/postprocessing/effect_composer.rb +56 -0
- data/lib/three/postprocessing/output_pass.rb +40 -0
- data/lib/three/postprocessing/render_pass.rb +42 -0
- data/lib/three/postprocessing/unreal_bloom_pass.rb +56 -0
- data/lib/three/renderers/renderer.rb +11 -0
- data/lib/three/renderers/threejs_renderer.rb +85 -0
- data/lib/three/scenes/scene.rb +29 -0
- data/lib/three/textures/cube_texture.rb +72 -0
- data/lib/three/textures/rgbe_texture.rb +45 -0
- data/lib/three/textures/texture.rb +200 -0
- data/lib/three/version.rb +5 -0
- data/lib/three-rb.rb +3 -0
- data/lib/three.rb +77 -0
- data/package.json +30 -0
- data/pnpm-lock.yaml +86 -0
- metadata +216 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "js"
|
|
4
|
+
|
|
5
|
+
begin
|
|
6
|
+
JS.global[:__threeReady].await
|
|
7
|
+
|
|
8
|
+
require_relative "../../../lib/three"
|
|
9
|
+
|
|
10
|
+
document = JS.global[:document]
|
|
11
|
+
window = JS.global[:window]
|
|
12
|
+
viewport = document.call(:querySelector, "#viewport")
|
|
13
|
+
status = document.call(:querySelector, "#status")
|
|
14
|
+
status_dot = document.call(:querySelector, "#status-dot")
|
|
15
|
+
status[:textContent] = "Starting Ruby scene"
|
|
16
|
+
|
|
17
|
+
scene = Three::Scene.new
|
|
18
|
+
camera = Three::PerspectiveCamera.new(45, aspect: 1.0, near: 0.1, far: 100)
|
|
19
|
+
camera.position.set(0, 0.15, 4.0)
|
|
20
|
+
|
|
21
|
+
scene.add(Three::AmbientLight.new(0xffffff, 0.65))
|
|
22
|
+
|
|
23
|
+
key_light = Three::DirectionalLight.new(0xffffff, 1.0)
|
|
24
|
+
key_light.position.set(2.5, 3.0, 4.0)
|
|
25
|
+
scene.add(key_light)
|
|
26
|
+
|
|
27
|
+
renderer = Three::Renderers::ThreeJSRenderer.new(
|
|
28
|
+
canvas: "#scene",
|
|
29
|
+
antialias: true,
|
|
30
|
+
alpha: false,
|
|
31
|
+
preserveDrawingBuffer: true
|
|
32
|
+
)
|
|
33
|
+
renderer.set_clear_color(0x11151a, 1)
|
|
34
|
+
|
|
35
|
+
gltf = Three::Loaders::GLTFLoader.new(backend: renderer.backend).load("/examples/browser/assets/animated_triangle.gltf")
|
|
36
|
+
model = gltf.scene
|
|
37
|
+
model.position.x = -0.75
|
|
38
|
+
model.scale.set(1.2, 1.2, 1.2)
|
|
39
|
+
scene.add(model)
|
|
40
|
+
|
|
41
|
+
draco_decoder_path = "/node_modules/three/examples/jsm/libs/draco/gltf/"
|
|
42
|
+
compressed_gltf = Three::Loaders::GLTFLoader.new(
|
|
43
|
+
backend: renderer.backend,
|
|
44
|
+
draco_decoder_path: draco_decoder_path
|
|
45
|
+
).load("/examples/browser/assets/compressed_triangle.gltf")
|
|
46
|
+
compressed_model = compressed_gltf.scene
|
|
47
|
+
compressed_model.position.x = 1.05
|
|
48
|
+
compressed_model.scale.set(0.82, 0.82, 0.82)
|
|
49
|
+
scene.add(compressed_model)
|
|
50
|
+
|
|
51
|
+
clock = Three::Clock.new
|
|
52
|
+
mixer = Three::AnimationMixer.new(model, backend: renderer.backend)
|
|
53
|
+
action = mixer.clip_action(gltf.animations.first)
|
|
54
|
+
action.play
|
|
55
|
+
|
|
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)
|
|
67
|
+
renderer.render(scene, camera)
|
|
68
|
+
|
|
69
|
+
JS.global[:__threeRbRenderer] = renderer.handle
|
|
70
|
+
JS.global[:__threeRbGltfRootScene] = renderer.backend.materialize(scene)
|
|
71
|
+
JS.global[:__threeRbCamera] = renderer.backend.materialize(camera)
|
|
72
|
+
JS.global[:__threeRbGltfScene] = renderer.backend.materialize(model)
|
|
73
|
+
JS.global[:__threeRbCompressedGltfScene] = renderer.backend.materialize(compressed_model)
|
|
74
|
+
JS.global[:__threeRbCompressedGltfDecoderPath] = draco_decoder_path
|
|
75
|
+
JS.global[:__threeRbGltfAnimations] = gltf.animations.length
|
|
76
|
+
JS.global[:__threeRbGltfAnimationName] = gltf.animations.first&.name
|
|
77
|
+
JS.global[:__threeRbGltfAnimationDuration] = gltf.animations.first&.duration
|
|
78
|
+
JS.global[:__threeRbGltfMixer] = mixer.handle
|
|
79
|
+
JS.global[:__threeRbGltfAction] = action.handle
|
|
80
|
+
JS.global[:__threeRbGltfFrame] = 0
|
|
81
|
+
JS.global[:__threeRbGltfAnimationTime] = 0
|
|
82
|
+
JS.global[:__threeRbDisposeGltf] = proc do
|
|
83
|
+
mixer.stop_all_action
|
|
84
|
+
mixer.uncache_root
|
|
85
|
+
renderer.dispose_subtree(model, remove: true, dispose_textures: true)
|
|
86
|
+
renderer.dispose_subtree(compressed_model, remove: true, dispose_textures: true)
|
|
87
|
+
JS.global[:__threeRbGltfDisposed] = true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
frame = 0
|
|
91
|
+
renderer.animation_loop do
|
|
92
|
+
frame += 1
|
|
93
|
+
delta = clock.get_delta
|
|
94
|
+
mixer.update(delta)
|
|
95
|
+
JS.global[:__threeRbGltfAnimationTime] = JS.global[:__threeRbGltfAnimationTime].to_f + delta
|
|
96
|
+
JS.global[:__threeRbGltfFrame] = frame
|
|
97
|
+
renderer.render(scene, camera)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
status[:textContent] = "Running"
|
|
101
|
+
status_dot[:dataset][:state] = "running"
|
|
102
|
+
rescue StandardError => error
|
|
103
|
+
JS.global.call(:__threeRbBootFailed, error.message) if JS.global[:__threeRbBootFailed]
|
|
104
|
+
raise
|
|
105
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
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/gltf/`, { waitUntil: "load" });
|
|
22
|
+
await waitForRunning(page, diagnostics);
|
|
23
|
+
await page.waitForFunction(
|
|
24
|
+
() => globalThis.__threeRbGltfScene?.children?.length > 0 &&
|
|
25
|
+
globalThis.__threeRbCompressedGltfScene?.children?.length > 0,
|
|
26
|
+
null,
|
|
27
|
+
{ timeout: 10_000 }
|
|
28
|
+
);
|
|
29
|
+
await page.waitForTimeout(1_000);
|
|
30
|
+
|
|
31
|
+
assertNonBlankCanvas(await sampleCanvas(page));
|
|
32
|
+
|
|
33
|
+
const scene = await page.evaluate(() => ({
|
|
34
|
+
frame: globalThis.__threeRbGltfFrame,
|
|
35
|
+
animationTime: globalThis.__threeRbGltfAnimationTime,
|
|
36
|
+
animationCount: globalThis.__threeRbGltfAnimations,
|
|
37
|
+
animationName: globalThis.__threeRbGltfAnimationName,
|
|
38
|
+
animationDuration: globalThis.__threeRbGltfAnimationDuration,
|
|
39
|
+
actionTime: globalThis.__threeRbGltfAction?.time,
|
|
40
|
+
animatedNodeQuaternion: globalThis.__threeRbGltfScene?.children?.[0]?.quaternion?.toArray?.(),
|
|
41
|
+
renderInfo: globalThis.__threeRbRenderer?.info?.render,
|
|
42
|
+
cameraType: globalThis.__threeRbCamera?.type,
|
|
43
|
+
rootChildren: globalThis.__threeRbGltfRootScene?.children?.length,
|
|
44
|
+
gltfType: globalThis.__threeRbGltfScene?.type,
|
|
45
|
+
gltfIsObject3D: globalThis.__threeRbGltfScene?.isObject3D,
|
|
46
|
+
gltfChildren: globalThis.__threeRbGltfScene?.children?.length,
|
|
47
|
+
compressedGltfType: globalThis.__threeRbCompressedGltfScene?.type,
|
|
48
|
+
compressedGltfIsObject3D: globalThis.__threeRbCompressedGltfScene?.isObject3D,
|
|
49
|
+
compressedGltfChildren: globalThis.__threeRbCompressedGltfScene?.children?.length,
|
|
50
|
+
compressedGeometryAttributes: Object.keys(globalThis.__threeRbCompressedGltfScene?.children?.[0]?.geometry?.attributes || {}),
|
|
51
|
+
compressedDecoderPath: globalThis.__threeRbCompressedGltfDecoderPath
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
if (scene.cameraType !== "PerspectiveCamera") {
|
|
55
|
+
throw new Error(`expected a PerspectiveCamera glTF view: ${JSON.stringify(scene)}`);
|
|
56
|
+
}
|
|
57
|
+
if (scene.gltfIsObject3D !== true || scene.gltfChildren < 1) {
|
|
58
|
+
throw new Error(`expected a loaded glTF Object3D scene: ${JSON.stringify(scene)}`);
|
|
59
|
+
}
|
|
60
|
+
if (scene.compressedGltfIsObject3D !== true || scene.compressedGltfChildren < 1) {
|
|
61
|
+
throw new Error(`expected a Draco-compressed glTF Object3D scene: ${JSON.stringify(scene)}`);
|
|
62
|
+
}
|
|
63
|
+
if (!scene.compressedGeometryAttributes.includes("position") || !scene.compressedDecoderPath.endsWith("/draco/gltf/")) {
|
|
64
|
+
throw new Error(`Draco-compressed glTF geometry was not decoded: ${JSON.stringify(scene)}`);
|
|
65
|
+
}
|
|
66
|
+
if (!scene.renderInfo || scene.renderInfo.triangles < 1) {
|
|
67
|
+
throw new Error(`renderer did not draw the glTF triangle: ${JSON.stringify(scene)}`);
|
|
68
|
+
}
|
|
69
|
+
if (!scene.frame) {
|
|
70
|
+
throw new Error(`glTF example animation did not advance: ${JSON.stringify(scene)}`);
|
|
71
|
+
}
|
|
72
|
+
if (scene.animationCount !== 1 || scene.animationName !== "TriangleSpin" || scene.animationDuration !== 2) {
|
|
73
|
+
throw new Error(`glTF animation metadata was not exposed: ${JSON.stringify(scene)}`);
|
|
74
|
+
}
|
|
75
|
+
if (!(scene.animationTime > 0) || !(scene.actionTime > 0)) {
|
|
76
|
+
throw new Error(`AnimationMixer did not advance the glTF action: ${JSON.stringify(scene)}`);
|
|
77
|
+
}
|
|
78
|
+
if (!scene.animatedNodeQuaternion || Math.abs(scene.animatedNodeQuaternion[1]) < 0.05) {
|
|
79
|
+
throw new Error(`glTF animated node quaternion did not change: ${JSON.stringify(scene)}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const disposal = await page.evaluate(() => {
|
|
83
|
+
const root = globalThis.__threeRbGltfScene;
|
|
84
|
+
const compressedRoot = globalThis.__threeRbCompressedGltfScene;
|
|
85
|
+
const rootScene = globalThis.__threeRbGltfRootScene;
|
|
86
|
+
const stats = {
|
|
87
|
+
geometries: 0,
|
|
88
|
+
materials: 0,
|
|
89
|
+
textures: 0,
|
|
90
|
+
geometryDisposeEvents: 0,
|
|
91
|
+
materialDisposeEvents: 0,
|
|
92
|
+
textureDisposeEvents: 0
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
[root, compressedRoot].forEach((loadedRoot) => loadedRoot.traverse((object) => {
|
|
96
|
+
if (object.geometry) {
|
|
97
|
+
stats.geometries += 1;
|
|
98
|
+
object.geometry.addEventListener("dispose", () => {
|
|
99
|
+
stats.geometryDisposeEvents += 1;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const materials = Array.isArray(object.material) ? object.material : [object.material].filter(Boolean);
|
|
104
|
+
for (const material of materials) {
|
|
105
|
+
stats.materials += 1;
|
|
106
|
+
material.addEventListener("dispose", () => {
|
|
107
|
+
stats.materialDisposeEvents += 1;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (material.map) {
|
|
111
|
+
stats.textures += 1;
|
|
112
|
+
material.map.addEventListener("dispose", () => {
|
|
113
|
+
stats.textureDisposeEvents += 1;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
globalThis.__threeRbDisposeGltf();
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
...stats,
|
|
123
|
+
disposed: globalThis.__threeRbGltfDisposed,
|
|
124
|
+
rootParent: root.parent?.type ?? null,
|
|
125
|
+
compressedRootParent: compressedRoot.parent?.type ?? null,
|
|
126
|
+
rootSceneStillContainsRoot: rootScene.children.includes(root),
|
|
127
|
+
rootSceneStillContainsCompressedRoot: rootScene.children.includes(compressedRoot)
|
|
128
|
+
};
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (
|
|
132
|
+
disposal.disposed !== true ||
|
|
133
|
+
disposal.rootParent !== null ||
|
|
134
|
+
disposal.compressedRootParent !== null ||
|
|
135
|
+
disposal.rootSceneStillContainsRoot ||
|
|
136
|
+
disposal.rootSceneStillContainsCompressedRoot
|
|
137
|
+
) {
|
|
138
|
+
throw new Error(`dispose_subtree did not detach the glTF root: ${JSON.stringify(disposal)}`);
|
|
139
|
+
}
|
|
140
|
+
if (disposal.geometries < 1 || disposal.geometryDisposeEvents !== disposal.geometries) {
|
|
141
|
+
throw new Error(`dispose_subtree did not dispose loaded geometries: ${JSON.stringify(disposal)}`);
|
|
142
|
+
}
|
|
143
|
+
if (disposal.materials < 1 || disposal.materialDisposeEvents !== disposal.materials) {
|
|
144
|
+
throw new Error(`dispose_subtree did not dispose loaded materials: ${JSON.stringify(disposal)}`);
|
|
145
|
+
}
|
|
146
|
+
if (disposal.textureDisposeEvents !== disposal.textures) {
|
|
147
|
+
throw new Error(`dispose_subtree did not dispose loaded textures: ${JSON.stringify(disposal)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
assertNoDiagnostics(diagnostics);
|
|
151
|
+
|
|
152
|
+
console.log(`glTF smoke test passed at ${server.url}/examples/browser/gltf/`);
|
|
153
|
+
} finally {
|
|
154
|
+
await browser.close();
|
|
155
|
+
await new Promise((resolve) => server.instance.close(resolve));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
main().catch((error) => {
|
|
160
|
+
console.error(error);
|
|
161
|
+
process.exitCode = 1;
|
|
162
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Browser Picking Example
|
|
2
|
+
|
|
3
|
+
This example uses `Three::Raycaster` to pick Ruby-authored meshes from browser click coordinates, map three.js intersections back to Ruby `Object3D` instances, and update the selected mesh material.
|
|
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/picking/
|
|
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:picking
|
|
33
|
+
```
|
|
@@ -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 picking</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,113 @@
|
|
|
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 picking scene"
|
|
16
|
+
|
|
17
|
+
scene = Three::Scene.new
|
|
18
|
+
camera = Three::PerspectiveCamera.new(60, aspect: 1.0, near: 0.1, far: 100)
|
|
19
|
+
camera.position.z = 4
|
|
20
|
+
|
|
21
|
+
geometry = Three::BoxGeometry.new(0.82, 0.82, 0.82)
|
|
22
|
+
idle_color = 0x4ed08f
|
|
23
|
+
picked_color = 0xffcc4d
|
|
24
|
+
|
|
25
|
+
left_material = Three::MeshBasicMaterial.new(color: idle_color)
|
|
26
|
+
right_material = Three::MeshBasicMaterial.new(color: idle_color)
|
|
27
|
+
left_cube = Three::Mesh.new(geometry, left_material)
|
|
28
|
+
left_cube.name = "left-cube"
|
|
29
|
+
left_cube.position.x = -0.75
|
|
30
|
+
right_cube = Three::Mesh.new(geometry, right_material)
|
|
31
|
+
right_cube.name = "right-cube"
|
|
32
|
+
right_cube.position.x = 0.75
|
|
33
|
+
scene.add(left_cube, right_cube)
|
|
34
|
+
|
|
35
|
+
renderer = Three::Renderers::ThreeJSRenderer.new(
|
|
36
|
+
canvas: "#scene",
|
|
37
|
+
antialias: true,
|
|
38
|
+
alpha: false,
|
|
39
|
+
preserveDrawingBuffer: true
|
|
40
|
+
)
|
|
41
|
+
renderer.set_clear_color(0x101418, 1)
|
|
42
|
+
|
|
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
|
+
pointer = Three::Vector2.new
|
|
53
|
+
raycaster = Three::Raycaster.new(backend: renderer.backend)
|
|
54
|
+
pickables = [left_cube, right_cube]
|
|
55
|
+
selected = nil
|
|
56
|
+
|
|
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)
|
|
62
|
+
raycaster.set_from_camera(pointer, camera)
|
|
63
|
+
hits = raycaster.intersect_objects(pickables, recursive: false)
|
|
64
|
+
hit = hits.find(&:object)
|
|
65
|
+
|
|
66
|
+
if selected
|
|
67
|
+
selected.material.color.set_hex(idle_color)
|
|
68
|
+
selected = nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
if hit
|
|
72
|
+
selected = hit.object
|
|
73
|
+
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
|
|
77
|
+
else
|
|
78
|
+
JS.global[:__threeRbPickedName] = nil
|
|
79
|
+
JS.global[:__threeRbPickedDistance] = nil
|
|
80
|
+
JS.global[:__threeRbPickedPoint] = nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
JS.global[:__threeRbPickCount] = JS.global[:__threeRbPickCount].to_i + 1
|
|
84
|
+
renderer.render(scene, camera)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
resize.call
|
|
88
|
+
window.call(:addEventListener, "resize", resize)
|
|
89
|
+
renderer.dom_element.call(:addEventListener, "click", pick)
|
|
90
|
+
renderer.render(scene, camera)
|
|
91
|
+
|
|
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
|
|
100
|
+
|
|
101
|
+
renderer.animation_loop do
|
|
102
|
+
JS.global[:__threeRbPickingFrame] = JS.global[:__threeRbPickingFrame].to_i + 1
|
|
103
|
+
left_cube.rotation.y += 0.01
|
|
104
|
+
right_cube.rotation.y -= 0.01
|
|
105
|
+
renderer.render(scene, camera)
|
|
106
|
+
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
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
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/picking/`, { waitUntil: "load" });
|
|
22
|
+
await waitForRunning(page, diagnostics);
|
|
23
|
+
await page.waitForTimeout(1_000);
|
|
24
|
+
|
|
25
|
+
assertNonBlankCanvas(await sampleCanvas(page));
|
|
26
|
+
|
|
27
|
+
const rect = await page.locator("[data-testid='scene-canvas']").boundingBox();
|
|
28
|
+
if (!rect) throw new Error("scene canvas bounding box is unavailable");
|
|
29
|
+
|
|
30
|
+
await page.mouse.click(rect.x + rect.width * 0.4, rect.y + rect.height * 0.5);
|
|
31
|
+
await page.waitForFunction(() => globalThis.__threeRbPickedName === "left-cube", null, { timeout: 5_000 });
|
|
32
|
+
|
|
33
|
+
const leftPick = await page.evaluate(() => ({
|
|
34
|
+
pickCount: globalThis.__threeRbPickCount,
|
|
35
|
+
pickedName: globalThis.__threeRbPickedName,
|
|
36
|
+
pickedDistance: globalThis.__threeRbPickedDistance,
|
|
37
|
+
pickedPoint: globalThis.__threeRbPickedPoint,
|
|
38
|
+
leftColor: globalThis.__threeRbLeftCube?.material?.color?.getHex?.(),
|
|
39
|
+
rightColor: globalThis.__threeRbRightCube?.material?.color?.getHex?.(),
|
|
40
|
+
renderInfo: globalThis.__threeRbRenderer?.info?.render
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
if (leftPick.leftColor !== 0xffcc4d || leftPick.rightColor !== 0x4ed08f || !(leftPick.pickedDistance > 0)) {
|
|
44
|
+
throw new Error(`left pick did not update the expected mesh: ${JSON.stringify(leftPick)}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await page.mouse.click(rect.x + rect.width * 0.6, rect.y + rect.height * 0.5);
|
|
48
|
+
await page.waitForFunction(() => globalThis.__threeRbPickedName === "right-cube", null, { timeout: 5_000 });
|
|
49
|
+
|
|
50
|
+
const rightPick = await page.evaluate(() => ({
|
|
51
|
+
frame: globalThis.__threeRbPickingFrame,
|
|
52
|
+
pickCount: globalThis.__threeRbPickCount,
|
|
53
|
+
pickedName: globalThis.__threeRbPickedName,
|
|
54
|
+
leftColor: globalThis.__threeRbLeftCube?.material?.color?.getHex?.(),
|
|
55
|
+
rightColor: globalThis.__threeRbRightCube?.material?.color?.getHex?.(),
|
|
56
|
+
renderInfo: globalThis.__threeRbRenderer?.info?.render
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
if (rightPick.leftColor !== 0x4ed08f || rightPick.rightColor !== 0xffcc4d || rightPick.pickCount < 2) {
|
|
60
|
+
throw new Error(`right pick did not update the expected mesh: ${JSON.stringify(rightPick)}`);
|
|
61
|
+
}
|
|
62
|
+
if (!rightPick.renderInfo || rightPick.renderInfo.triangles < 24 || !rightPick.frame) {
|
|
63
|
+
throw new Error(`picking scene did not render or animate: ${JSON.stringify(rightPick)}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
assertNoDiagnostics(diagnostics);
|
|
67
|
+
|
|
68
|
+
console.log(`picking smoke test passed at ${server.url}/examples/browser/picking/`);
|
|
69
|
+
} finally {
|
|
70
|
+
await browser.close();
|
|
71
|
+
await new Promise((resolve) => server.instance.close(resolve));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
main().catch((error) => {
|
|
76
|
+
console.error(error);
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# three-rb postprocessing browser example
|
|
2
|
+
|
|
3
|
+
This example renders a Ruby-authored scene through ruby.wasm, the JavaScript three.js renderer, and an explicit postprocessing pipeline. It focuses on `Three::Postprocessing::EffectComposer`, `RenderPass`, `UnrealBloomPass`, `DotScreenPass`, `OutputPass`, composer sizing, pass property and uniform updates, and rendering through `composer.render(scene, camera)`.
|
|
4
|
+
|
|
5
|
+
## Run
|
|
6
|
+
|
|
7
|
+
From the repository root:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pnpm install
|
|
11
|
+
ruby -run -e httpd . -p 8000
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Open:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
http://localhost:8000/examples/browser/postprocessing/
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Smoke Test
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm test:browser:postprocessing
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The smoke test serves the repository root, waits for the Ruby scene to reach `Running`, samples the WebGL canvas for nonblank pixels, verifies that the composer owns a render pass and bloom pass, checks bloom pass settings, and confirms animation frames are rendered through the composer path.
|