From 6bbf8039e41fe58fb51ce607eff07de80bd11b73 Mon Sep 17 00:00:00 2001 From: Amp Date: Wed, 19 Aug 2026 09:41:41 +0000 Subject: [PATCH] Strip core to render data and render graphs Move glTF, picking, camera controls, and conventional handles into addons. Keep camera and material mutations in SIMD-aligned shared SOA rows and synchronize material updates directly into GPU buffers. Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure --- .amp/services.yaml | 8 +- AGENTS.md | 2 +- Cargo.lock | 227 ----- Cargo.toml | 34 +- README.md | 41 +- addons/default-pipelines/src/index.js | 6 +- addons/gltf-import/package.json | 2 +- addons/gltf-import/src/gltf.js | 392 +++++++++ addons/gltf-import/src/index.js | 35 +- addons/gltf-import/src/worker.js | 14 +- addons/mesh-handles/package.json | 2 +- addons/mesh-handles/src/bvh-worker.js | 2 +- addons/mesh-handles/src/index.js | 305 ++++++- .../mesh-handles}/src/snapshot.js | 2 +- addons/render-graph-ast/src/index.js | 4 +- examples/README.md | 3 +- examples/cookbook/08-compile-and-switch.js | 6 - examples/cookbook/13-bvh-picking.js | 6 +- examples/cookbook/14-worker-to-worker.js | 1 - examples/cookbook/15-complete-scene.js | 15 +- examples/cookbook/16-camera-render-data.js | 122 +++ examples/cookbook/17-conventional-handles.js | 19 + examples/cookbook/README.md | 4 +- examples/cookbook/index.js | 2 + examples/index.html | 201 +++++ examples/render-graph-studio/demo-loadouts.js | 6 +- examples/render-graph-studio/index.html | 9 +- examples/render-graph-studio/index.js | 129 +-- level-editor/Cargo.toml | 46 -- level-editor/src/lib.rs | 104 --- package.json | 14 +- packages/yawn-core/package.json | 5 +- packages/yawn-core/src/index.js | 68 +- renderer/Cargo.toml | 22 - renderer/src/app_setup.rs | 31 +- renderer/src/camera.rs | 543 ------------ renderer/src/gltf.rs | 774 ------------------ renderer/src/lib.rs | 19 +- renderer/src/message.rs | 157 ---- .../src/platform/web/worker/mainWorker.js | 8 +- renderer/src/platform/web/worker/mod.rs | 12 +- renderer/src/render_data/camera.rs | 285 +++++++ renderer/src/render_data/mod.rs | 7 +- renderer/src/render_data/upload.rs | 734 +++++++++++++++++ renderer/src/render_graph/ast.rs | 11 +- renderer/src/render_graph/compiler.rs | 16 +- renderer/src/render_graph/contracts.rs | 35 +- renderer/src/render_graph/mod.rs | 1 - renderer/src/render_graph/runtime.rs | 27 +- renderer/src/render_graph/schema.rs | 2 + renderer/src/render_graph/tests.rs | 49 +- renderer/src/renderer/executors/mod.rs | 2 +- renderer/src/renderer/executors/pipeline.rs | 54 +- renderer/src/renderer/frame_data.rs | 146 ++++ renderer/src/renderer/material.rs | 211 ++--- renderer/src/renderer/mod.rs | 695 +++++----------- renderer/src/renderer/pipeline_library.rs | 32 +- renderer/src/renderer/profiler.rs | 537 ------------ renderer/src/renderer/scene.rs | 130 --- renderer/src/shared_soa.rs | 239 +++++- rsw.toml | 2 +- tests/cookbook-examples.test.js | 22 +- tests/demo-loadouts.test.js | 2 +- tests/gltf-import.test.js | 30 +- tests/snapshot-bvh.test.js | 13 +- tests/yawn-core.test.js | 100 ++- vite.config.js | 37 +- 67 files changed, 3152 insertions(+), 3669 deletions(-) create mode 100644 addons/gltf-import/src/gltf.js rename {packages/yawn-core => addons/mesh-handles}/src/snapshot.js (98%) create mode 100644 examples/cookbook/16-camera-render-data.js create mode 100644 examples/cookbook/17-conventional-handles.js create mode 100644 examples/index.html delete mode 100644 level-editor/Cargo.toml delete mode 100644 level-editor/src/lib.rs delete mode 100644 renderer/src/camera.rs delete mode 100644 renderer/src/gltf.rs delete mode 100644 renderer/src/message.rs create mode 100644 renderer/src/render_data/camera.rs create mode 100644 renderer/src/render_data/upload.rs create mode 100644 renderer/src/renderer/frame_data.rs delete mode 100644 renderer/src/renderer/profiler.rs delete mode 100644 renderer/src/renderer/scene.rs diff --git a/.amp/services.yaml b/.amp/services.yaml index abb8a19..77bb035 100644 --- a/.amp/services.yaml +++ b/.amp/services.yaml @@ -1,6 +1,6 @@ services: - yawn-dev: - command: npm run dev + yawn-examples: + command: npm run examples portal: - title: Yawn - description: WebGPU level editor development server with hot reload. + title: Yawn examples + description: Example index and WebGPU render graph studio with hot reload. diff --git a/AGENTS.md b/AGENTS.md index ec5aaea..28ada97 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Build, Lint, and Test Commands -- `npm run dev`: Start Vite dev server with hot reload for WASM bundle +- `npm run examples`: Start the examples index with Vite and WASM hot reload - `npm run build`: Build optimized WASM and JS in `dist/` for development - `npm run build-release`: Build optimized WASM and JS for production - `cargo check`: Validate Rust sources quickly before full builds diff --git a/Cargo.lock b/Cargo.lock index 68e640e..92e1083 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,15 +17,6 @@ dependencies = [ "libc", ] -[[package]] -name = "approx" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278" -dependencies = [ - "num-traits", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -47,12 +38,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "bit-set" version = "0.8.0" @@ -115,12 +100,6 @@ dependencies = [ "syn", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "byteorder-lite" version = "0.1.0" @@ -139,16 +118,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" -[[package]] -name = "cgmath" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317" -dependencies = [ - "approx", - "num-traits", -] - [[package]] name = "codespan-reporting" version = "0.12.0" @@ -170,16 +139,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "console_log" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" -dependencies = [ - "log", - "web-sys", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -289,95 +248,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - [[package]] name = "gl_generator" version = "0.14.0" @@ -401,45 +271,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "gltf" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7" -dependencies = [ - "base64", - "byteorder", - "gltf-json", - "image", - "lazy_static", - "serde_json", - "urlencoding", -] - -[[package]] -name = "gltf-derive" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51" -dependencies = [ - "inflections", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "gltf-json" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14" -dependencies = [ - "gltf-derive", - "serde", - "serde_derive", - "serde_json", -] - [[package]] name = "glutin_wgl_sys" version = "0.6.1" @@ -550,12 +381,6 @@ dependencies = [ "hashbrown", ] -[[package]] -name = "inflections" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" - [[package]] name = "itoa" version = "1.0.15" @@ -595,29 +420,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "level-editor" -version = "0.1.0" -dependencies = [ - "bytemuck", - "console_error_panic_hook", - "js-sys", - "log", - "renderer", - "ultraviolet", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-logger", - "web-sys", - "wgpu", -] - [[package]] name = "libc" version = "0.2.174" @@ -800,18 +602,6 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "pkg-config" version = "0.3.32" @@ -908,15 +698,10 @@ name = "renderer" version = "0.1.0" dependencies = [ "bytemuck", - "cgmath", "console_error_panic_hook", - "console_log", - "futures", - "gltf", "image", "js-sys", "log", - "raw-window-handle", "serde", "serde_json", "thiserror 2.0.15", @@ -999,12 +784,6 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - [[package]] name = "slotmap" version = "1.0.7" @@ -1116,12 +895,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "version_check" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index 58fb918..c0027bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,47 +1,25 @@ [workspace] -members = ["renderer", "level-editor"] +members = ["renderer"] resolver = "2" [workspace.dependencies] wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] } wasm-bindgen-futures = "0.4.50" console_error_panic_hook = "0.1.7" -console_log = "1.0.0" log = "0.4.27" wasm-logger = "0.2.0" web-sys = { version = "0.3.77", features = [ - "Window", - "Document", - "Element", - "HtmlCanvasElement", - "HtmlInputElement", - "File", - "FileList", "OffscreenCanvas", - "MouseEvent", - "PointerEvent", - "WheelEvent", - "Worker", "DedicatedWorkerGlobalScope", - "Event", - "MessageEvent", - "Blob", - "BlobPropertyBag", - "Url", - "Request", - "RequestInit", - "RequestMode", - "Response", - "Headers", - "AddEventListenerOptions" + "MessageEvent" ]} js-sys = "0.3.77" bytemuck = { version = "1.23.1", features = ["derive"] } -cgmath = "0.18" -raw-window-handle = "0.6.2" wgpu = "26.0.1" thiserror = "2.0.15" ultraviolet = "0.10.0" -futures = "0.3" -gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual", "KHR_materials_ior"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } + +[profile.release] +opt-level = "z" +lto = true diff --git a/README.md b/README.md index 7005141..a4fda61 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ JavaScript objects ──┘ │ Any browser thread ── infrequent commands ──────────────────> worker Any browser thread ── atomic SOA writes ────────────────────> shared WASM memory -glTF import worker ── fetch URL ──> fixed shared SOA upload ─┘ +glTF import worker ── parse URL ──> generic render-data packet ─> fixed shared SOA ─┘ ``` The canonical AST is the only public render-graph wire format. Nodes are named @@ -29,17 +29,19 @@ pipelines before activating a graph. Authored render shaders use Yawn's fixed scene ABI. Render and compute declarations carry source, entry points, and dispatch/state metadata and are prepared with the graph loadout. Core contains no built-in shader source or pipeline declarations. +Its public responsibility stops at shared render data and render-graph compilation, +loadouts, lifecycle, and transient resource management; conveniences live outside it. ## Packages -- `packages/yawn-core` (`@yawn/core`) — the worker command transport, serialized - graph lifecycle, and shared SOA views; it returns `[slot, generation]` handles. +- `packages/yawn-core` (`@yawn/core`) — render-data shared arrays and render-graph + lifecycle transport; it returns `[slot, generation]` render-data handles. - `addons/render-graph-ast` — canonical immutable DAG AST and S-expression serializer. - `addons/render-graph-js` — plain-object/fluent graph APIs that serialize and load ASTs. - `addons/render-graph-fxnode` — FXNode snapshot exporter and diagnostic mapping. - `addons/default-pipelines` — optional scene/frame shader and compute declarations. -- `addons/gltf-import` — URL-fetching worker that writes GLB bytes directly to a fixed SOA. -- `addons/mesh-handles` — conventional `Mesh`/`Instance` objects and optional BVH picking. +- `addons/gltf-import` — glTF worker that writes format-neutral render-data packets directly to a fixed SOA. +- `addons/mesh-handles` — conventional mesh, instance, camera, and material objects plus optional BVH picking. The integration example in `examples/render-graph-studio` consumes every package through its public API; no example source or shader lives in core. @@ -80,7 +82,8 @@ await graph.load(core); `@yawn/core` exposes 64-byte-aligned shared SOA columns. Every stride is a multiple of 16 bytes and scalar lanes are atomic `u32`, `i32`, or IEEE-754 `f32` bits. The built-in instance transform/type columns are generation-guarded so a stale handle -cannot mutate a reused slot. +cannot mutate a reused slot. The built-in `camera.state` column is one 64-byte, +16-lane `f32` row containing eye, target, up, and projection parameters. Allocate application columns infrequently through the worker: @@ -95,16 +98,32 @@ const velocity = await core.allocateArray({ velocity.write(instanceSlot, [1, 0, 0, 0]); ``` +Camera state has no dedicated core API. Read and write it through the same render-data +SOA interface as every other hot value; these mutations do not enqueue worker messages: + +```js +const camera = core.array("camera.state"); +const state = camera.read(0); +state[0] = nextEye[0]; +state[1] = nextEye[1]; +state[2] = nextEye[2]; +camera.write(0, state); +``` + Import a GLB without transferring its bytes through renderer messages: ```js import { GltfImporter } from "@yawn/gltf-import"; -import { MeshHandles } from "@yawn/mesh-handles"; +import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles"; const importer = new GltfImporter(core); -const handles = new MeshHandles(core); -const meshes = handles.fromImportedScene(await importer.load(gltfUrl)); +const imported = await importer.load(gltfUrl); +const meshes = new MeshHandles(core).fromImportedScene(imported); +const materials = new MaterialHandles(core).fromImportedScene(imported); +const camera = new CameraHandle(core); meshes[0].defaultInstance.setTransform(nextTransform); // direct shared-SOA write +materials[0].roughness = 0.35; // direct shared-SOA write +camera.position = [4, 3, 6]; // direct shared-SOA write ``` The renderer grows mesh/instance-domain columns with render-data capacity and @@ -115,7 +134,7 @@ use shared memory; a GLB commit message contains only an array ID and byte count `YawnCore` accepts a transport bridge whose worker endpoint can be a `Worker` or a started `MessagePort`, so the same API can run on the browser main thread or another -worker. Optional picking is installed with the mesh addon's `createPickingWorker`. +worker. Optional snapshot/BVH picking is owned entirely by the mesh-handles addon. Cross-origin isolation is required (`COOP: same-origin`, `COEP: require-corp`). The Vite development and preview servers already set both headers. @@ -123,7 +142,7 @@ Vite development and preview servers already set both headers. ## Development ```sh -npm run dev +npm run examples npm run test:js cargo check --workspace ``` diff --git a/addons/default-pipelines/src/index.js b/addons/default-pipelines/src/index.js index f8e8055..5f22ce2 100644 --- a/addons/default-pipelines/src/index.js +++ b/addons/default-pipelines/src/index.js @@ -1,5 +1,5 @@ export const gltfShader = /* wgsl */ ` -struct UniformData { mouse_move: vec2, mouse_click: vec2, resolution: vec2, time: f32, _padding0: f32, camera_position: vec4 } +struct UniformData { resolution: vec2, time: f32, _padding0: f32, camera_position: vec4 } struct MaterialData { base_color_factor: vec4, emissive_factor: vec4, surface_factors: vec4, alpha_optics: vec4, flags: vec4, uv_sets: vec4, debug_extras: vec4 } @group(0) @binding(0) var uni: UniformData; @group(1) @binding(0) var view_proj: mat4x4; @@ -76,8 +76,8 @@ export const noopComputeShader = /* wgsl */ `@compute @workgroup_size(1) fn main export const defaultPipelines = Object.freeze({ render: Object.freeze([ Object.freeze({ name: "ground_plane", shader: groundShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }), - Object.freeze({ name: "gltf_standard", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }), - Object.freeze({ name: "gltf_standard_double_sided", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", doubleSided: true }), + Object.freeze({ name: "gltf_standard", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", material: true }), + Object.freeze({ name: "gltf_standard_double_sided", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", doubleSided: true, material: true }), Object.freeze({ name: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }), ]), compute: Object.freeze([ diff --git a/addons/gltf-import/package.json b/addons/gltf-import/package.json index 79bb41a..1471b09 100644 --- a/addons/gltf-import/package.json +++ b/addons/gltf-import/package.json @@ -1,7 +1,7 @@ { "name": "@yawn/gltf-import", "version": "0.1.0", - "description": "glTF fetch worker that uploads directly into Yawn shared SOA memory", + "description": "glTF worker that publishes generic render data directly into Yawn shared SOA memory", "type": "module", "exports": "./src/index.js" } diff --git a/addons/gltf-import/src/gltf.js b/addons/gltf-import/src/gltf.js new file mode 100644 index 0000000..c3f5221 --- /dev/null +++ b/addons/gltf-import/src/gltf.js @@ -0,0 +1,392 @@ +const GLB_MAGIC = 0x46546c67; +const JSON_CHUNK = 0x4e4f534a; +const BIN_CHUNK = 0x004e4942; +const PACKET_MAGIC = 0x50445259; +const PACKET_VERSION = 1; +const COMPONENT_WIDTH = Object.freeze({ SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT2: 4, MAT3: 9, MAT4: 16 }); +const COMPONENT_SIZE = Object.freeze({ 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 }); +const IDENTITY = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); +const decoder = new TextDecoder(); +const encoder = new TextEncoder(); + +const fail = code => { throw new Error(code); }; +const align4 = value => (value + 3) & ~3; +const finite = values => values.every(Number.isFinite); + +function parseContainer(source) { + if (!(source instanceof Uint8Array) || !source.byteLength) fail("GLTF_EMPTY"); + const view = new DataView(source.buffer, source.byteOffset, source.byteLength); + if (source.byteLength >= 12 && view.getUint32(0, true) === GLB_MAGIC) { + if (view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== source.byteLength) + fail("GLTF_INVALID_CONTAINER"); + let offset = 12, json, binary; + while (offset < source.byteLength) { + if (offset + 8 > source.byteLength) fail("GLTF_INVALID_CONTAINER"); + const length = view.getUint32(offset, true); + const type = view.getUint32(offset + 4, true); + const end = offset + 8 + length; + if (end > source.byteLength) fail("GLTF_INVALID_CONTAINER"); + const chunk = source.subarray(offset + 8, end); + if (type === JSON_CHUNK && !json) json = chunk; + if (type === BIN_CHUNK && !binary) binary = chunk; + offset = end; + } + if (!json) fail("GLTF_JSON_MISSING"); + return { document: JSON.parse(decoder.decode(json).replace(/\0+$/u, "").trimEnd()), binary }; + } + return { document: JSON.parse(decoder.decode(source).replace(/^\uFEFF/u, "")), binary: undefined }; +} + +async function fetchBytes(uri, baseUrl, fetcher) { + const response = await fetcher(new URL(uri, baseUrl)); + if (!response.ok) fail(`HTTP_${response.status}`); + return new Uint8Array(await response.arrayBuffer()); +} + +async function loadBuffers(document, binary, baseUrl, fetcher) { + return Promise.all((document.buffers ?? []).map(async (buffer, index) => { + const bytes = buffer.uri === undefined + ? (index === 0 ? binary : undefined) + : await fetchBytes(buffer.uri, baseUrl, fetcher); + if (!bytes || bytes.byteLength < buffer.byteLength) fail("GLTF_BUFFER_INVALID"); + return bytes; + })); +} + +function component(data, offset, type) { + switch (type) { + case 5120: return data.getInt8(offset); + case 5121: return data.getUint8(offset); + case 5122: return data.getInt16(offset, true); + case 5123: return data.getUint16(offset, true); + case 5125: return data.getUint32(offset, true); + case 5126: return data.getFloat32(offset, true); + default: fail("GLTF_ACCESSOR_COMPONENT"); + } +} + +function normalizeComponent(value, type) { + switch (type) { + case 5120: return Math.max(value / 127, -1); + case 5121: return value / 255; + case 5122: return Math.max(value / 32767, -1); + case 5123: return value / 65535; + case 5125: return value / 4294967295; + default: return value; + } +} + +function viewBytes(document, buffers, index) { + const view = document.bufferViews?.[index]; + const buffer = view && buffers[view.buffer]; + if (!view || !buffer) fail("GLTF_BUFFER_VIEW_INVALID"); + const start = view.byteOffset ?? 0; + const end = start + view.byteLength; + if (end > buffer.byteLength) fail("GLTF_BUFFER_VIEW_INVALID"); + return { view, bytes: buffer.subarray(start, end) }; +} + +function readAccessor(document, buffers, index, { integer = false } = {}) { + const accessor = document.accessors?.[index]; + const width = accessor && COMPONENT_WIDTH[accessor.type]; + const size = accessor && COMPONENT_SIZE[accessor.componentType]; + if (!accessor || !width || !size || !Number.isInteger(accessor.count) || accessor.count < 0) + fail("GLTF_ACCESSOR_INVALID"); + const values = integer ? new Uint32Array(accessor.count * width) : new Float32Array(accessor.count * width); + if (accessor.bufferView !== undefined) { + const { view, bytes } = viewBytes(document, buffers, accessor.bufferView); + const stride = view.byteStride ?? width * size; + const start = accessor.byteOffset ?? 0; + if (stride < width * size || start + Math.max(0, accessor.count - 1) * stride + width * size > bytes.byteLength) + fail("GLTF_ACCESSOR_RANGE"); + const data = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + for (let item = 0; item < accessor.count; item++) { + for (let lane = 0; lane < width; lane++) { + let value = component(data, start + item * stride + lane * size, accessor.componentType); + if (!integer && accessor.normalized) value = normalizeComponent(value, accessor.componentType); + values[item * width + lane] = value; + } + } + } + if (accessor.sparse) { + const sparse = accessor.sparse; + const indices = sparse.indices; + const indexSize = COMPONENT_SIZE[indices.componentType]; + if (!indexSize || ![5121, 5123, 5125].includes(indices.componentType)) fail("GLTF_SPARSE_INVALID"); + const indexView = viewBytes(document, buffers, indices.bufferView).bytes; + const valueView = viewBytes(document, buffers, sparse.values.bufferView).bytes; + const indexStart = indices.byteOffset ?? 0; + const valueStart = sparse.values.byteOffset ?? 0; + if (indexStart + sparse.count * indexSize > indexView.byteLength || valueStart + sparse.count * width * size > valueView.byteLength) + fail("GLTF_SPARSE_INVALID"); + const indexData = new DataView(indexView.buffer, indexView.byteOffset, indexView.byteLength); + const valueData = new DataView(valueView.buffer, valueView.byteOffset, valueView.byteLength); + for (let item = 0; item < sparse.count; item++) { + const target = component(indexData, indexStart + item * indexSize, indices.componentType); + if (target >= accessor.count) fail("GLTF_SPARSE_INVALID"); + for (let lane = 0; lane < width; lane++) { + let value = component(valueData, valueStart + (item * width + lane) * size, accessor.componentType); + if (!integer && accessor.normalized) value = normalizeComponent(value, accessor.componentType); + values[target * width + lane] = value; + } + } + } + if (!finite(values)) fail("GLTF_ACCESSOR_NONFINITE"); + return { count: accessor.count, width, values }; +} + +const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]; +const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]; +const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]; +function normalize(value, fallback) { + const length = Math.hypot(...value); + return length > Number.EPSILON && Number.isFinite(length) ? value.map(item => item / length) : fallback; +} +function lanes(values, index, width) { return Array.from(values.subarray(index * width, (index + 1) * width)); } + +function repairGeometry(positions, normals, tangents, uvs, indices) { + const count = positions.length / 3; + if (indices.length % 3 || Array.from(indices).some(index => index >= count)) fail("GLTF_TRIANGLES_INVALID"); + const normalsValid = normals?.length === positions.length; + const tangentsValid = tangents?.length === count * 4; + if (normalsValid && tangentsValid) + return { positions, normals, tangents, uvs, indices }; + const outPositions = [], outNormals = [], outTangents = [], outUvs = []; + for (let triangle = 0; triangle < indices.length; triangle += 3) { + const ids = [indices[triangle], indices[triangle + 1], indices[triangle + 2]]; + const p = ids.map(index => lanes(positions, index, 3)); + const uv = ids.map(index => lanes(uvs, index, 2)); + const faceNormal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0, 1, 0]); + const duv1 = sub([...uv[1], 0], [...uv[0], 0]); + const duv2 = sub([...uv[2], 0], [...uv[0], 0]); + const determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0]; + const edge1 = sub(p[1], p[0]), edge2 = sub(p[2], p[0]); + const rawTangent = Math.abs(determinant) > Number.EPSILON + ? edge1.map((value, lane) => (value * duv2[1] - edge2[lane] * duv1[1]) / determinant) + : [0, 0, 0]; + const rawBitangent = Math.abs(determinant) > Number.EPSILON + ? edge2.map((value, lane) => (value * duv1[0] - edge1[lane] * duv2[0]) / determinant) + : [0, 0, 0]; + for (let corner = 0; corner < 3; corner++) { + const normal = normalize(normalsValid ? lanes(normals, ids[corner], 3) : faceNormal, faceNormal); + const projected = rawTangent.map((value, lane) => value - normal[lane] * dot(normal, rawTangent)); + const axis = Math.abs(normal[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]; + const tangent = normalize(projected, normalize(cross(axis, normal), [0, 0, 1])); + const generated = [...tangent, dot(cross(normal, tangent), rawBitangent) < 0 ? -1 : 1]; + outPositions.push(...p[corner]); + outNormals.push(...normal); + outTangents.push(...(tangentsValid ? lanes(tangents, ids[corner], 4) : generated)); + outUvs.push(...uv[corner]); + } + } + const repairedIndices = Uint32Array.from({ length: outPositions.length / 3 }, (_, index) => index); + return { + positions: new Float32Array(outPositions), + normals: new Float32Array(outNormals), + tangents: new Float32Array(outTangents), + uvs: new Float32Array(outUvs), + indices: repairedIndices, + }; +} + +function multiply(a, b) { + const result = Array(16).fill(0); + for (let column = 0; column < 4; column++) + for (let row = 0; row < 4; row++) + for (let lane = 0; lane < 4; lane++) + result[column * 4 + row] += a[lane * 4 + row] * b[column * 4 + lane]; + return result; +} + +function nodeMatrix(node) { + if (node.matrix) { + if (node.matrix.length !== 16 || !finite(node.matrix)) fail("GLTF_NODE_TRANSFORM"); + return Array.from(node.matrix); + } + const [x, y, z, w] = node.rotation ?? [0, 0, 0, 1]; + const [sx, sy, sz] = node.scale ?? [1, 1, 1]; + const [tx, ty, tz] = node.translation ?? [0, 0, 0]; + const matrix = [ + (1 - 2 * y * y - 2 * z * z) * sx, (2 * x * y + 2 * z * w) * sx, (2 * x * z - 2 * y * w) * sx, 0, + (2 * x * y - 2 * z * w) * sy, (1 - 2 * x * x - 2 * z * z) * sy, (2 * y * z + 2 * x * w) * sy, 0, + (2 * x * z + 2 * y * w) * sz, (2 * y * z - 2 * x * w) * sz, (1 - 2 * x * x - 2 * y * y) * sz, 0, + tx, ty, tz, 1, + ]; + if (!finite(matrix)) fail("GLTF_NODE_TRANSFORM"); + return matrix; +} + +function textureReference(reference) { + return reference ? { texture: reference.index, texCoord: reference.texCoord ?? 0 } : null; +} + +function materialMetadata(material, index) { + const pbr = material.pbrMetallicRoughness ?? {}; + const ior = material.extensions?.KHR_materials_ior?.ior ?? 1.5; + if (!Number.isFinite(ior) || (ior !== 0 && ior < 1)) fail("GLTF_MATERIAL_IOR"); + return { + key: index + 1, + baseColorFactor: pbr.baseColorFactor ?? [1, 1, 1, 1], + metallicFactor: pbr.metallicFactor ?? 1, + roughnessFactor: pbr.roughnessFactor ?? 1, + emissiveFactor: material.emissiveFactor ?? [0, 0, 0], + ior, + alphaMode: (material.alphaMode ?? "OPAQUE").toLowerCase(), + alphaCutoff: material.alphaCutoff ?? 0.5, + doubleSided: material.doubleSided ?? false, + baseColorTexture: textureReference(pbr.baseColorTexture), + metallicRoughnessTexture: textureReference(pbr.metallicRoughnessTexture), + normalTexture: textureReference(material.normalTexture), + normalScale: material.normalTexture?.scale ?? 1, + occlusionTexture: textureReference(material.occlusionTexture), + occlusionStrength: material.occlusionTexture?.strength ?? 1, + emissiveTexture: textureReference(material.emissiveTexture), + }; +} + +function samplerMetadata(sampler) { + const min = sampler.minFilter; + return { + magFilter: sampler.magFilter === 9728 ? "nearest" : "linear", + minFilter: [9728, 9984, 9986].includes(min) ? "nearest" : "linear", + mipmapFilter: [9984, 9985].includes(min) ? "nearest" : "linear", + addressU: sampler.wrapS === 33071 ? "clamp_to_edge" : sampler.wrapS === 33648 ? "mirror_repeat" : "repeat", + addressV: sampler.wrapT === 33071 ? "clamp_to_edge" : sampler.wrapT === 33648 ? "mirror_repeat" : "repeat", + }; +} + +function inferMime(image) { + if (image.mimeType) return image.mimeType; + const uri = image.uri?.toLowerCase() ?? ""; + if (uri.startsWith("data:image/png") || uri.endsWith(".png")) return "image/png"; + if (uri.startsWith("data:image/jpeg") || /\.jpe?g(?:$|[?#])/u.test(uri)) return "image/jpeg"; + fail("GLTF_IMAGE_MIME"); +} + +async function decodeScene(document, buffers, baseUrl, fetcher) { + const geometries = [], occurrences = [], geometryIds = new Map(); + for (let meshIndex = 0; meshIndex < (document.meshes ?? []).length; meshIndex++) { + const mesh = document.meshes[meshIndex]; + for (let primitiveIndex = 0; primitiveIndex < (mesh.primitives ?? []).length; primitiveIndex++) { + const primitive = mesh.primitives[primitiveIndex]; + if ((primitive.mode ?? 4) !== 4 || primitive.attributes?.POSITION === undefined) fail("GLTF_TRIANGLES_REQUIRED"); + const position = readAccessor(document, buffers, primitive.attributes.POSITION); + if (position.width !== 3 || !position.count) fail("GLTF_POSITION_INVALID"); + const normal = primitive.attributes.NORMAL === undefined ? null : readAccessor(document, buffers, primitive.attributes.NORMAL); + const tangent = primitive.attributes.TANGENT === undefined ? null : readAccessor(document, buffers, primitive.attributes.TANGENT); + const texcoord = primitive.attributes.TEXCOORD_0 === undefined ? null : readAccessor(document, buffers, primitive.attributes.TEXCOORD_0); + if (normal && normal.width !== 3 || tangent && tangent.width !== 4 || texcoord && texcoord.width !== 2) + fail("GLTF_ATTRIBUTE_INVALID"); + const uvs = new Float32Array(position.count * 2); + if (texcoord) uvs.set(texcoord.values.subarray(0, uvs.length)); + const indexAccessor = primitive.indices === undefined + ? null + : readAccessor(document, buffers, primitive.indices, { integer: true }); + if (indexAccessor && indexAccessor.width !== 1) fail("GLTF_INDEX_INVALID"); + const indices = indexAccessor?.values + ?? Uint32Array.from({ length: position.count }, (_, index) => index); + const repaired = repairGeometry(position.values, normal?.values, tangent?.values, uvs, indices); + const id = geometries.length; + geometryIds.set(`${meshIndex}:${primitiveIndex}`, id); + const material = primitive.material === undefined ? undefined : document.materials?.[primitive.material]; + geometries.push({ + id, + material: primitive.material === undefined ? 0 : primitive.material + 1, + instanceType: [1 | 4 | (material?.doubleSided ? 8 : 0), ...Array(15).fill(0)], + ...repaired, + }); + } + } + + const children = new Set((document.nodes ?? []).flatMap(node => node.children ?? [])); + const scene = document.scenes?.[document.scene ?? 0]; + const roots = scene?.nodes ?? (document.nodes ?? []).map((_, index) => index).filter(index => !children.has(index)); + const active = new Set(); + const visit = (nodeIndex, parent) => { + const node = document.nodes?.[nodeIndex]; + if (!node || active.has(nodeIndex)) fail("GLTF_NODE_INVALID"); + active.add(nodeIndex); + const world = multiply(parent, nodeMatrix(node)); + if (node.mesh !== undefined) { + const mesh = document.meshes?.[node.mesh]; + if (!mesh) fail("GLTF_MESH_INVALID"); + for (let primitive = 0; primitive < mesh.primitives.length; primitive++) { + const geometry = geometryIds.get(`${node.mesh}:${primitive}`); + if (geometry !== undefined) occurrences.push({ geometry, transform: world }); + } + } + for (const child of node.children ?? []) visit(child, world); + active.delete(nodeIndex); + }; + for (const root of roots) visit(root, IDENTITY); + + const images = await Promise.all((document.images ?? []).map(async image => { + const data = image.bufferView === undefined + ? await fetchBytes(image.uri, baseUrl, fetcher) + : viewBytes(document, buffers, image.bufferView).bytes.slice(); + return { mimeType: inferMime(image), bytes: data }; + })); + return { + geometries, + occurrences, + materials: [materialMetadata({}, -1), ...(document.materials ?? []).map(materialMetadata)], + textures: (document.textures ?? []).map(texture => ({ image: texture.source, sampler: texture.sampler ?? null })), + samplers: (document.samplers ?? []).map(samplerMetadata), + images, + }; +} + +function encodePacket(scene) { + const chunks = []; + let payloadLength = 0; + const append = (source, alignment = 4) => { + const bytes = source instanceof Uint8Array + ? source + : new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + const offset = alignment === 4 ? align4(payloadLength) : payloadLength; + if (offset > payloadLength) chunks.push({ offset: payloadLength, bytes: new Uint8Array(offset - payloadLength) }); + chunks.push({ offset, bytes }); + payloadLength = offset + bytes.byteLength; + return offset; + }; + const stream = (values, width) => ({ offset: append(values), count: values.length / width }); + const metadata = { + geometries: scene.geometries.map(geometry => ({ + id: geometry.id, + material: geometry.material, + instanceType: geometry.instanceType, + positions: stream(geometry.positions, 3), + normals: stream(geometry.normals, 3), + tangents: stream(geometry.tangents, 4), + uvs: stream(geometry.uvs, 2), + indices: stream(geometry.indices, 1), + })), + occurrences: scene.occurrences, + materials: scene.materials, + textures: scene.textures, + samplers: scene.samplers, + images: scene.images.map(image => ({ + mimeType: image.mimeType, + data: { offset: append(image.bytes), byteLength: image.bytes.byteLength }, + })), + }; + const metadataBytes = encoder.encode(JSON.stringify(metadata)); + const payloadOffset = align4(16 + metadataBytes.byteLength); + const packet = new Uint8Array(payloadOffset + payloadLength); + const header = new DataView(packet.buffer); + header.setUint32(0, PACKET_MAGIC, true); + header.setUint32(4, PACKET_VERSION, true); + header.setUint32(8, metadataBytes.byteLength, true); + header.setUint32(12, payloadLength, true); + packet.set(metadataBytes, 16); + for (const chunk of chunks) packet.set(chunk.bytes, payloadOffset + chunk.offset); + return packet; +} + +/** Convert glTF 2.0/GLB bytes into Yawn's format-neutral render-data packet. */ +export async function gltfToRenderDataPacket(source, baseUrl, fetcher = fetch) { + const { document, binary } = parseContainer(source); + if (document.asset?.version !== "2.0") fail("GLTF_VERSION_UNSUPPORTED"); + const buffers = await loadBuffers(document, binary, baseUrl, fetcher); + return encodePacket(await decodeScene(document, buffers, baseUrl, fetcher)); +} diff --git a/addons/gltf-import/src/index.js b/addons/gltf-import/src/index.js index 270fcc5..f53a657 100644 --- a/addons/gltf-import/src/index.js +++ b/addons/gltf-import/src/index.js @@ -6,7 +6,31 @@ export class GltfImportError extends Error { } } -/** Fetches glTF in a dedicated worker and commits only shared-memory upload metadata. */ +function frameCamera(core, bounds, framing) { + if (!bounds || framing === false) return; + if (framing !== undefined && framing !== "exterior" && framing !== "interior") + throw new TypeError("framing must be exterior, interior, or false"); + const min = bounds.min, max = bounds.max; + if (!Array.isArray(min) || !Array.isArray(max) || min.length !== 3 || max.length !== 3) return; + const center = min.map((value, axis) => (value + max[axis]) * 0.5); + const extent = max.map((value, axis) => value - min[axis]); + const radius = Math.max(1, Math.hypot(...extent) * 0.5); + const camera = core.array("camera.state"); + const state = camera.read(0); + const interior = framing === "interior"; + const eye = interior + ? [center[0], center[1] + radius * 0.05, center[2]] + : [center[0] + radius * 1.8, center[1] + radius * 1.4, center[2] + radius * 1.8]; + const target = interior ? [center[0] + radius, center[1], center[2]] : center; + state.splice(0, 3, ...eye); + state.splice(4, 3, ...target); + state.splice(8, 3, 0, 1, 0); + state[14] = Math.max(radius * 0.001, 0.1); + state[15] = Math.max(radius * 6, 1.1); + camera.write(0, state); +} + +/** Parses glTF in a dedicated worker and publishes a generic render-data packet through shared memory. */ export class GltfImporter { #core; #worker; @@ -16,7 +40,8 @@ export class GltfImporter { #disposed = false; constructor(core, { workerFactory } = {}) { - if (!core?.allocateArray || !core?.commitGlbUpload) throw new TypeError("core must implement the Yawn shared upload protocol"); + if (!core?.allocateArray || !core?.commitRenderDataUpload) + throw new TypeError("core must implement the Yawn shared render-data protocol"); this.#core = core; this.#worker = workerFactory ? workerFactory() @@ -55,7 +80,7 @@ export class GltfImporter { if (message.type === "allocate") { const length = Math.ceil(message.byteLength / 16); pending.array = await this.#core.allocateArray({ - name: "upload.gltf", + name: "upload.renderData", domain: "fixed", scalar: "u32", lanes: 4, @@ -68,11 +93,11 @@ export class GltfImporter { ...pending.array.share(), }); } else if (message.type === "ready") { - const result = await this.#core.commitGlbUpload( + const result = await this.#core.commitRenderDataUpload( pending.array, message.byteLength, - pending.options, ); + frameCamera(this.#core, result.bounds, pending.options.framing); this.#pending.delete(message.request); pending.resolve(result); } else if (message.type === "error") { diff --git a/addons/gltf-import/src/worker.js b/addons/gltf-import/src/worker.js index 62209ae..5929054 100644 --- a/addons/gltf-import/src/worker.js +++ b/addons/gltf-import/src/worker.js @@ -1,4 +1,5 @@ import { writeSharedUpload } from "./shared-upload.js"; +import { gltfToRenderDataPacket } from "./gltf.js"; const downloads = new Map(); @@ -10,16 +11,17 @@ addEventListener("message", async ({ data: message }) => { if (!response.ok) throw new Error(`HTTP_${response.status}`); const bytes = new Uint8Array(await response.arrayBuffer()); if (!bytes.byteLength) throw new Error("GLTF_EMPTY"); - downloads.set(request, bytes); - postMessage({ type: "allocate", request, byteLength: bytes.byteLength }); + const packet = await gltfToRenderDataPacket(bytes, message.url); + downloads.set(request, packet); + postMessage({ type: "allocate", request, byteLength: packet.byteLength }); return; } if (message?.type === "storage") { - const bytes = downloads.get(request); - if (!bytes) throw new Error("GLTF_REQUEST_UNKNOWN"); - writeSharedUpload(message.buffer, message.descriptor, bytes); + const packet = downloads.get(request); + if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN"); + writeSharedUpload(message.buffer, message.descriptor, packet); downloads.delete(request); - postMessage({ type: "ready", request, byteLength: bytes.byteLength }); + postMessage({ type: "ready", request, byteLength: packet.byteLength }); } } catch (error) { downloads.delete(request); diff --git a/addons/mesh-handles/package.json b/addons/mesh-handles/package.json index c338c13..3035a98 100644 --- a/addons/mesh-handles/package.json +++ b/addons/mesh-handles/package.json @@ -1,7 +1,7 @@ { "name": "@yawn/mesh-handles", "version": "0.1.0", - "description": "Conventional mesh handles over the Yawn worker and shared-SOA protocol", + "description": "Conventional mesh, instance, camera, and material handles over Yawn render data", "type": "module", "exports": "./src/index.js", "dependencies": { diff --git a/addons/mesh-handles/src/bvh-worker.js b/addons/mesh-handles/src/bvh-worker.js index 598954f..af0bd61 100644 --- a/addons/mesh-handles/src/bvh-worker.js +++ b/addons/mesh-handles/src/bvh-worker.js @@ -1,4 +1,4 @@ -import { SnapshotReader } from "@yawn/core/snapshot"; +import { SnapshotReader } from "./snapshot.js"; import { DerivedBvh } from "./bvh-core.js"; let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0; diff --git a/addons/mesh-handles/src/index.js b/addons/mesh-handles/src/index.js index 9464ca0..1e4e9f0 100644 --- a/addons/mesh-handles/src/index.js +++ b/addons/mesh-handles/src/index.js @@ -1,7 +1,10 @@ -const TOKEN = Symbol("yawn mesh handle addon"); +import { RendererError } from "@yawn/core"; +import { SnapshotReader } from "./snapshot.js"; -/** Creates the optional snapshot/BVH worker used by core's picking protocol. */ -export const createPickingWorker = () => new Worker( +const TOKEN = Symbol("yawn mesh handle addon"); +const SNAPSHOT_EVENT = "yawn-render-data-snapshot"; +const PUBLISHED_EVENT = "yawn-render-data-snapshot-published"; +const createPickingWorker = () => new Worker( new URL("./bvh-worker.js", import.meta.url), { type: "module", name: "yawn-spatial-query" }, ); @@ -9,9 +12,17 @@ export const createPickingWorker = () => new Worker( /** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */ export class MeshHandles { #core; + #factory; #worker; #reader; #snapshot; #epoch = 0; #next = 1; #picks = new Map(); #disposed = false; + #onSnapshot; #onPublished; - constructor(core) { + constructor(core, { pickingWorkerFactory = createPickingWorker } = {}) { this.#core = core; + this.#factory = pickingWorkerFactory; + this.#onSnapshot = event => this.#installSnapshot(event.detail); + this.#onPublished = event => this.#publish(event.detail?.epoch); + core.addEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot); + core.addEventListener?.(PUBLISHED_EVENT, this.#onPublished); + if (core.renderDataSnapshot) this.#installSnapshot(core.renderDataSnapshot); } fromImportedScene(result) { @@ -20,7 +31,7 @@ export class MeshHandles { } async pickRay(origin, direction, options) { - const result = await this.#core.pickRay(origin, direction, options); + const result = await this.#pickRay(origin, direction, options); return { ...result, hits: result.hits.map((hit) => ({ @@ -29,6 +40,124 @@ export class MeshHandles { })), }; } + + #installSnapshot(snapshot) { + try { + if (snapshot?.controlVersion !== 1 || snapshot?.schemaVersion !== 2) throw new Error("version"); + this.#snapshot = snapshot; + this.#reader = new SnapshotReader(snapshot.memory, snapshot.controlPtr); + this.#epoch = this.#reader.latest().epoch; + this.#worker?.postMessage({ type: "init", ...snapshot }); + } catch { + this.#disable("PICK_PROTOCOL_MISMATCH"); + } + } + + #publish(epoch) { + if (this.#disposed || !this.#reader) return; + try { + this.#epoch = this.#reader.latest().epoch; + this.#worker?.postMessage({ type: "update", epoch: this.#epoch || (epoch >>> 0) }); + } catch { + this.#disable("PICK_PROTOCOL_MISMATCH"); + } + } + + #ensureWorker() { + if (this.#worker) return true; + if (!this.#reader || !this.#factory) return false; + try { + this.#worker = this.#factory(); + this.#worker.addEventListener("message", event => this.#workerMessage(event.data)); + this.#worker.addEventListener("error", () => this.#disable("PICK_WORKER_ERROR")); + this.#worker.addEventListener("messageerror", () => this.#disable("PICK_WORKER_ERROR")); + this.#worker.start?.(); + this.#worker.postMessage({ type: "init", ...this.#snapshot }); + if (this.#epoch) this.#worker.postMessage({ type: "update", epoch: this.#epoch }); + return true; + } catch { + this.#disable("PICK_WORKER_ERROR"); + return false; + } + } + + #disable(code) { + const error = new RendererError(code); + for (const pending of this.#picks.values()) pending.reject(error); + this.#picks.clear(); + try { this.#worker?.terminate?.(); } catch { /* best effort */ } + this.#worker = null; + } + + #workerMessage(message) { + if (message?.type === "fatal") { this.#disable(message.code || "PICK_WORKER_ERROR"); return; } + if (message?.type !== "pick") return; + const pending = this.#picks.get(message.request); + if (!pending) return; + this.#picks.delete(message.request); + let latest; + try { latest = this.#reader.latest().epoch; this.#epoch = latest; } + catch { pending.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); this.#disable("PICK_PROTOCOL_MISMATCH"); return; } + if (message.stale || pending.epoch !== message.epoch || message.epoch !== latest) { + if (!pending.retried && latest) this.#sendPick({ ...pending, retried: true }, latest); + else pending.reject(new RendererError("PICK_STALE")); + return; + } + pending.resolve({ + epoch: latest, + hits: (message.hits || []).map(hit => ({ + instance: [hit.slot >>> 0, hit.generation >>> 0], + distance: hit.distance, + })), + }); + } + + #sendPick(pending, epoch) { + const request = this.#next++ >>> 0 || this.#next++; + pending.epoch = epoch; + this.#picks.set(request, pending); + try { + this.#worker.postMessage({ + type: "pick", request, epoch, + origin: pending.origin, direction: pending.direction, + maxDistance: pending.maxDistance, maxHits: pending.maxHits, + }); + } catch { + this.#picks.delete(request); + pending.reject(new RendererError("PICK_WORKER_ERROR")); + } + } + + #pickRay(origin, direction, { maxDistance = Infinity, maxHits = 1 } = {}) { + const vector = (value, name) => { + if (!value || value.length !== 3 || [...value].some(x => typeof x !== "number" || !Number.isFinite(x))) + throw new TypeError(`${name} must contain 3 finite numbers`); + return [...value]; + }; + origin = vector(origin, "origin"); + direction = vector(direction, "direction"); + if (direction.every(x => x === 0)) throw new TypeError("direction must be nonzero"); + if (typeof maxDistance !== "number" || (!(Number.isFinite(maxDistance) && maxDistance >= 0) && maxDistance !== Infinity) || !Number.isInteger(maxHits) || maxHits < 1 || maxHits > 64) + throw new TypeError("invalid pick options"); + if (this.#disposed) return Promise.reject(new RendererError("DISPOSED")); + if (!this.#ensureWorker()) return Promise.reject(new RendererError("PICK_UNAVAILABLE")); + let epoch; + try { epoch = this.#reader.latest().epoch; this.#epoch = epoch; } + catch { return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); } + if (!epoch) return Promise.reject(new RendererError("PICK_STALE")); + return new Promise((resolve, reject) => this.#sendPick({ + resolve, reject, origin, direction, maxDistance, maxHits, retried: false, + }, epoch)); + } + + dispose() { + if (this.#disposed) return; + this.#disposed = true; + this.#core.removeEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot); + this.#core.removeEventListener?.(PUBLISHED_EVENT, this.#onPublished); + try { this.#worker?.postMessage?.({ type: "dispose" }); } catch { /* best effort */ } + this.#disable("DISPOSED"); + } } export class Mesh { @@ -65,3 +194,169 @@ export class Instance { setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); } async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; } } + +function requireArray(core, name, { domain, scalar, lanes }) { + if (!core?.array) throw new TypeError("core must implement the Yawn shared render-data protocol"); + const array = core.array(name); + if (array.domain !== domain || array.scalar !== scalar || array.lanes !== lanes) + throw new RendererError("SOA_PROTOCOL_MISMATCH"); + return array; +} + +function vector(value, length, name) { + if (!value || value.length !== length || [...value].some(item => typeof item !== "number" || !Number.isFinite(item))) + throw new TypeError(`${name} must contain ${length} finite numbers`); + return Array.from(value); +} + +function finite(value, name) { + if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be finite`); + return value; +} + +/** Conventional camera properties backed by the canonical SIMD-width camera SOA row. */ +export class CameraHandle { + #array; + + constructor(core) { + this.#array = requireArray(core, "camera.state", { domain: "fixed", scalar: "f32", lanes: 16 }); + } + + get state() { return this.#array.read(0); } + set state(value) { this.#write(vector(value, 16, "state")); } + get position() { return this.state.slice(0, 3); } + set position(value) { this.update({ position: value }); } + get target() { return this.state.slice(4, 7); } + set target(value) { this.update({ target: value }); } + get up() { return this.state.slice(8, 11); } + set up(value) { this.update({ up: value }); } + get fovY() { return this.state[12]; } + set fovY(value) { this.update({ fovY: value }); } + get aspect() { return this.state[13]; } + set aspect(value) { this.update({ aspect: value }); } + get near() { return this.state[14]; } + set near(value) { this.update({ near: value }); } + get far() { return this.state[15]; } + set far(value) { this.update({ far: value }); } + + update(properties = {}) { + if (!properties || typeof properties !== "object") throw new TypeError("camera properties must be an object"); + const known = new Set(["position", "target", "up", "fovY", "aspect", "near", "far"]); + for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown camera property '${key}'`); + const state = this.state; + if (properties.position !== undefined) state.splice(0, 3, ...vector(properties.position, 3, "position")); + if (properties.target !== undefined) state.splice(4, 3, ...vector(properties.target, 3, "target")); + if (properties.up !== undefined) state.splice(8, 3, ...vector(properties.up, 3, "up")); + if (properties.fovY !== undefined) state[12] = finite(properties.fovY, "fovY"); + if (properties.aspect !== undefined) state[13] = finite(properties.aspect, "aspect"); + if (properties.near !== undefined) state[14] = finite(properties.near, "near"); + if (properties.far !== undefined) state[15] = finite(properties.far, "far"); + this.#write(state); + return this; + } + + lookAt(position, target, { up = this.up } = {}) { + return this.update({ position, target, up }); + } + + #write(state) { + const offset = state.slice(0, 3).map((value, axis) => state[4 + axis] - value); + const up = state.slice(8, 11); + const cross = [ + offset[1] * up[2] - offset[2] * up[1], + offset[2] * up[0] - offset[0] * up[2], + offset[0] * up[1] - offset[1] * up[0], + ]; + if (Math.hypot(...offset) < 0.1 || Math.hypot(...up) === 0 || Math.hypot(...cross) === 0) + throw new RangeError("camera position, target, and up do not define a view"); + if (!(state[12] > 0 && state[12] < Math.PI) || state[13] <= 0 || state[14] <= 0 || state[15] <= state[14]) + throw new RangeError("camera projection is invalid"); + state[3] = state[7] = 1; + state[11] = 0; + this.#array.write(0, state); + } +} + +const FLOAT_WORD = new ArrayBuffer(4); +const FLOAT_VIEW = new Float32Array(FLOAT_WORD); +const WORD_VIEW = new Uint32Array(FLOAT_WORD); +function wordToFloat(word) { WORD_VIEW[0] = word; return FLOAT_VIEW[0]; } +function floatToWord(value) { FLOAT_VIEW[0] = value; return WORD_VIEW[0]; } + +/** Creates scene-scoped material objects over packed material SOA rows. */ +export class MaterialHandles { + #array; + + constructor(core) { + this.#array = requireArray(core, "material.state", { domain: "fixed", scalar: "u32", lanes: 28 }); + } + + fromImportedScene(result) { + if (!result || !Array.isArray(result.materials)) throw new TypeError("invalid imported scene"); + return result.materials.map(material => this.get(material?.key)); + } + + get(key) { + if (!Number.isInteger(key) || key < 0 || key > 0xffffffff || key >= this.#array.length) + throw new RangeError("material key is outside the shared material rows"); + return new MaterialHandle(TOKEN, this.#array, key); + } +} + +export class MaterialHandle { + #array; #key; + + constructor(token, array, key) { + if (token !== TOKEN) throw new TypeError("MaterialHandle cannot be constructed directly"); + this.#array = array; + this.#key = key; + } + + get key() { return this.#key; } + get baseColor() { return this.#floats(0, 4); } + set baseColor(value) { this.update({ baseColor: value }); } + get emissive() { return this.#floats(4, 3); } + set emissive(value) { this.update({ emissive: value }); } + get metallic() { return this.#float(8); } + set metallic(value) { this.update({ metallic: value }); } + get roughness() { return this.#float(9); } + set roughness(value) { this.update({ roughness: value }); } + get normalScale() { return this.#float(10); } + set normalScale(value) { this.update({ normalScale: value }); } + get occlusionStrength() { return this.#float(11); } + set occlusionStrength(value) { this.update({ occlusionStrength: value }); } + get alphaCutoff() { return this.#float(13); } + set alphaCutoff(value) { this.update({ alphaCutoff: value }); } + get ior() { return this.#float(14); } + set ior(value) { this.update({ ior: value }); } + + update(properties = {}) { + if (!properties || typeof properties !== "object") throw new TypeError("material properties must be an object"); + const known = new Set(["baseColor", "emissive", "metallic", "roughness", "normalScale", "occlusionStrength", "alphaCutoff", "ior"]); + for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown material property '${key}'`); + const words = this.#array.read(this.#key); + const setFloat = (lane, value, name) => { words[lane] = floatToWord(finite(value, name)); }; + if (properties.baseColor !== undefined) + vector(properties.baseColor, 4, "baseColor").forEach((value, lane) => setFloat(lane, value, "baseColor")); + if (properties.emissive !== undefined) + vector(properties.emissive, 3, "emissive").forEach((value, lane) => setFloat(4 + lane, value, "emissive")); + for (const [name, lane] of [["metallic", 8], ["roughness", 9], ["normalScale", 10], ["occlusionStrength", 11], ["alphaCutoff", 13]]) { + if (properties[name] !== undefined) setFloat(lane, properties[name], name); + } + if (properties.metallic !== undefined && !(properties.metallic >= 0 && properties.metallic <= 1)) throw new RangeError("metallic must be in [0, 1]"); + if (properties.roughness !== undefined && !(properties.roughness >= 0 && properties.roughness <= 1)) throw new RangeError("roughness must be in [0, 1]"); + if (properties.occlusionStrength !== undefined && !(properties.occlusionStrength >= 0 && properties.occlusionStrength <= 1)) throw new RangeError("occlusionStrength must be in [0, 1]"); + if (properties.alphaCutoff !== undefined && !(properties.alphaCutoff >= 0 && properties.alphaCutoff <= 1)) throw new RangeError("alphaCutoff must be in [0, 1]"); + if (properties.ior !== undefined) { + const ior = finite(properties.ior, "ior"); + if (ior !== 0 && ior < 1) throw new RangeError("ior must be 0 or at least 1"); + setFloat(14, ior, "ior"); + setFloat(15, ior === 0 ? 1 : ((ior - 1) / (ior + 1)) ** 2, "ior"); + } + this.#array.write(this.#key, words); + return this; + } + + #float(lane) { return wordToFloat(this.#array.read(this.#key)[lane]); } + #floats(start, length) { return this.#array.read(this.#key).slice(start, start + length).map(wordToFloat); } +} diff --git a/packages/yawn-core/src/snapshot.js b/addons/mesh-handles/src/snapshot.js similarity index 98% rename from packages/yawn-core/src/snapshot.js rename to addons/mesh-handles/src/snapshot.js index 7e5f853..6787eb2 100644 --- a/packages/yawn-core/src/snapshot.js +++ b/addons/mesh-handles/src/snapshot.js @@ -1,4 +1,4 @@ -/** Shared render-data snapshot protocol used by core picking workers. */ +/** Shared render-data snapshot protocol consumed by the mesh-handle BVH worker. */ export const SNAPSHOT = Object.freeze({ MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256, SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2, diff --git a/addons/render-graph-ast/src/index.js b/addons/render-graph-ast/src/index.js index 93d513f..71baec7 100644 --- a/addons/render-graph-ast/src/index.js +++ b/addons/render-graph-ast/src/index.js @@ -51,13 +51,15 @@ function normalizePipelines(raw = {}) { vertexEntry: pipeline.vertexEntry ?? "vs_main", fragmentEntry: pipeline.fragmentEntry ?? "fs_main", doubleSided: pipeline.doubleSided ?? false, + material: pipeline.material ?? false, }; if ( !identifier(result.name) || !identifier(result.vertexEntry) || !identifier(result.fragmentEntry) || typeof result.shader !== "string" || - typeof result.doubleSided !== "boolean" + typeof result.doubleSided !== "boolean" || + typeof result.material !== "boolean" ) fail("AST_PIPELINE", "invalid render pipeline declaration"); return result; diff --git a/examples/README.md b/examples/README.md index 465be8e..b49c268 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,9 +1,10 @@ # Yawn examples +- `index.html` is the browser landing page for the complete example and recipes. - `render-graph-studio/` is the complete browser integration with FXNode, JSO, external pipelines, shared glTF import, mesh handles, and picking. - `cookbook/` contains small copyable recipes, each focused on one public API. -Start the full browser example with `npm run dev`. The cookbook modules are plain +Start the examples index with `npm run examples`. The cookbook modules are plain ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live renderer is required. diff --git a/examples/cookbook/08-compile-and-switch.js b/examples/cookbook/08-compile-and-switch.js index 1309be5..4a9da38 100644 --- a/examples/cookbook/08-compile-and-switch.js +++ b/examples/cookbook/08-compile-and-switch.js @@ -11,9 +11,3 @@ export async function compileAndSwitch(core, graph) { throw error; } } - -/** Return to clear-only mode before releasing an active compiled loadout. */ -export async function dropActiveGraph(core, compiled) { - await core.switchToImmediate(); - await core.dropCompiledGraph(compiled.compiledId); -} diff --git a/examples/cookbook/13-bvh-picking.js b/examples/cookbook/13-bvh-picking.js index 5c8fd6c..8cbbe1e 100644 --- a/examples/cookbook/13-bvh-picking.js +++ b/examples/cookbook/13-bvh-picking.js @@ -1,8 +1,6 @@ -import { MeshHandles } from "@yawn/mesh-handles"; - /** Query the optional snapshot/BVH worker and receive wrapped instance handles. */ -export function pickNearest(core, origin, direction) { - return new MeshHandles(core).pickRay(origin, direction, { +export function pickNearest(meshHandles, origin, direction) { + return meshHandles.pickRay(origin, direction, { maxDistance: 10_000, maxHits: 1, }); diff --git a/examples/cookbook/14-worker-to-worker.js b/examples/cookbook/14-worker-to-worker.js index d63eea3..20310e4 100644 --- a/examples/cookbook/14-worker-to-worker.js +++ b/examples/cookbook/14-worker-to-worker.js @@ -6,7 +6,6 @@ export async function connectFromWorker(port, options = {}) { worker: port, memory: options.memory, ringPtr: options.ringPtr, - pickingWorkerFactory: options.pickingWorkerFactory, free: options.free, }); await core.ready; diff --git a/examples/cookbook/15-complete-scene.js b/examples/cookbook/15-complete-scene.js index fe065a9..fd60433 100644 --- a/examples/cookbook/15-complete-scene.js +++ b/examples/cookbook/15-complete-scene.js @@ -1,19 +1,10 @@ import { culling } from "../render-graph-studio/render-graph/presets.js"; import { importGltf } from "./09-gltf-import-worker.js"; -import { compileAndSwitch, dropActiveGraph } from "./08-compile-and-switch.js"; +import { compileAndSwitch } from "./08-compile-and-switch.js"; /** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */ export async function loadCompleteScene(core, gltfUrl) { + const meshes = await importGltf(core, gltfUrl); const compiled = await compileAndSwitch(core, culling); - try { - const meshes = await importGltf(core, gltfUrl); - return { - compiled, - meshes, - dispose: () => dropActiveGraph(core, compiled), - }; - } catch (error) { - await dropActiveGraph(core, compiled).catch(() => {}); - throw error; - } + return { compiled, meshes }; } diff --git a/examples/cookbook/16-camera-render-data.js b/examples/cookbook/16-camera-render-data.js new file mode 100644 index 0000000..667e1ef --- /dev/null +++ b/examples/cookbook/16-camera-render-data.js @@ -0,0 +1,122 @@ +const MIN_DISTANCE = 0.1; +const MAX_PITCH = Math.PI / 2 - 0.01; + +// The camera is ordinary render data, not a core API. This example maps browser +// controls straight onto its packed SOA row without sending input messages. +export function installCameraRenderDataControls(core, canvas) { + const camera = core.array("camera.state"); + const abort = new AbortController(); + const options = { signal: abort.signal }; + const write = (state) => camera.write(0, state); + + canvas.addEventListener( + "pointerdown", + (event) => { + if (event.pointerType === "mouse" && (event.button === 1 || event.button === 2)) { + event.preventDefault(); + canvas.setPointerCapture(event.pointerId); + } + }, + options, + ); + canvas.addEventListener( + "pointermove", + (event) => { + if ( + event.pointerType !== "mouse" || + !canvas.hasPointerCapture(event.pointerId) || + (event.buttons & 6) === 0 + ) { + return; + } + event.preventDefault(); + const state = camera.read(0); + const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]]; + const distance = Math.hypot(...offset); + if ((event.buttons & 4) !== 0) { + const yaw = Math.atan2(offset[0], offset[2]) + event.movementX * 0.005; + const pitch = Math.max( + -MAX_PITCH, + Math.min( + MAX_PITCH, + Math.asin(offset[1] / distance) + event.movementY * 0.005, + ), + ); + const horizontal = Math.cos(pitch) * distance; + state[0] = state[4] + Math.sin(yaw) * horizontal; + state[1] = state[5] + Math.sin(pitch) * distance; + state[2] = state[6] + Math.cos(yaw) * horizontal; + } else { + const forward = [ + (state[4] - state[0]) / distance, + (state[5] - state[1]) / distance, + (state[6] - state[2]) / distance, + ]; + const right = [ + forward[1] * state[10] - forward[2] * state[9], + forward[2] * state[8] - forward[0] * state[10], + forward[0] * state[9] - forward[1] * state[8], + ]; + const rightLength = Math.hypot(...right); + right.forEach((value, index) => (right[index] = value / rightLength)); + const up = [ + right[1] * forward[2] - right[2] * forward[1], + right[2] * forward[0] - right[0] * forward[2], + right[0] * forward[1] - right[1] * forward[0], + ]; + const units = + (2 * distance * Math.tan(state[12] * 0.5)) / + Math.max(1, canvas.clientHeight); + const translation = right.map( + (value, index) => + -value * event.movementX * units + up[index] * event.movementY * units, + ); + for (let index = 0; index < 3; index++) { + state[index] += translation[index]; + state[index + 4] += translation[index]; + } + } + write(state); + }, + options, + ); + for (const type of ["pointerup", "pointercancel"]) { + canvas.addEventListener( + type, + (event) => { + if (canvas.hasPointerCapture(event.pointerId)) { + canvas.releasePointerCapture(event.pointerId); + } + }, + options, + ); + } + canvas.addEventListener( + "wheel", + (event) => { + event.preventDefault(); + const delta = + event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? event.deltaY * 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? event.deltaY * canvas.clientHeight + : event.deltaY; + if (!Number.isFinite(delta) || delta === 0) return; + const state = camera.read(0); + const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]]; + const distance = Math.hypot(...offset); + const nextDistance = Math.max( + MIN_DISTANCE, + Math.min(state[15] * 0.95, distance * Math.exp(0.002 * delta)), + ); + for (let index = 0; index < 3; index++) { + state[index] = state[index + 4] + offset[index] * (nextDistance / distance); + } + write(state); + }, + { ...options, passive: false }, + ); + canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options); + + return () => abort.abort(); +} diff --git a/examples/cookbook/17-conventional-handles.js b/examples/cookbook/17-conventional-handles.js new file mode 100644 index 0000000..8f9fb9e --- /dev/null +++ b/examples/cookbook/17-conventional-handles.js @@ -0,0 +1,19 @@ +import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles"; + +/** Wrap one import result in the conventional object API without hiding core. */ +export function conventionalSceneHandles(core, imported) { + return { + camera: new CameraHandle(core), + meshes: new MeshHandles(core).fromImportedScene(imported), + materials: new MaterialHandles(core).fromImportedScene(imported), + }; +} + +/** Property assignments remain direct SAB writes; no camera/material message is sent. */ +export function restyleScene({ camera, materials }) { + camera.lookAt([4, 3, 6], [0, 0, 0]); + if (materials[0]) { + materials[0].baseColor = [0.2, 0.55, 1, 1]; + materials[0].roughness = 0.35; + } +} diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md index 9b8766a..93a75b7 100644 --- a/examples/cookbook/README.md +++ b/examples/cookbook/README.md @@ -12,7 +12,7 @@ Import the function you need and pass the same `YawnCore` instance to every addo | `05-default-pipelines.js` | Attaching optional external WGSL declarations | | `06-custom-render-pipeline.js` | Supplying a custom scene render shader | | `07-compute-pipeline.js` | Supplying a binding-free compute pass | -| `08-compile-and-switch.js` | Compiling, activating, and safely cleaning up a graph | +| `08-compile-and-switch.js` | Compiling and transactionally activating a graph | | `09-gltf-import-worker.js` | Fetching a glTF URL into shared memory from a worker | | `10-mesh-instances.js` | Creating and mutating conventional instance handles | | `11-custom-soa-column.js` | Allocating an instance-sized shared SOA column | @@ -20,6 +20,8 @@ Import the function you need and pass the same `YawnCore` instance to every addo | `13-bvh-picking.js` | Ray picking through the mesh-handles addon | | `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` | | `15-complete-scene.js` | Combining the graph, glTF, and mesh addons | +| `16-camera-render-data.js` | Treating camera input as direct render-data SAB writes | +| `17-conventional-handles.js` | Conventional camera and material properties over SAB rows | Recipes 1–7 isolate graph authoring concepts, so their ASTs are intentionally fragments rather than complete renderable loadouts. Recipe 15 uses the complete diff --git a/examples/cookbook/index.js b/examples/cookbook/index.js index 1033571..cecd9e1 100644 --- a/examples/cookbook/index.js +++ b/examples/cookbook/index.js @@ -13,3 +13,5 @@ export * from "./12-direct-sab-animation.js"; export * from "./13-bvh-picking.js"; export * from "./14-worker-to-worker.js"; export * from "./15-complete-scene.js"; +export * from "./16-camera-render-data.js"; +export * from "./17-conventional-handles.js"; diff --git a/examples/index.html b/examples/index.html new file mode 100644 index 0000000..6945256 --- /dev/null +++ b/examples/index.html @@ -0,0 +1,201 @@ + + + + + + + Yawn Examples + + + +
+
+
YAWN.
+ +
+
+
+
Worker-native rendering toolkit
+

Build the graph.
Share the data.

+

+ Start with the complete interactive scene, then pull apart the focused + recipes for graph ASTs, external pipelines, glTF workers, mesh handles, + and direct shared-memory mutation. +

+
+ + +
+
Interactive example · all packages
+

Render Graph Studio

+

+ Edit an FXNode graph, swap JSO loadouts, stream procedural glTF through + shared memory, pick instances, and orbit the shared-SOA camera. +

+
Launch studio
+
+ +
+ +
+
+

Focused recipes

+

Small ES modules meant to copy, change, and combine.

+
+
+ 01 · AST

Canonical DAG

Share one graph output through references and serialize it as an S-expression.

+ 02 · JSO

Plain object graph

Describe a graph with ordinary JavaScript objects and compile to the canonical AST.

+ 03 · JSO

Fluent builder

Author the same public graph contract with a compact chainable API.

+ 04 · FXNode

Snapshot export

Turn an editor snapshot into the same AST consumed by every other frontend.

+ 05 · Pipelines

Default loadout

Attach the optional external pipeline addon without placing shaders in core.

+ 06 · WGSL

Custom render pipeline

Provide scene WGSL, entry points, and state as graph-owned data.

+ 07 · Compute

Compute pipeline

Declare a compute shader and dispatch dimensions inside the graph loadout.

+ 08 · Lifecycle

Compile and switch

Prepare and transactionally activate compiled graph resources.

+ 09 · glTF

Shared import worker

Parse a URL in a worker and write generic render data directly into shared SOA memory.

+ 10 · Handles

Mesh instances

Use conventional generation-safe mesh and instance objects over the core protocol.

+ 11 · SOA

Custom column

Request an aligned instance-sized array, then mutate it without messages.

+ 12 · SAB

Direct animation

Update guarded transforms at frame rate through shared memory.

+ 13 · Picking

BVH picking

Ray-pick shared scene data using the optional mesh-handles worker.

+ 14 · Workers

Worker to worker

Run the public core API from any browser worker through a MessagePort.

+ 15 · Complete

Complete scene

Combine graph, pipeline, glTF import, and mesh addons end to end.

+ 16 · Camera

Camera render data

Orbit, pan, and zoom by directly writing one SIMD-width SAB row.

+ 17 · Handles

Camera and materials

Use familiar camera and material properties while retaining direct SAB mutations.

+
+
+
+
Yawn examplesnpm run examples
+
+ + diff --git a/examples/render-graph-studio/demo-loadouts.js b/examples/render-graph-studio/demo-loadouts.js index 2c0b30b..bd8a3e4 100644 --- a/examples/render-graph-studio/demo-loadouts.js +++ b/examples/render-graph-studio/demo-loadouts.js @@ -21,7 +21,7 @@ export function encodeGeometryGlb({positions,normals,texcoords,indices}) { const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3); if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry"); const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]}); - const json={asset:{version:"2.0",generator:"yawn-phase8"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[ + const json={asset:{version:"2.0",generator:"yawn-demo"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[ {bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max}, {bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"}, {bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"}, @@ -57,7 +57,7 @@ const galleryPngBase64=Object.freeze({ }); const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})})); const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]})); - const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials, + const json={asset:{version:"2.0",generator:"yawn-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials, samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})), buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]}; let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out); diff --git a/examples/render-graph-studio/index.html b/examples/render-graph-studio/index.html index e6d5197..00b354d 100644 --- a/examples/render-graph-studio/index.html +++ b/examples/render-graph-studio/index.html @@ -71,11 +71,6 @@ text-transform: uppercase; letter-spacing: 0.08em; } - #profile-menu { min-width: 210px; color: #9da9ba; } - #profile-menu summary { cursor: pointer; color: #eef2f8; } - #profile-menu table { width: 100%; font-size: 11px; } - #profile-menu th { text-align: left; font-weight: 500; } - #profile-menu td { text-align: right; font-variant-numeric: tabular-nums; } select, button { font: inherit; @@ -165,7 +160,7 @@ >Scene loadout