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 <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-19 09:41:41 +00:00
co-authored by heaust
parent 0e44917f9e
commit 6bbf8039e4
67 changed files with 3152 additions and 3669 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
services: services:
yawn-dev: yawn-examples:
command: npm run dev command: npm run examples
portal: portal:
title: Yawn title: Yawn examples
description: WebGPU level editor development server with hot reload. description: Example index and WebGPU render graph studio with hot reload.
+1 -1
View File
@@ -1,5 +1,5 @@
# Build, Lint, and Test Commands # 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`: Build optimized WASM and JS in `dist/` for development
- `npm run build-release`: Build optimized WASM and JS for production - `npm run build-release`: Build optimized WASM and JS for production
- `cargo check`: Validate Rust sources quickly before full builds - `cargo check`: Validate Rust sources quickly before full builds
Generated
-227
View File
@@ -17,15 +17,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "approx"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.6" version = "0.7.6"
@@ -47,12 +38,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@@ -115,12 +100,6 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "byteorder-lite" name = "byteorder-lite"
version = "0.1.0" version = "0.1.0"
@@ -139,16 +118,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" 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]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.12.0" version = "0.12.0"
@@ -170,16 +139,6 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -289,95 +248,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" 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]] [[package]]
name = "gl_generator" name = "gl_generator"
version = "0.14.0" version = "0.14.0"
@@ -401,45 +271,6 @@ dependencies = [
"web-sys", "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]] [[package]]
name = "glutin_wgl_sys" name = "glutin_wgl_sys"
version = "0.6.1" version = "0.6.1"
@@ -550,12 +381,6 @@ dependencies = [
"hashbrown", "hashbrown",
] ]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.15" version = "1.0.15"
@@ -595,29 +420,6 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" 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]] [[package]]
name = "libc" name = "libc"
version = "0.2.174" version = "0.2.174"
@@ -800,18 +602,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" 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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@@ -908,15 +698,10 @@ name = "renderer"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"cgmath",
"console_error_panic_hook", "console_error_panic_hook",
"console_log",
"futures",
"gltf",
"image", "image",
"js-sys", "js-sys",
"log", "log",
"raw-window-handle",
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.15", "thiserror 2.0.15",
@@ -999,12 +784,6 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]] [[package]]
name = "slotmap" name = "slotmap"
version = "1.0.7" version = "1.0.7"
@@ -1116,12 +895,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
+6 -28
View File
@@ -1,47 +1,25 @@
[workspace] [workspace]
members = ["renderer", "level-editor"] members = ["renderer"]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] } wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] }
wasm-bindgen-futures = "0.4.50" wasm-bindgen-futures = "0.4.50"
console_error_panic_hook = "0.1.7" console_error_panic_hook = "0.1.7"
console_log = "1.0.0"
log = "0.4.27" log = "0.4.27"
wasm-logger = "0.2.0" wasm-logger = "0.2.0"
web-sys = { version = "0.3.77", features = [ web-sys = { version = "0.3.77", features = [
"Window",
"Document",
"Element",
"HtmlCanvasElement",
"HtmlInputElement",
"File",
"FileList",
"OffscreenCanvas", "OffscreenCanvas",
"MouseEvent",
"PointerEvent",
"WheelEvent",
"Worker",
"DedicatedWorkerGlobalScope", "DedicatedWorkerGlobalScope",
"Event", "MessageEvent"
"MessageEvent",
"Blob",
"BlobPropertyBag",
"Url",
"Request",
"RequestInit",
"RequestMode",
"Response",
"Headers",
"AddEventListenerOptions"
]} ]}
js-sys = "0.3.77" js-sys = "0.3.77"
bytemuck = { version = "1.23.1", features = ["derive"] } bytemuck = { version = "1.23.1", features = ["derive"] }
cgmath = "0.18"
raw-window-handle = "0.6.2"
wgpu = "26.0.1" wgpu = "26.0.1"
thiserror = "2.0.15" thiserror = "2.0.15"
ultraviolet = "0.10.0" 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"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
[profile.release]
opt-level = "z"
lto = true
+30 -11
View File
@@ -16,7 +16,7 @@ JavaScript objects ──┘ │
Any browser thread ── infrequent commands ──────────────────> worker Any browser thread ── infrequent commands ──────────────────> worker
Any browser thread ── atomic SOA writes ────────────────────> shared WASM memory 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 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 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 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. 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
- `packages/yawn-core` (`@yawn/core`) — the worker command transport, serialized - `packages/yawn-core` (`@yawn/core`) — render-data shared arrays and render-graph
graph lifecycle, and shared SOA views; it returns `[slot, generation]` handles. lifecycle transport; it returns `[slot, generation]` render-data handles.
- `addons/render-graph-ast` — canonical immutable DAG AST and S-expression serializer. - `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-js` — plain-object/fluent graph APIs that serialize and load ASTs.
- `addons/render-graph-fxnode` — FXNode snapshot exporter and diagnostic mapping. - `addons/render-graph-fxnode` — FXNode snapshot exporter and diagnostic mapping.
- `addons/default-pipelines` — optional scene/frame shader and compute declarations. - `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/gltf-import`glTF worker that writes format-neutral render-data packets directly to a fixed SOA.
- `addons/mesh-handles` — conventional `Mesh`/`Instance` objects and optional BVH picking. - `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 The integration example in `examples/render-graph-studio` consumes every package
through its public API; no example source or shader lives in core. 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 `@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 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 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: Allocate application columns infrequently through the worker:
@@ -95,16 +98,32 @@ const velocity = await core.allocateArray({
velocity.write(instanceSlot, [1, 0, 0, 0]); 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: Import a GLB without transferring its bytes through renderer messages:
```js ```js
import { GltfImporter } from "@yawn/gltf-import"; 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 importer = new GltfImporter(core);
const handles = new MeshHandles(core); const imported = await importer.load(gltfUrl);
const meshes = handles.fromImportedScene(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 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 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 `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 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 Cross-origin isolation is required (`COOP: same-origin`, `COEP: require-corp`). The
Vite development and preview servers already set both headers. Vite development and preview servers already set both headers.
@@ -123,7 +142,7 @@ Vite development and preview servers already set both headers.
## Development ## Development
```sh ```sh
npm run dev npm run examples
npm run test:js npm run test:js
cargo check --workspace cargo check --workspace
``` ```
+3 -3
View File
@@ -1,5 +1,5 @@
export const gltfShader = /* wgsl */ ` export const gltfShader = /* wgsl */ `
struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> } struct UniformData { resolution: vec2<f32>, time: f32, _padding0: f32, camera_position: vec4<f32> }
struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> } struct MaterialData { base_color_factor: vec4<f32>, emissive_factor: vec4<f32>, surface_factors: vec4<f32>, alpha_optics: vec4<f32>, flags: vec4<u32>, uv_sets: vec4<u32>, debug_extras: vec4<u32> }
@group(0) @binding(0) var<uniform> uni: UniformData; @group(0) @binding(0) var<uniform> uni: UniformData;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>; @group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
@@ -76,8 +76,8 @@ export const noopComputeShader = /* wgsl */ `@compute @workgroup_size(1) fn main
export const defaultPipelines = Object.freeze({ export const defaultPipelines = Object.freeze({
render: Object.freeze([ render: Object.freeze([
Object.freeze({ name: "ground_plane", shader: groundShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }), 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", 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 }), 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" }), Object.freeze({ name: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }),
]), ]),
compute: Object.freeze([ compute: Object.freeze([
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@yawn/gltf-import", "name": "@yawn/gltf-import",
"version": "0.1.0", "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", "type": "module",
"exports": "./src/index.js" "exports": "./src/index.js"
} }
+392
View File
@@ -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));
}
+30 -5
View File
@@ -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 { export class GltfImporter {
#core; #core;
#worker; #worker;
@@ -16,7 +40,8 @@ export class GltfImporter {
#disposed = false; #disposed = false;
constructor(core, { workerFactory } = {}) { 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.#core = core;
this.#worker = workerFactory this.#worker = workerFactory
? workerFactory() ? workerFactory()
@@ -55,7 +80,7 @@ export class GltfImporter {
if (message.type === "allocate") { if (message.type === "allocate") {
const length = Math.ceil(message.byteLength / 16); const length = Math.ceil(message.byteLength / 16);
pending.array = await this.#core.allocateArray({ pending.array = await this.#core.allocateArray({
name: "upload.gltf", name: "upload.renderData",
domain: "fixed", domain: "fixed",
scalar: "u32", scalar: "u32",
lanes: 4, lanes: 4,
@@ -68,11 +93,11 @@ export class GltfImporter {
...pending.array.share(), ...pending.array.share(),
}); });
} else if (message.type === "ready") { } else if (message.type === "ready") {
const result = await this.#core.commitGlbUpload( const result = await this.#core.commitRenderDataUpload(
pending.array, pending.array,
message.byteLength, message.byteLength,
pending.options,
); );
frameCamera(this.#core, result.bounds, pending.options.framing);
this.#pending.delete(message.request); this.#pending.delete(message.request);
pending.resolve(result); pending.resolve(result);
} else if (message.type === "error") { } else if (message.type === "error") {
+8 -6
View File
@@ -1,4 +1,5 @@
import { writeSharedUpload } from "./shared-upload.js"; import { writeSharedUpload } from "./shared-upload.js";
import { gltfToRenderDataPacket } from "./gltf.js";
const downloads = new Map(); const downloads = new Map();
@@ -10,16 +11,17 @@ addEventListener("message", async ({ data: message }) => {
if (!response.ok) throw new Error(`HTTP_${response.status}`); if (!response.ok) throw new Error(`HTTP_${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer()); const bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.byteLength) throw new Error("GLTF_EMPTY"); if (!bytes.byteLength) throw new Error("GLTF_EMPTY");
downloads.set(request, bytes); const packet = await gltfToRenderDataPacket(bytes, message.url);
postMessage({ type: "allocate", request, byteLength: bytes.byteLength }); downloads.set(request, packet);
postMessage({ type: "allocate", request, byteLength: packet.byteLength });
return; return;
} }
if (message?.type === "storage") { if (message?.type === "storage") {
const bytes = downloads.get(request); const packet = downloads.get(request);
if (!bytes) throw new Error("GLTF_REQUEST_UNKNOWN"); if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN");
writeSharedUpload(message.buffer, message.descriptor, bytes); writeSharedUpload(message.buffer, message.descriptor, packet);
downloads.delete(request); downloads.delete(request);
postMessage({ type: "ready", request, byteLength: bytes.byteLength }); postMessage({ type: "ready", request, byteLength: packet.byteLength });
} }
} catch (error) { } catch (error) {
downloads.delete(request); downloads.delete(request);
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@yawn/mesh-handles", "name": "@yawn/mesh-handles",
"version": "0.1.0", "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", "type": "module",
"exports": "./src/index.js", "exports": "./src/index.js",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,4 +1,4 @@
import { SnapshotReader } from "@yawn/core/snapshot"; import { SnapshotReader } from "./snapshot.js";
import { DerivedBvh } from "./bvh-core.js"; import { DerivedBvh } from "./bvh-core.js";
let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0; let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0;
+300 -5
View File
@@ -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. */ const TOKEN = Symbol("yawn mesh handle addon");
export const createPickingWorker = () => new Worker( 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), new URL("./bvh-worker.js", import.meta.url),
{ type: "module", name: "yawn-spatial-query" }, { 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. */ /** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */
export class MeshHandles { export class MeshHandles {
#core; #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.#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) { fromImportedScene(result) {
@@ -20,7 +31,7 @@ export class MeshHandles {
} }
async pickRay(origin, direction, options) { async pickRay(origin, direction, options) {
const result = await this.#core.pickRay(origin, direction, options); const result = await this.#pickRay(origin, direction, options);
return { return {
...result, ...result,
hits: result.hits.map((hit) => ({ 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 { export class Mesh {
@@ -65,3 +194,169 @@ export class Instance {
setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); } setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); }
async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; } 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); }
}
@@ -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({ export const SNAPSHOT = Object.freeze({
MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256, MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256,
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2, SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2,
+3 -1
View File
@@ -51,13 +51,15 @@ function normalizePipelines(raw = {}) {
vertexEntry: pipeline.vertexEntry ?? "vs_main", vertexEntry: pipeline.vertexEntry ?? "vs_main",
fragmentEntry: pipeline.fragmentEntry ?? "fs_main", fragmentEntry: pipeline.fragmentEntry ?? "fs_main",
doubleSided: pipeline.doubleSided ?? false, doubleSided: pipeline.doubleSided ?? false,
material: pipeline.material ?? false,
}; };
if ( if (
!identifier(result.name) || !identifier(result.name) ||
!identifier(result.vertexEntry) || !identifier(result.vertexEntry) ||
!identifier(result.fragmentEntry) || !identifier(result.fragmentEntry) ||
typeof result.shader !== "string" || typeof result.shader !== "string" ||
typeof result.doubleSided !== "boolean" typeof result.doubleSided !== "boolean" ||
typeof result.material !== "boolean"
) )
fail("AST_PIPELINE", "invalid render pipeline declaration"); fail("AST_PIPELINE", "invalid render pipeline declaration");
return result; return result;
+2 -1
View File
@@ -1,9 +1,10 @@
# Yawn examples # 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, - `render-graph-studio/` is the complete browser integration with FXNode, JSO,
external pipelines, shared glTF import, mesh handles, and picking. external pipelines, shared glTF import, mesh handles, and picking.
- `cookbook/` contains small copyable recipes, each focused on one public API. - `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 ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live
renderer is required. renderer is required.
@@ -11,9 +11,3 @@ export async function compileAndSwitch(core, graph) {
throw error; 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);
}
+2 -4
View File
@@ -1,8 +1,6 @@
import { MeshHandles } from "@yawn/mesh-handles";
/** Query the optional snapshot/BVH worker and receive wrapped instance handles. */ /** Query the optional snapshot/BVH worker and receive wrapped instance handles. */
export function pickNearest(core, origin, direction) { export function pickNearest(meshHandles, origin, direction) {
return new MeshHandles(core).pickRay(origin, direction, { return meshHandles.pickRay(origin, direction, {
maxDistance: 10_000, maxDistance: 10_000,
maxHits: 1, maxHits: 1,
}); });
-1
View File
@@ -6,7 +6,6 @@ export async function connectFromWorker(port, options = {}) {
worker: port, worker: port,
memory: options.memory, memory: options.memory,
ringPtr: options.ringPtr, ringPtr: options.ringPtr,
pickingWorkerFactory: options.pickingWorkerFactory,
free: options.free, free: options.free,
}); });
await core.ready; await core.ready;
+3 -12
View File
@@ -1,19 +1,10 @@
import { culling } from "../render-graph-studio/render-graph/presets.js"; import { culling } from "../render-graph-studio/render-graph/presets.js";
import { importGltf } from "./09-gltf-import-worker.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. */ /** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */
export async function loadCompleteScene(core, gltfUrl) { export async function loadCompleteScene(core, gltfUrl) {
const meshes = await importGltf(core, gltfUrl);
const compiled = await compileAndSwitch(core, culling); const compiled = await compileAndSwitch(core, culling);
try { return { compiled, meshes };
const meshes = await importGltf(core, gltfUrl);
return {
compiled,
meshes,
dispose: () => dropActiveGraph(core, compiled),
};
} catch (error) {
await dropActiveGraph(core, compiled).catch(() => {});
throw error;
}
} }
+122
View File
@@ -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();
}
@@ -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;
}
}
+3 -1
View File
@@ -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 | | `05-default-pipelines.js` | Attaching optional external WGSL declarations |
| `06-custom-render-pipeline.js` | Supplying a custom scene render shader | | `06-custom-render-pipeline.js` | Supplying a custom scene render shader |
| `07-compute-pipeline.js` | Supplying a binding-free compute pass | | `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 | | `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 | | `10-mesh-instances.js` | Creating and mutating conventional instance handles |
| `11-custom-soa-column.js` | Allocating an instance-sized shared SOA column | | `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 | | `13-bvh-picking.js` | Ray picking through the mesh-handles addon |
| `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` | | `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` |
| `15-complete-scene.js` | Combining the graph, glTF, and mesh addons | | `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 17 isolate graph authoring concepts, so their ASTs are intentionally Recipes 17 isolate graph authoring concepts, so their ASTs are intentionally
fragments rather than complete renderable loadouts. Recipe 15 uses the complete fragments rather than complete renderable loadouts. Recipe 15 uses the complete
+2
View File
@@ -13,3 +13,5 @@ export * from "./12-direct-sab-animation.js";
export * from "./13-bvh-picking.js"; export * from "./13-bvh-picking.js";
export * from "./14-worker-to-worker.js"; export * from "./14-worker-to-worker.js";
export * from "./15-complete-scene.js"; export * from "./15-complete-scene.js";
export * from "./16-camera-render-data.js";
export * from "./17-conventional-handles.js";
+201
View File
@@ -0,0 +1,201 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Runnable integration and focused addon recipes for the Yawn render core."
/>
<title>Yawn Examples</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #0a0c0d;
color: #f3f1e8;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
body::before {
position: fixed;
inset: 0;
z-index: -1;
content: "";
background:
radial-gradient(circle at 75% 8%, #bb5b2b26, transparent 28rem),
linear-gradient(#ffffff08 1px, transparent 1px),
linear-gradient(90deg, #ffffff08 1px, transparent 1px),
#0a0c0d;
background-size: auto, 64px 64px, 64px 64px, auto;
}
a { color: inherit; }
.shell { width: min(1180px, calc(100% - 40px)); margin: auto; }
header {
display: flex;
align-items: center;
justify-content: space-between;
height: 82px;
border-bottom: 1px solid #ffffff1a;
}
.wordmark { font-size: 18px; font-weight: 850; letter-spacing: .18em; }
.wordmark span { color: #da7543; }
header nav { display: flex; gap: 22px; color: #a9ada8; font-size: 13px; }
header nav a { text-decoration: none; }
header nav a:hover { color: #fff; }
.hero { padding: 88px 0 58px; max-width: 820px; }
.eyebrow,
.recipe-number {
color: #dc7543;
font: 700 11px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: .14em;
text-transform: uppercase;
}
h1 {
margin: 16px 0 20px;
max-width: 780px;
font-size: clamp(48px, 8vw, 92px);
font-weight: 760;
letter-spacing: -.055em;
line-height: .94;
}
.hero p { max-width: 660px; color: #b0b3ae; font-size: 18px; line-height: 1.65; }
.primary {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(270px, .6fr);
min-height: 340px;
overflow: hidden;
border: 1px solid #ffffff20;
border-radius: 18px;
background: #111416e8;
text-decoration: none;
transition: border-color 160ms, transform 160ms;
}
.primary:hover { transform: translateY(-3px); border-color: #dc754388; }
.primary-copy { padding: clamp(30px, 5vw, 64px); }
.primary h2 { margin: 15px 0 12px; font-size: clamp(30px, 4vw, 52px); letter-spacing: -.035em; }
.primary p { max-width: 580px; color: #aaafaa; font-size: 16px; line-height: 1.65; }
.launch { display: inline-flex; align-items: center; gap: 10px; margin-top: 30px; font-weight: 720; }
.launch span { color: #dc7543; font-size: 22px; transition: transform 160ms; }
.primary:hover .launch span { transform: translateX(5px); }
.primary-art {
display: grid;
place-items: center;
min-height: 260px;
border-left: 1px solid #ffffff16;
background: linear-gradient(145deg, #d57442, #6d2e1d);
}
.graph {
position: relative;
width: 190px;
height: 165px;
filter: drop-shadow(0 18px 25px #3d130b88);
}
.node { position: absolute; width: 78px; height: 48px; border: 1px solid #ffffff77; border-radius: 7px; background: #17191bd9; }
.node::after { position: absolute; inset: 10px 13px; content: ""; border-top: 3px solid #fff; border-bottom: 3px solid #ffffff55; }
.node:nth-child(1) { left: 0; top: 8px; }
.node:nth-child(2) { right: 0; top: 58px; }
.node:nth-child(3) { left: 20px; bottom: 0; }
.edge { position: absolute; height: 1px; transform-origin: left; background: #fff9; }
.edge-a { left: 70px; top: 48px; width: 77px; transform: rotate(19deg); }
.edge-b { left: 92px; top: 135px; width: 70px; transform: rotate(-40deg); }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 94px 0 26px; }
.section-heading h2 { margin: 0; font-size: 34px; letter-spacing: -.03em; }
.section-heading p { margin: 0; color: #929792; }
.recipes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.recipe {
min-height: 180px;
padding: 25px;
border: 1px solid #ffffff17;
border-radius: 12px;
background: #101315d9;
text-decoration: none;
transition: background 140ms, border-color 140ms;
}
.recipe:hover { border-color: #dc75436b; background: #17191b; }
.recipe h3 { margin: 24px 0 8px; font-size: 17px; }
.recipe p { margin: 0; color: #989d98; font-size: 13px; line-height: 1.55; }
footer { display: flex; justify-content: space-between; gap: 20px; margin-top: 80px; padding: 34px 0 50px; border-top: 1px solid #ffffff1a; color: #858a85; font-size: 13px; }
code { color: #d5d0c4; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (max-width: 850px) {
.primary { grid-template-columns: 1fr; }
.primary-art { min-height: 220px; border-top: 1px solid #ffffff16; border-left: 0; }
.recipes { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
.shell { width: min(100% - 24px, 1180px); }
header nav a:first-child { display: none; }
.hero { padding-top: 60px; }
.recipes { grid-template-columns: 1fr; }
.section-heading, footer { align-items: start; flex-direction: column; }
}
</style>
</head>
<body>
<div class="shell">
<header>
<div class="wordmark">YAWN<span>.</span></div>
<nav aria-label="Examples navigation">
<a href="#recipes">Recipes</a>
<a href="https://github.com/heaust-ops/yawn">Repository ↗</a>
</nav>
</header>
<main>
<section class="hero">
<div class="eyebrow">Worker-native rendering toolkit</div>
<h1>Build the graph.<br />Share the data.</h1>
<p>
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.
</p>
</section>
<a class="primary" href="./render-graph-studio/">
<div class="primary-copy">
<div class="eyebrow">Interactive example · all packages</div>
<h2>Render Graph Studio</h2>
<p>
Edit an FXNode graph, swap JSO loadouts, stream procedural glTF through
shared memory, pick instances, and orbit the shared-SOA camera.
</p>
<div class="launch">Launch studio <span></span></div>
</div>
<div class="primary-art" aria-hidden="true">
<div class="graph">
<i class="node"></i><i class="node"></i><i class="node"></i>
<i class="edge edge-a"></i><i class="edge edge-b"></i>
</div>
</div>
</a>
<section id="recipes" aria-labelledby="recipes-title">
<div class="section-heading">
<h2 id="recipes-title">Focused recipes</h2>
<p>Small ES modules meant to copy, change, and combine.</p>
</div>
<div class="recipes">
<a class="recipe" href="./cookbook/01-canonical-ast.js"><span class="recipe-number">01 · AST</span><h3>Canonical DAG</h3><p>Share one graph output through references and serialize it as an S-expression.</p></a>
<a class="recipe" href="./cookbook/02-jso-graph.js"><span class="recipe-number">02 · JSO</span><h3>Plain object graph</h3><p>Describe a graph with ordinary JavaScript objects and compile to the canonical AST.</p></a>
<a class="recipe" href="./cookbook/03-fluent-builder.js"><span class="recipe-number">03 · JSO</span><h3>Fluent builder</h3><p>Author the same public graph contract with a compact chainable API.</p></a>
<a class="recipe" href="./cookbook/04-fxnode-export.js"><span class="recipe-number">04 · FXNode</span><h3>Snapshot export</h3><p>Turn an editor snapshot into the same AST consumed by every other frontend.</p></a>
<a class="recipe" href="./cookbook/05-default-pipelines.js"><span class="recipe-number">05 · Pipelines</span><h3>Default loadout</h3><p>Attach the optional external pipeline addon without placing shaders in core.</p></a>
<a class="recipe" href="./cookbook/06-custom-render-pipeline.js"><span class="recipe-number">06 · WGSL</span><h3>Custom render pipeline</h3><p>Provide scene WGSL, entry points, and state as graph-owned data.</p></a>
<a class="recipe" href="./cookbook/07-compute-pipeline.js"><span class="recipe-number">07 · Compute</span><h3>Compute pipeline</h3><p>Declare a compute shader and dispatch dimensions inside the graph loadout.</p></a>
<a class="recipe" href="./cookbook/08-compile-and-switch.js"><span class="recipe-number">08 · Lifecycle</span><h3>Compile and switch</h3><p>Prepare and transactionally activate compiled graph resources.</p></a>
<a class="recipe" href="./cookbook/09-gltf-import-worker.js"><span class="recipe-number">09 · glTF</span><h3>Shared import worker</h3><p>Parse a URL in a worker and write generic render data directly into shared SOA memory.</p></a>
<a class="recipe" href="./cookbook/10-mesh-instances.js"><span class="recipe-number">10 · Handles</span><h3>Mesh instances</h3><p>Use conventional generation-safe mesh and instance objects over the core protocol.</p></a>
<a class="recipe" href="./cookbook/11-custom-soa-column.js"><span class="recipe-number">11 · SOA</span><h3>Custom column</h3><p>Request an aligned instance-sized array, then mutate it without messages.</p></a>
<a class="recipe" href="./cookbook/12-direct-sab-animation.js"><span class="recipe-number">12 · SAB</span><h3>Direct animation</h3><p>Update guarded transforms at frame rate through shared memory.</p></a>
<a class="recipe" href="./cookbook/13-bvh-picking.js"><span class="recipe-number">13 · Picking</span><h3>BVH picking</h3><p>Ray-pick shared scene data using the optional mesh-handles worker.</p></a>
<a class="recipe" href="./cookbook/14-worker-to-worker.js"><span class="recipe-number">14 · Workers</span><h3>Worker to worker</h3><p>Run the public core API from any browser worker through a MessagePort.</p></a>
<a class="recipe" href="./cookbook/15-complete-scene.js"><span class="recipe-number">15 · Complete</span><h3>Complete scene</h3><p>Combine graph, pipeline, glTF import, and mesh addons end to end.</p></a>
<a class="recipe" href="./cookbook/16-camera-render-data.js"><span class="recipe-number">16 · Camera</span><h3>Camera render data</h3><p>Orbit, pan, and zoom by directly writing one SIMD-width SAB row.</p></a>
<a class="recipe" href="./cookbook/17-conventional-handles.js"><span class="recipe-number">17 · Handles</span><h3>Camera and materials</h3><p>Use familiar camera and material properties while retaining direct SAB mutations.</p></a>
</div>
</section>
</main>
<footer><span>Yawn examples</span><code>npm run examples</code></footer>
</div>
</body>
</html>
@@ -21,7 +21,7 @@ export function encodeGeometryGlb({positions,normals,texcoords,indices}) {
const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3); const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3);
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry"); 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 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:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},
{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"}, {bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},
{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"}, {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<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;}; const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
/** Build the deterministic Phase 6 PBR shader validation gallery. */ /** Build a deterministic PBR shader validation gallery. */
export function createMaterialGalleryGlb(){ export function createMaterialGalleryGlb(){
// A modest shared sphere keeps the embedded GLB compact while making roughness // A modest shared sphere keeps the embedded GLB compact while making roughness
// and normal-map responses much easier to compare than the former cubes. // and normal-map responses much easier to compare than the former cubes.
@@ -78,7 +78,7 @@ export function createMaterialGalleryGlb(){
]; ];
const nodes=materials.map((material,index)=>({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 nodes=materials.map((material,index)=>({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 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})), 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"}]}; 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); 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);
+2 -7
View File
@@ -71,11 +71,6 @@
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; 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, select,
button { button {
font: inherit; font: inherit;
@@ -165,7 +160,7 @@
>Scene loadout<select id="loadout-select"> >Scene loadout<select id="loadout-select">
<option value="cubes">Cubes</option> <option value="cubes">Cubes</option>
<option value="spheres">UV spheres</option> <option value="spheres">UV spheres</option>
<option value="materials">Phase 6 PBR gallery</option> <option value="materials">PBR material gallery</option>
</select></label </select></label
><label class="field" for="graph-select" ><label class="field" for="graph-select"
>Graph preset<select id="graph-select"> >Graph preset<select id="graph-select">
@@ -175,7 +170,7 @@
> >
</div> </div>
<canvas id="canvas0"></canvas <canvas id="canvas0"></canvas
><output id="demo-status" aria-live="polite">Starting Phase 8</output> ><output id="demo-status" aria-live="polite">Starting renderer</output>
</section> </section>
<section class="editor" aria-label="Render graph editor"> <section class="editor" aria-label="Render graph editor">
<div class="editor-bar"> <div class="editor-bar">
+16 -113
View File
@@ -1,5 +1,6 @@
import { YawnCore, RendererError } from "@yawn/core"; import { YawnCore, RendererError } from "@yawn/core";
import { MeshHandles, createPickingWorker } from "@yawn/mesh-handles"; import { MeshHandles } from "@yawn/mesh-handles";
import { installCameraRenderDataControls } from "../cookbook/16-camera-render-data.js";
import { loadDemoLoadout } from "./demo-loadouts.js"; import { loadDemoLoadout } from "./demo-loadouts.js";
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode"; import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
import { createGraphAst } from "@yawn/render-graph-ast"; import { createGraphAst } from "@yawn/render-graph-ast";
@@ -37,11 +38,7 @@ const state = {
compiled: {}, compiled: {},
telemetry: null, telemetry: null,
}; };
export const profileRequested = (search) => function createWorkerTransport() {
new URLSearchParams(search).get("profile") === "1";
const profileEnabled = profileRequested(location.search);
function createWorkerTransport(profile) {
const canvas = document.querySelector("#canvas0"); const canvas = document.querySelector("#canvas0");
const dpr = devicePixelRatio; const dpr = devicePixelRatio;
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr); canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
@@ -62,16 +59,6 @@ function createWorkerTransport(profile) {
kind, kind,
values: new Float64Array(values), values: new Float64Array(values),
}); });
const mouseValues = (event) => [
devicePixelRatio,
event.buttons,
event.movementX,
event.movementY,
event.offsetX,
event.offsetY,
Math.max(1, canvas.clientHeight),
];
addEventListener( addEventListener(
"resize", "resize",
() => () =>
@@ -82,106 +69,19 @@ function createWorkerTransport(profile) {
]), ]),
options, options,
); );
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
) {
event.preventDefault();
post(1, mouseValues(event));
}
},
options,
);
for (const type of ["pointerup", "pointercancel"]) {
canvas.addEventListener(
type,
(event) => {
if (canvas.hasPointerCapture(event.pointerId)) {
canvas.releasePointerCapture(event.pointerId);
}
},
options,
);
}
canvas.addEventListener(
"click",
(event) => {
if (event.button === 0) post(2, mouseValues(event));
},
options,
);
canvas.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const delta =
event.deltaMode === 0
? event.deltaY
: event.deltaMode === 1
? event.deltaY * 16
: event.deltaMode === 2
? event.deltaY * Math.max(1, canvas.clientHeight)
: null;
if (Number.isFinite(delta)) post(3, [delta]);
},
{ ...options, passive: false },
);
canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options);
const offscreen = canvas.transferControlToOffscreen(); const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "init", canvas: offscreen, profile }, [offscreen]); worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
return { return {
worker, worker,
pickingWorkerFactory: createPickingWorker,
free() { free() {
abort.abort(); abort.abort();
}, },
}; };
} }
function installProfileMenu() {
if (!profileEnabled) return;
const menu = document.createElement("details");
menu.id = "profile-menu";
menu.innerHTML =
'<summary>GPU profile</summary><div id="profile-status">Waiting for GPU timestamps…</div><table><tbody id="profile-passes"></tbody></table>';
document.querySelector(".toolbar")?.append(menu);
on(renderer, "renderer-profile", (event) => {
const p = event.detail;
document.querySelector("#profile-status").textContent = p.available
? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped`
: "GPU timestamps unavailable";
document.querySelector("#profile-passes").replaceChildren(
...Object.entries(p.passes || {}).map(([id, ms]) => {
const row = document.createElement("tr"),
name = document.createElement("th"),
value = document.createElement("td");
name.textContent = id;
value.textContent = `${Number(ms).toFixed(3)} ms`;
row.append(name, value);
return row;
}),
);
});
}
function publish(telemetry) { function publish(telemetry) {
state.telemetry = telemetry; state.telemetry = telemetry;
document.documentElement.dataset.phase8State = JSON.stringify({ document.documentElement.dataset.yawnState = JSON.stringify({
activeLoadout: state.loadout, activeLoadout: state.loadout,
activeGraph: state.graph, activeGraph: state.graph,
renderDataRevision: telemetry.revision, renderDataRevision: telemetry.revision,
@@ -195,7 +95,6 @@ function publish(telemetry) {
draws: telemetry.draws, draws: telemetry.draws,
instances: telemetry.instances, instances: telemetry.instances,
indices: telemetry.indices, indices: telemetry.indices,
framingRadius: telemetry.framingRadius,
gpuError: telemetry.gpuError, gpuError: telemetry.gpuError,
}); });
} }
@@ -252,9 +151,9 @@ async function transaction(label, operation, rollback) {
try { try {
await rollback?.(); await rollback?.();
} catch (rollbackError) { } catch (rollbackError) {
console.error("Phase 8 rollback failed", rollbackError); console.error("Render graph rollback failed", rollbackError);
} }
console.error("Phase 8 transaction failed", error); console.error("Render graph transaction failed", error);
status(`Failed · ${error?.code ?? error?.message ?? error}`); status(`Failed · ${error?.code ?? error?.message ?? error}`);
} }
return false; return false;
@@ -328,6 +227,7 @@ async function cleanup() {
await editor?.destroy(); await editor?.destroy();
} finally { } finally {
gltfImporter?.dispose(); gltfImporter?.dispose();
meshHandles?.dispose();
renderer?.dispose(); renderer?.dispose();
} }
} }
@@ -337,12 +237,15 @@ const pagehide = () => {
async function start() { async function start() {
addEventListener("pagehide", pagehide, { once: true }); addEventListener("pagehide", pagehide, { once: true });
delete document.documentElement.dataset.phase8Ready; delete document.documentElement.dataset.yawnReady;
renderer = new YawnCore(createWorkerTransport(profileEnabled)); const transport = createWorkerTransport();
renderer = new YawnCore(transport);
meshHandles = new MeshHandles(renderer); meshHandles = new MeshHandles(renderer);
gltfImporter = new GltfImporter(renderer); gltfImporter = new GltfImporter(renderer);
installProfileMenu();
await renderer.ready; await renderer.ready;
listeners.push(
installCameraRenderDataControls(renderer, document.querySelector("#canvas0")),
);
const nextEditor = await createRenderGraphEditor( const nextEditor = await createRenderGraphEditor(
document.querySelector("#graph-editor"), document.querySelector("#graph-editor"),
); );
@@ -465,11 +368,11 @@ async function start() {
}, },
); );
if (!initialized) throw new Error("Initial demo transaction failed"); if (!initialized) throw new Error("Initial demo transaction failed");
document.documentElement.dataset.phase8Ready = "true"; document.documentElement.dataset.yawnReady = "true";
} }
const startupError = (error) => { const startupError = (error) => {
if (cleaned) return; if (cleaned) return;
console.error("Phase 8 startup failed", error); console.error("Render graph startup failed", error);
status(`Startup failed · ${error?.code ?? error}`); status(`Startup failed · ${error?.code ?? error}`);
void cleanup(); void cleanup();
}; };
-46
View File
@@ -1,46 +0,0 @@
cargo-features = ["profile-rustflags"]
[package]
name = "level-editor"
description = "Level editor application using the renderer"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[features]
default = []
atomics = []
bulk-memory = []
[profile.release]
opt-level = "z"
lto = true
[target.wasm32-unknown-unknown]
rustflags = [
"-Clink-args=--shared-memory",
"-Clink-args=--max-memory=1073741824",
"-Clink-args=--import-memory",
"-Clink-args=--export=__wasm_init_tls",
"-Clink-args=--export=__tls_size",
"-Clink-args=--export=__tls_align",
"-Clink-args=--export=__tls_base",
]
[dependencies]
renderer = { path = "../renderer" }
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
console_error_panic_hook = { workspace = true }
log = { workspace = true }
wasm-logger = { workspace = true }
web-sys = { workspace = true }
js-sys = { workspace = true }
ultraviolet = { workspace = true }
wgpu = { workspace = true }
bytemuck = { workspace = true }
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
-104
View File
@@ -1,104 +0,0 @@
#![cfg(target_arch = "wasm32")]
use wasm_bindgen::prelude::*;
use renderer::camera::Camera;
use renderer::render_data::RenderData;
use renderer::renderer as gpu_renderer;
use renderer::renderer::scene::FrameMetadata;
struct EditorScene {
uniform_buffers: [wgpu::Buffer; 2],
bind_groups: [wgpu::BindGroup; 2],
frame_metadata: FrameMetadata,
cam: Camera,
}
impl renderer::renderer::scene::Scene for EditorScene {
fn setup(
renderer_context: &gpu_renderer::RendererContext,
resources: &mut gpu_renderer::PipelineLibrary,
_render_data: &mut RenderData,
) -> Self {
let dimension = ultraviolet::Vec2::new(
renderer_context.surface_config.width as f32,
renderer_context.surface_config.height as f32,
);
let mut frame_metadata = FrameMetadata::new(dimension);
let camera = Camera::new(dimension.x / dimension.y);
frame_metadata.set_camera_position(camera.position());
let uniform_resource = frame_metadata.create_uniform_resource(&renderer_context.device);
let camera_resource = camera.create_uniform_resource(&renderer_context.device);
let bind_group_layouts = [
uniform_resource.bind_group_layout,
camera_resource.bind_group_layout,
];
resources.set_bind_group_layouts(&bind_group_layouts);
EditorScene {
uniform_buffers: [uniform_resource.buffer, camera_resource.buffer],
bind_groups: [uniform_resource.bind_group, camera_resource.bind_group],
frame_metadata,
cam: camera,
}
}
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
Some(&mut self.frame_metadata)
}
fn camera_mut(&mut self) -> Option<&mut Camera> {
Some(&mut self.cam)
}
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
Some([&self.uniform_buffers[0], &self.uniform_buffers[1]])
}
fn bind_groups(&self) -> &[wgpu::BindGroup] {
&self.bind_groups
}
fn handle_mouse_click(&mut self, x: f32, y: f32) {
self.frame_metadata.mouse_click = [x, y];
}
fn handle_zoom(&mut self, delta_y: f32) {
self.cam.zoom(delta_y);
}
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32) {
self.cam.orbit(delta_x, delta_y);
}
fn handle_pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
self.cam.pan(delta_x, delta_y, viewport_height);
}
fn set_camera_depth_range(&mut self, near: f32, far: f32) {
self.cam.set_depth_range(near, far);
}
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3) {
self.cam.look_at(eye, center);
}
}
/// Start the level editor inside its owning render worker.
#[wasm_bindgen]
pub fn worker_main(profile: bool) -> u32 {
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
wasm_logger::init(wasm_logger::Config::default());
renderer::app_setup::worker_entrypoint::<EditorScene>(profile)
}
/// Return this worker's shared WebAssembly memory to messaging clients.
#[wasm_bindgen]
pub fn worker_memory() -> JsValue {
wasm_bindgen::memory()
}
+5 -9
View File
@@ -9,21 +9,17 @@
"addons/*" "addons/*"
], ],
"scripts": { "scripts": {
"dev": "run-s rsw:build dev:watch", "examples": "run-s rsw:build examples:watch",
"dev:watch": "run-p rsw:watch dev:vite", "examples:watch": "run-p rsw:watch examples:vite",
"dev:vite": "vite dev", "examples:vite": "vite dev",
"rsw:watch": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw watch", "rsw:watch": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw watch",
"rsw:build": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw build", "rsw:build": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw build",
"wasm-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web level-editor -- --features atomics,bulk-memory -Z build-std=panic_abort,std", "wasm-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
"wasm-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web level-editor -- --features atomics,bulk-memory -Z build-std=panic_abort,std", "wasm-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
"wasm-renderer-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
"wasm-renderer-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
"bundle-dev": "vite build --mode development", "bundle-dev": "vite build --mode development",
"bundle-release": "vite build", "bundle-release": "vite build",
"build": "run-s clean wasm-dev bundle-dev", "build": "run-s clean wasm-dev bundle-dev",
"build-release": "run-s clean wasm-release bundle-release", "build-release": "run-s clean wasm-release bundle-release",
"build-renderer": "run-s clean wasm-renderer-dev bundle-dev",
"build-renderer-release": "run-s clean wasm-renderer-release bundle-release",
"test:js": "node --test tests/*.test.js", "test:js": "node --test tests/*.test.js",
"start": "vite preview", "start": "vite preview",
"clean": "rimraf --glob dist **/pkg", "clean": "rimraf --glob dist **/pkg",
+1 -4
View File
@@ -2,8 +2,5 @@
"name": "@yawn/core", "name": "@yawn/core",
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
"exports": { "exports": "./src/index.js"
".": "./src/index.js",
"./snapshot": "./src/snapshot.js"
}
} }
+17 -51
View File
@@ -1,7 +1,5 @@
import { SnapshotReader } from "./snapshot.js";
const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2; const HEADER_WORDS = 16, SLOT_WORDS = 40, CAPACITY = 1024, SLOT_VERSION = 2;
const OP = { IMPORT_GLB: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 }; const OP = { INSTALL_RENDER_DATA: 1, CREATE_INSTANCE: 3, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, ALLOCATE_SOA: 11 };
export class RendererError extends Error { export class RendererError extends Error {
constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; } constructor(code, details) { super(details?.message ?? code); this.name = "RendererError"; this.code = code; this.details = details; }
@@ -12,9 +10,8 @@ export class YawnCore extends EventTarget {
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false; #pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
#readyResolve; #readyReject; #arrays = new Map(); #readyResolve; #readyReject; #arrays = new Map();
#transportReady = false; #transportReady = false;
#telemetry; #profile; #stopped = false; #telemetry; #stopped = false; #renderDataSnapshot;
#graphQueue = []; #graphBusy = false; #graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
constructor(bridge) { constructor(bridge) {
super(); super();
@@ -31,20 +28,11 @@ export class YawnCore extends EventTarget {
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR")); this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR")); this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
this.#worker.start?.(); this.#worker.start?.();
try {
const factory = bridge.pickingWorkerFactory;
if (factory) {
this.#bvh = factory();
this.#bvh.addEventListener("message", e => this.#bvhMessage(e.data));
this.#bvh.addEventListener("error", () => this.#disablePicking("PICKING_FAILED"));
this.#bvh.addEventListener("messageerror", () => this.#disablePicking("PICKING_FAILED"));
} else this.#picking = false;
} catch { this.#picking = false; }
} }
get ready() { return this.#ready; } get ready() { return this.#ready; }
get telemetry() { return this.#telemetry; } get telemetry() { return this.#telemetry; }
get profile() { return this.#profile; } get renderDataSnapshot() { return this.#renderDataSnapshot; }
array(name) { array(name) {
const array = this.#arrays.get(name); const array = this.#arrays.get(name);
@@ -99,9 +87,6 @@ export class YawnCore extends EventTarget {
} else if (message?.type === "telemetry") { } else if (message?.type === "telemetry") {
this.#telemetry = message; this.#telemetry = message;
this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message })); this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
} else if (message?.type === "profile-snapshot") {
this.#profile = message;
this.dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
} else if (message?.type === "fatal") { } else if (message?.type === "fatal") {
console.error("renderer worker fatal", JSON.stringify(message)); console.error("renderer worker fatal", JSON.stringify(message));
this.#fail(message.code || "WORKER_FATAL"); this.#fail(message.code || "WORKER_FATAL");
@@ -117,11 +102,18 @@ export class YawnCore extends EventTarget {
} else if (message?.type === "snapshot-init") { } else if (message?.type === "snapshot-init") {
try { try {
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version"); if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
this.#snapshotReader = new SnapshotReader(this.#bridge.memory, message.controlPtr); this.#renderDataSnapshot = Object.freeze({
this.#bvh?.postMessage({type:"init",memory:this.#bridge.memory,controlPtr:message.controlPtr,controlVersion:1,schemaVersion:2}); memory: this.#bridge.memory,
} catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); } controlPtr: message.controlPtr,
controlVersion: message.controlVersion,
schemaVersion: message.schemaVersion,
});
this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot", { detail: this.#renderDataSnapshot }));
} catch { this.#fail("SNAPSHOT_PROTOCOL_MISMATCH"); }
} else if (message?.type === "snapshot-published") { } else if (message?.type === "snapshot-published") {
try { this.#snapshotEpoch=this.#snapshotReader?.latest().epoch||0; this.#bvh?.postMessage({type:"update",epoch:this.#snapshotEpoch}); } catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); } this.dispatchEvent(new CustomEvent("yawn-render-data-snapshot-published", {
detail: Object.freeze({ epoch: message.epoch >>> 0 }),
}));
} }
} }
@@ -132,30 +124,10 @@ export class YawnCore extends EventTarget {
return this.#arrays.get(descriptor.name); return this.#arrays.get(descriptor.name);
} }
#disablePicking(code) { this.#picking=false; const allowed=new Set(["PICK_UNAVAILABLE","PICK_PROTOCOL_MISMATCH","PICK_WORKER_ERROR","PICK_STALE","DISPOSED"]); const error=new RendererError(allowed.has(code)?code:"PICK_WORKER_ERROR"); for(const p of this.#picks.values())p.reject(error); this.#picks.clear(); try{this.#bvh?.terminate?.();}catch{} this.#bvh=null; }
#bvhMessage(message) {
if(message?.type==="fatal"){this.#disablePicking(message.code);return;}
if(message?.type!=="pick")return;
const p=this.#picks.get(message.request);if(!p)return;this.#picks.delete(message.request);
let latest=0;try{latest=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=latest;}catch{this.#disablePicking("PICK_PROTOCOL_MISMATCH");p.reject(new RendererError("PICK_PROTOCOL_MISMATCH"));return;}
if(message.stale||p.epoch!==message.epoch||message.epoch!==latest){if(!p.retried&&latest){this.#sendPick({...p,retried:true},latest);}else p.reject(new RendererError("PICK_STALE"));return;}
const hits=(message.hits||[]).map(hit=>({instance:[hit.slot>>>0,hit.generation>>>0],distance:hit.distance}));p.resolve({epoch:latest,hits});
}
#sendPick(p,epoch){const request=this.#pickNext++>>>0||this.#pickNext++;p.epoch=epoch;this.#picks.set(request,p);try{this.#bvh.postMessage({type:"pick",request,epoch,origin:p.origin,direction:p.direction,maxDistance:p.maxDistance,maxHits:p.maxHits});}catch{this.#picks.delete(request);p.reject(new RendererError("PICK_WORKER_ERROR"));}}
pickRay(origin,direction,{maxDistance=Infinity,maxHits=1}={}) {
const vector=(v,name)=>{if(!v||v.length!==3||[...v].some(x=>typeof x!=="number"||!Number.isFinite(x)))throw new TypeError(`${name} must contain 3 finite numbers`);return [...v];};
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.#picking||!this.#bvh||!this.#snapshotReader)return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
let epoch;try{epoch=this.#snapshotReader.latest().epoch;this.#snapshotEpoch=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));
}
#stop() { #stop() {
if (this.#stopped) return; if (this.#stopped) return;
this.#stopped = true; this.#stopped = true;
try { this.#worker?.terminate?.(); } catch { /* best effort */ } try { this.#worker?.terminate?.(); } catch { /* best effort */ }
try { this.#bvh?.postMessage?.({type:"dispose"}); this.#bvh?.terminate?.(); } catch { /* best effort */ }
try { this.#bridge?.free?.(); } catch { /* best effort */ } try { this.#bridge?.free?.(); } catch { /* best effort */ }
this.#bridge = null; this.#bridge = null;
} }
@@ -171,7 +143,6 @@ export class YawnCore extends EventTarget {
this.#payloadPending.clear(); this.#payloadPending.clear();
for (const pending of this.#graphQueue) pending.reject(error); for (const pending of this.#graphQueue) pending.reject(error);
this.#graphQueue.length = 0; this.#graphQueue.length = 0;
this.#disablePicking(code === "DISPOSED" ? "DISPOSED" : "PICK_WORKER_ERROR");
this.#stop(); this.#stop();
} }
@@ -211,14 +182,13 @@ export class YawnCore extends EventTarget {
return promise; return promise;
} }
commitGlbUpload(array, byteLength, { framing = "exterior" } = {}) { commitRenderDataUpload(array, byteLength) {
if (this.#disposed) throw new RendererError("DISPOSED"); if (this.#disposed) throw new RendererError("DISPOSED");
if (framing !== "exterior" && framing !== "interior") throw new TypeError("framing must be exterior or interior");
if (!(array instanceof SharedSoaArray) || array.domain !== "fixed" || array.scalar !== "u32" || array.stride !== array.lanes * 4) if (!(array instanceof SharedSoaArray) || array.domain !== "fixed" || array.scalar !== "u32" || array.stride !== array.lanes * 4)
throw new TypeError("array must be a packed fixed uint32 shared array"); throw new TypeError("array must be a packed fixed uint32 shared array");
if (!Number.isInteger(byteLength) || byteLength < 1 || byteLength > array.length * array.lanes * 4) if (!Number.isInteger(byteLength) || byteLength < 1 || byteLength > array.length * array.lanes * 4)
throw new RangeError("byteLength is outside the shared array"); throw new RangeError("byteLength is outside the shared array");
return this.#enqueue(OP.IMPORT_GLB, [array.id, byteLength, framing === "interior" ? 1 : 0]); return this.#enqueue(OP.INSTALL_RENDER_DATA, [array.id, byteLength]);
} }
async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) { async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) {
@@ -306,11 +276,7 @@ export class YawnCore extends EventTarget {
switchCompiledGraph(compiledId) { switchCompiledGraph(compiledId) {
validateCompiledId(compiledId); validateCompiledId(compiledId);
if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero"); if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero");
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [1, ...compiledId])); return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, compiledId));
}
switchToImmediate() {
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [0, 0, 0]));
} }
dispose() { this.#fail("DISPOSED"); } dispose() { this.#fail("DISPOSED"); }
-22
View File
@@ -1,5 +1,3 @@
cargo-features = ["profile-rustflags"]
[package] [package]
name = "renderer" name = "renderer"
description = "WGPU renderer core library" description = "WGPU renderer core library"
@@ -14,38 +12,18 @@ default = []
atomics = [] atomics = []
bulk-memory = [] bulk-memory = []
[profile.release]
opt-level = "z"
lto = true
[target.wasm32-unknown-unknown]
rustflags = [
"-Clink-args=--shared-memory",
"-Clink-args=--max-memory=1073741824",
"-Clink-args=--import-memory",
"-Clink-args=--export=__wasm_init_tls",
"-Clink-args=--export=__tls_size",
"-Clink-args=--export=__tls_align",
"-Clink-args=--export=__tls_base",
]
[dependencies] [dependencies]
wasm-bindgen = { workspace = true } wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true } wasm-bindgen-futures = { workspace = true }
console_error_panic_hook = { workspace = true } console_error_panic_hook = { workspace = true }
console_log = { workspace = true }
log = { workspace = true } log = { workspace = true }
wasm-logger = { workspace = true } wasm-logger = { workspace = true }
web-sys = { workspace = true } web-sys = { workspace = true }
js-sys = { workspace = true } js-sys = { workspace = true }
bytemuck = { workspace = true } bytemuck = { workspace = true }
cgmath = { workspace = true }
raw-window-handle = { workspace = true }
wgpu = { workspace = true } wgpu = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
ultraviolet = { workspace = true } ultraviolet = { workspace = true }
futures = { workspace = true }
gltf = { workspace = true }
image = { workspace = true } image = { workspace = true }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
+6 -25
View File
@@ -7,11 +7,11 @@ use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use crate::command_ring::CommandRing; use crate::command_ring::CommandRing;
use crate::message::{MouseMessage, ResizeMessage, WheelMessage, WindowEvent};
use crate::platform::web::worker; use crate::platform::web::worker;
use crate::renderer::ResizeMessage;
thread_local! { thread_local! {
static WORKER_EVENTS: RefCell<Option<mpsc::Sender<WindowEvent>>> = const { RefCell::new(None) }; static WORKER_EVENTS: RefCell<Option<mpsc::Sender<ResizeMessage>>> = const { RefCell::new(None) };
} }
/// Deliver a low-frequency browser event to the worker-owned renderer channel. /// Deliver a low-frequency browser event to the worker-owned renderer channel.
@@ -20,30 +20,11 @@ pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
let values = values.to_vec(); let values = values.to_vec();
let value = |index: usize| values.get(index).copied().unwrap_or_default(); let value = |index: usize| values.get(index).copied().unwrap_or_default();
let event = match kind { let event = match kind {
0 => WindowEvent::Resize(ResizeMessage { 0 => ResizeMessage {
width: value(0), width: value(0),
height: value(1), height: value(1),
scale_factor: value(2), scale_factor: value(2),
}), },
1 | 2 => {
let message = MouseMessage {
scale_factor: value(0),
buttons: value(1) as u16,
movement_x: value(2),
movement_y: value(3),
offset_x: value(4),
offset_y: value(5),
viewport_height: value(6),
};
if kind == 1 {
WindowEvent::PointerMove(message)
} else {
WindowEvent::PointerClick(message)
}
}
3 => WindowEvent::PointerWheel(WheelMessage {
delta_y_pixels: value(0) as f32,
}),
_ => return, _ => return,
}; };
WORKER_EVENTS.with(|sender| { WORKER_EVENTS.with(|sender| {
@@ -54,7 +35,7 @@ pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
} }
/// Start the typed renderer and return its SAB command-ring pointer. /// Start the typed renderer and return its SAB command-ring pointer.
pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bool) -> u32 { pub fn worker_entrypoint() -> u32 {
let (sender, events) = mpsc::channel(); let (sender, events) = mpsc::channel();
// The render worker owns this allocation for its entire lifetime. Publishing a // The render worker owns this allocation for its entire lifetime. Publishing a
// stable address lets every connected thread use the same shared command ring. // stable address lets every connected thread use the same shared command ring.
@@ -62,7 +43,7 @@ pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bo
let ring_ptr = ring.ptr(); let ring_ptr = ring.ptr();
WORKER_EVENTS.with(|worker_events| *worker_events.borrow_mut() = Some(sender)); WORKER_EVENTS.with(|worker_events| *worker_events.borrow_mut() = Some(sender));
spawn_local(async move { spawn_local(async move {
worker::run_render_loop::<T>(events, ring, profile).await; worker::run_render_loop(events, ring).await;
}); });
ring_ptr ring_ptr
} }
-543
View File
@@ -1,543 +0,0 @@
use std::f32::consts::PI;
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
use wgpu::util::DeviceExt;
use crate::renderer::scene::UniformResource;
/// A camera matrix cannot produce a safe, meaningful frustum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum FrustumError {
#[error("frustum plane {plane} contains a non-finite component")]
NonFinite { plane: usize },
#[error("frustum plane {plane} has a near-degenerate normal")]
Degenerate { plane: usize },
}
const MIN_DISTANCE: f32 = 0.1;
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
const ORBIT_SENSITIVITY: f32 = 0.005;
const ZOOM_SENSITIVITY: f32 = 0.002;
#[repr(C)]
pub struct Camera {
// Hot data - cached computed matrix (64 bytes, 1 cache line)
pub view_proj: [[f32; 4]; 4],
// Warm data - frequently accessed vectors (36 bytes)
position: Vec3,
target: Vec3,
up: Vec3,
// Cold data - projection parameters (16 bytes)
fov: f32,
aspect_ratio: f32,
z_near: f32,
z_far: f32,
// Rotor orientation for orbit camera behaviour
rotor: Rotor3,
distance: f32,
// Dirty flag for lazy evaluation
dirty: bool,
}
struct OrthonormalBasis {
right: Vec3,
up: Vec3,
forward: Vec3,
}
impl OrthonormalBasis {
pub fn new(right: Vec3, up: Vec3, forward: Vec3) -> Self {
Self { right, up, forward }
}
pub fn from_camera(camera: &Camera) -> Self {
let mut forward_offset = camera.target - camera.position;
if forward_offset.mag_sq() <= f32::EPSILON {
forward_offset = -Vec3::unit_z();
}
let forward = forward_offset.normalized();
let mut right = forward.cross(camera.up);
// Check if right vector is near zero (forward and up are parallel)
if right.mag_sq() < 1e-10 {
// Try alternate axes to find a valid right vector
let alternate_axes = [Vec3::unit_y(), Vec3::unit_x()];
for axis in alternate_axes.iter() {
right = forward.cross(*axis);
if right.mag_sq() >= 1e-10 {
break;
}
}
}
right = right.normalized();
let up = right.cross(forward).normalized();
Self::new(right, up, forward)
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
impl Camera {
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
extract_frustum_planes(self.view_proj)
}
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
position: Vec3::new(0.0, 0.5, 3.0),
target: Vec3::new(0.0, 0.0, 0.0),
up: Vec3::unit_y(),
fov: PI / 3.0,
aspect_ratio,
z_near: 0.1,
z_far: 100000.0,
rotor: Rotor3::identity(),
distance: 1.0,
dirty: true,
};
camera.compute_rotor();
camera.compute_view_proj_mat();
camera
}
pub fn compute_view_proj_mat(&mut self) {
let view = Mat4::look_at(self.position, self.target, self.up);
let proj = projection::rh_yup::perspective_wgpu_dx(
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
);
self.view_proj = (proj * view).into();
self.dirty = false;
}
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
if (self.position - self.target).mag_sq() <= f32::EPSILON {
self.position = self.target + Vec3::unit_z() * MIN_DISTANCE;
}
self.up = Vec3::unit_y();
self.compute_rotor();
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn set_depth_range(&mut self, z_near: f32, z_far: f32) {
self.z_near = z_near;
self.z_far = z_far.max(z_near + f32::EPSILON);
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn position(&self) -> Vec3 {
self.position
}
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
self.aspect_ratio = aspect_ratio;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
if !delta_x.is_finite() || !delta_y.is_finite() {
return;
}
// Skip tiny movements to reduce unnecessary computations
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
return;
}
let yaw_theta = delta_x * ORBIT_SENSITIVITY;
let yaw_rotor =
Rotor3::from_angle_plane(yaw_theta, Bivec3::from_normalized_axis(Vec3::unit_y()));
let basis = OrthonormalBasis::from_camera(self);
let pitch_angle = (delta_y * ORBIT_SENSITIVITY).clamp(-MAX_PITCH, MAX_PITCH);
let pitch_rotor =
Rotor3::from_angle_plane(pitch_angle, Bivec3::from_normalized_axis(basis.right));
let orbit_rotor = (yaw_rotor * pitch_rotor).normalized();
self.rotor = (orbit_rotor * self.rotor).normalized();
let mut offset = self.position - self.target;
if offset.mag_sq() <= f32::EPSILON {
offset = Vec3::unit_z() * self.distance.max(MIN_DISTANCE);
}
orbit_rotor.rotate_vec(&mut offset);
self.distance = offset.mag().max(MIN_DISTANCE);
self.position = offset + self.target;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn zoom(&mut self, delta_y_pixels: f32) {
if !delta_y_pixels.is_finite() || delta_y_pixels.abs() <= f32::EPSILON {
return;
}
let mut offset = self.position - self.target;
let mut current_distance = offset.mag();
if !current_distance.is_finite() {
return;
}
if current_distance <= f32::EPSILON {
offset = Vec3::unit_z() * MIN_DISTANCE;
current_distance = MIN_DISTANCE;
}
let direction = offset / current_distance;
let max_distance = (self.z_far * 0.95).max(MIN_DISTANCE);
if !max_distance.is_finite() {
return;
}
let candidate = f64::from(current_distance)
* (f64::from(ZOOM_SENSITIVITY) * f64::from(delta_y_pixels)).exp();
let new_distance = candidate.clamp(f64::from(MIN_DISTANCE), f64::from(max_distance)) as f32;
let new_position = self.target + direction * new_distance;
if !vec3_is_finite(new_position) {
return;
}
self.position = new_position;
self.distance = new_distance;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn pan(&mut self, delta_x: f32, delta_y: f32, viewport_height: f32) {
if !delta_x.is_finite()
|| !delta_y.is_finite()
|| !viewport_height.is_finite()
|| !self.fov.is_finite()
{
return;
}
let distance = (self.position - self.target).mag().max(MIN_DISTANCE);
if !distance.is_finite() {
return;
}
let world_units_per_pixel =
2.0 * distance * (self.fov * 0.5).tan() / viewport_height.max(1.0);
let basis = OrthonormalBasis::from_camera(self);
let translation = (-basis.right * delta_x + basis.up * delta_y) * world_units_per_pixel;
let position = self.position + translation;
let target = self.target + translation;
if !vec3_is_finite(position) || !vec3_is_finite(target) {
return;
}
self.position = position;
self.target = target;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: "camera uniform buffer".into(),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
contents: bytemuck::cast_slice(&[self.view_proj]),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Camera bind group layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Camera bind group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
fn compute_rotor(&mut self) {
let offset = self.position - self.target;
let distance = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
self.distance = distance.max(MIN_DISTANCE);
// to compute the initial rotor we will do two rotations
// these will orient the camera to the new coordinates
//
// but first we need the orthonormal basis for the current camera
let basis = OrthonormalBasis::from_camera(self);
// first rotation
// this is the swing to make position face the target
let camera_local_up = Vec3::unit_z();
let swing_rotor = Rotor3::from_rotation_between(camera_local_up, -basis.forward);
// now we need a twist rotor which aligns the camera up
let mut up_after_swing = self.up.clone();
swing_rotor.rotate_vec(&mut up_after_swing);
// to rotate a vector by a rotor we need
// - a bivector (represents the axis of rotation)
// - angle of rotation
let twist_axis = (-basis.forward).normalized();
let twist_plane = Bivec3::from_normalized_axis(twist_axis);
// Calculate twist angle between the up vectors:
// u1 × uc ⋅ (-f)
// θ = atan2( ————————————— , u1 ⋅ uc )
// ‖u1 × uc‖
//
// Where:
// u1 = up vector after swing rotation
// uc = camera's current up vector
// f = forward vector (twist axis)
let theta = up_after_swing
.cross(self.up)
.dot(twist_axis)
.atan2(up_after_swing.dot(self.up));
let twist_rotor = Rotor3::from_angle_plane(theta, twist_plane);
self.rotor = (swing_rotor * twist_rotor).normalized();
}
}
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
let r0 = row(0);
let r1 = row(1);
let r2 = row(2);
let r3 = row(3);
let mut planes = [
add(r3, r0),
sub(r3, r0),
add(r3, r1),
sub(r3, r1),
r2,
sub(r3, r2),
];
for (plane, p) in planes.iter_mut().enumerate() {
if !p.iter().all(|component| component.is_finite()) {
return Err(FrustumError::NonFinite { plane });
}
// Scale first: directly squaring very large/small coefficients can overflow or
// underflow even though the plane itself is normalizable.
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
if scale < f32::MIN_POSITIVE {
return Err(FrustumError::Degenerate { plane });
}
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
for v in p {
*v = (*v / scale) / length;
if !v.is_finite() {
return Err(FrustumError::NonFinite { plane });
}
}
}
Ok(planes)
}
fn vec3_is_finite(value: Vec3) -> bool {
value.x.is_finite() && value.y.is_finite() && value.z.is_finite()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
let mut nonfinite = Camera::new(1.0).view_proj;
nonfinite[0][0] = f32::NAN;
assert!(matches!(
extract_frustum_planes(nonfinite),
Err(FrustumError::NonFinite { .. })
));
assert!(matches!(
extract_frustum_planes([[0.0; 4]; 4]),
Err(FrustumError::Degenerate { .. })
));
}
#[test]
fn frustum_extraction_normalizes_without_overflow() {
let mut matrix = Camera::new(1.0).view_proj;
for value in matrix.iter_mut().flatten() {
*value *= 1.0e20;
}
let planes = extract_frustum_planes(matrix).unwrap();
for plane in planes {
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!((length - 1.0).abs() < 1.0e-5);
assert!(plane.iter().all(|value| value.is_finite()));
}
}
fn assert_vec3_close(actual: Vec3, expected: Vec3, epsilon: f32) {
assert!(
(actual.x - expected.x).abs() <= epsilon,
"x: {actual:?} != {expected:?}"
);
assert!(
(actual.y - expected.y).abs() <= epsilon,
"y: {actual:?} != {expected:?}"
);
assert!(
(actual.z - expected.z).abs() <= epsilon,
"z: {actual:?} != {expected:?}"
);
}
fn assert_camera_finite(camera: &Camera) {
assert!(vec3_is_finite(camera.position));
assert!(vec3_is_finite(camera.target));
assert!(camera.distance.is_finite());
assert!(camera
.view_proj
.iter()
.flatten()
.all(|component| component.is_finite()));
}
#[test]
fn zoom_is_multiplicative_and_preserves_target() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_distance = camera.distance;
camera.zoom(-100.0);
assert!(camera.distance < initial_distance);
assert_eq!(camera.target, initial_target);
camera.zoom(100.0);
assert_vec3_close(camera.position, initial_position, 1e-4);
assert!((camera.distance - initial_distance).abs() <= 1e-4);
camera.zoom(100.0);
assert!(camera.distance > initial_distance);
assert_eq!(camera.target, initial_target);
}
#[test]
fn zoom_clamps_and_remains_finite() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
camera.zoom(f32::NEG_INFINITY);
assert!((camera.distance - 10.0).abs() <= 1e-5);
camera.zoom(-f32::MAX);
assert!((camera.distance - MIN_DISTANCE).abs() <= f32::EPSILON);
camera.zoom(f32::MAX);
assert!(camera.distance <= camera.z_far * 0.95);
assert_camera_finite(&camera);
}
#[test]
fn pan_moves_eye_and_target_equally_at_target_plane_scale() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
let initial_position = camera.position;
let initial_target = camera.target;
let initial_offset = initial_position - initial_target;
let units = 2.0 * 10.0 * (PI / 6.0).tan() / 1000.0;
camera.pan(20.0, 10.0, 1000.0);
let translation = Vec3::new(-20.0 * units, 10.0 * units, 0.0);
assert_vec3_close(camera.position, initial_position + translation, 1e-5);
assert_vec3_close(camera.target, initial_target + translation, 1e-5);
assert_vec3_close(camera.position - camera.target, initial_offset, 1e-5);
assert!((camera.distance - 10.0).abs() <= 1e-5);
}
#[test]
fn pan_scale_is_proportional_to_distance() {
let mut near = Camera::new(1.0);
near.look_at(Vec3::new(0.0, 0.0, 5.0), Vec3::zero());
let mut far = Camera::new(1.0);
far.look_at(Vec3::new(0.0, 0.0, 10.0), Vec3::zero());
near.pan(10.0, 0.0, 1000.0);
far.pan(10.0, 0.0, 1000.0);
assert!((far.target.mag() / near.target.mag() - 2.0).abs() <= 1e-5);
}
#[test]
fn orbit_preserves_target_and_distance() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::new(2.0, 3.0, 10.0), Vec3::new(1.0, -1.0, 0.5));
let initial_target = camera.target;
let initial_distance = (camera.position - camera.target).mag();
camera.orbit(40.0, -25.0);
assert_eq!(camera.target, initial_target);
assert!(((camera.position - camera.target).mag() - initial_distance).abs() <= 1e-5);
assert!((camera.distance - initial_distance).abs() <= 1e-5);
assert!(camera.distance >= MIN_DISTANCE);
assert_camera_finite(&camera);
}
#[test]
fn controls_reject_invalid_input_and_recover_degenerate_look_at() {
let mut camera = Camera::new(1.0);
camera.look_at(Vec3::zero(), Vec3::zero());
assert!((camera.position - camera.target).mag() >= MIN_DISTANCE);
let position = camera.position;
let target = camera.target;
camera.orbit(f32::NAN, 1.0);
camera.zoom(f32::INFINITY);
camera.pan(f32::NAN, 1.0, 0.0);
assert_eq!(camera.position, position);
assert_eq!(camera.target, target);
camera.pan(1.0, 1.0, 0.0);
camera.orbit(4.0, -3.0);
camera.zoom(2.0);
assert_camera_finite(&camera);
}
}
-774
View File
@@ -1,774 +0,0 @@
use std::collections::HashMap;
use gltf::Gltf;
use ultraviolet::{Mat4, Vec3};
use crate::render_data::{
InstanceHandle, InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform,
RenderData, RenderDataError,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum AlphaMode {
#[default]
Opaque,
Mask,
Blend,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TextureReference {
pub texture: usize,
pub tex_coord: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Material {
pub key: MaterialKey,
pub base_color_factor: [f32; 4],
pub metallic_factor: f32,
pub roughness_factor: f32,
pub emissive_factor: [f32; 3],
/// Index of refraction for the dielectric Fresnel response.
pub ior: f32,
pub alpha_mode: AlphaMode,
pub alpha_cutoff: f32,
pub double_sided: bool,
pub base_color_texture: Option<TextureReference>,
pub metallic_roughness_texture: Option<TextureReference>,
pub normal_texture: Option<TextureReference>,
pub normal_scale: f32,
pub occlusion_texture: Option<TextureReference>,
pub occlusion_strength: f32,
pub emissive_texture: Option<TextureReference>,
}
impl Default for Material {
fn default() -> Self {
Self {
key: MaterialKey::DEFAULT,
base_color_factor: [1.0; 4],
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor: [0.0; 3],
ior: 1.5,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
base_color_texture: None,
metallic_roughness_texture: None,
normal_texture: None,
normal_scale: 1.0,
occlusion_texture: None,
occlusion_strength: 1.0,
emissive_texture: None,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TextureMetadata {
pub image: usize,
pub sampler: Option<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SamplerMetadata {
pub index: usize,
pub mag_filter: Option<String>,
pub min_filter: Option<String>,
pub wrap_s: String,
pub wrap_t: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ImageSource {
Uri(String),
BufferView(usize),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageMetadata {
pub index: usize,
pub name: Option<String>,
pub mime_type: Option<String>,
pub source: ImageSource,
/// Encoded PNG/JPEG bytes. Kept encoded so GPU installation can decode and
/// upload images one at a time instead of retaining a decoded image batch.
pub encoded_data: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct InstalledScene {
pub meshes: Vec<MeshHandle>,
pub instances: Vec<InstanceHandle>,
pub bounds: Option<ModelBounds>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelBounds {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl ModelBounds {
fn include(&mut self, p: [f32; 3]) {
for i in 0..3 {
self.min[i] = self.min[i].min(p[i]);
self.max[i] = self.max[i].max(p[i]);
}
}
}
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
let first = *points.first()?;
if points.len() < 200 {
let mut bounds = ModelBounds {
min: first,
max: first,
};
for point in &points[1..] {
bounds.include(*point);
}
return Some(bounds);
}
let trim = points.len() / 100;
let mut min = [0.0; 3];
let mut max = [0.0; 3];
for axis in 0..3 {
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
values.sort_by(f32::total_cmp);
min[axis] = values[trim];
max[axis] = values[values.len() - trim - 1];
}
Some(ModelBounds { min, max })
}
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("failed to decode bytes")]
GltfParse(#[from] gltf::Error),
#[error("unsupported or malformed primitive: {0}")]
InvalidPrimitive(String),
#[error("unsupported image source: {0}")]
UnsupportedImage(String),
#[error("invalid KHR_materials_ior value: {0}")]
InvalidIor(f32),
#[error("failed to install imported scene")]
Install(#[from] RenderDataError),
}
fn decode_ior(value: Option<f32>) -> Result<f32, ImportError> {
let ior = value.unwrap_or(1.5);
if ior == 0.0 || (ior.is_finite() && ior >= 1.0) {
Ok(ior)
} else {
Err(ImportError::InvalidIor(ior))
}
}
#[derive(Clone, Debug)]
pub struct ImportedGeometry {
pub key: (usize, usize),
pub material: MaterialKey,
pub double_sided: bool,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub tangents: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
#[derive(Clone, Debug)]
pub struct ImportedOccurrence {
pub key: (usize, usize),
pub transform: ModelTransform,
}
#[derive(Clone, Debug, Default)]
pub struct ImportedScene {
pub geometries: Vec<ImportedGeometry>,
pub occurrences: Vec<ImportedOccurrence>,
pub materials: Vec<Material>,
pub textures: Vec<TextureMetadata>,
pub samplers: Vec<SamplerMetadata>,
pub images: Vec<ImageMetadata>,
}
fn texture_reference(info: gltf::texture::Info<'_>) -> TextureReference {
TextureReference {
texture: info.texture().index(),
tex_coord: info.tex_coord(),
}
}
fn normalize(value: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
let length = value.iter().map(|x| x * x).sum::<f32>().sqrt();
if length > f32::EPSILON && length.is_finite() {
value.map(|x| x / length)
} else {
fallback
}
}
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
/// Completes triangle vertex attributes. Generated attributes use corner vertices,
/// which deliberately splits UV seams, hard normal edges, and opposite handedness.
fn repair_geometry(
positions: Vec<[f32; 3]>,
normals: Option<Vec<[f32; 3]>>,
tangents: Option<Vec<[f32; 4]>>,
uvs: Vec<[f32; 2]>,
indices: Vec<u32>,
) -> Result<
(
Vec<[f32; 3]>,
Vec<[f32; 3]>,
Vec<[f32; 4]>,
Vec<[f32; 2]>,
Vec<u32>,
),
ImportError,
> {
if indices.len() % 3 != 0 || indices.iter().any(|&i| i as usize >= positions.len()) {
return Err(ImportError::InvalidPrimitive(
"triangle indices are malformed".into(),
));
}
let normals_valid = normals.as_ref().is_some_and(|x| x.len() == positions.len());
let tangents_valid = tangents
.as_ref()
.is_some_and(|x| x.len() == positions.len());
if normals_valid && tangents_valid {
return Ok((positions, normals.unwrap(), tangents.unwrap(), uvs, indices));
}
let mut out_p = Vec::with_capacity(indices.len());
let mut out_n = Vec::with_capacity(indices.len());
let mut out_t = Vec::with_capacity(indices.len());
let mut out_uv = Vec::with_capacity(indices.len());
for triangle in indices.chunks_exact(3) {
let ids = [
triangle[0] as usize,
triangle[1] as usize,
triangle[2] as usize,
];
let p = ids.map(|i| positions[i]);
let uv = ids.map(|i| uvs.get(i).copied().unwrap_or([0.0; 2]));
let face_normal = normalize(cross(sub(p[1], p[0]), sub(p[2], p[0])), [0.0, 1.0, 0.0]);
let duv1 = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]];
let duv2 = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]];
let determinant = duv1[0] * duv2[1] - duv1[1] * duv2[0];
let edge1 = sub(p[1], p[0]);
let edge2 = sub(p[2], p[0]);
let (raw_tangent, raw_bitangent) =
if determinant.abs() > f32::EPSILON && determinant.is_finite() {
let r = determinant.recip();
(
std::array::from_fn(|i| (edge1[i] * duv2[1] - edge2[i] * duv1[1]) * r),
std::array::from_fn(|i| (edge2[i] * duv1[0] - edge1[i] * duv2[0]) * r),
)
} else {
([0.0; 3], [0.0; 3])
};
for corner in 0..3 {
let n = normals
.as_ref()
.filter(|_| normals_valid)
.map_or(face_normal, |x| x[ids[corner]]);
let n = normalize(n, face_normal);
let projected = std::array::from_fn(|i| raw_tangent[i] - n[i] * dot(n, raw_tangent));
let fallback_axis = if n[0].abs() < 0.9 {
[1.0, 0.0, 0.0]
} else {
[0.0, 1.0, 0.0]
};
let tangent3 = normalize(
projected,
normalize(cross(fallback_axis, n), [0.0, 0.0, 1.0]),
);
let generated = [
tangent3[0],
tangent3[1],
tangent3[2],
if dot(cross(n, tangent3), raw_bitangent) < 0.0 {
-1.0
} else {
1.0
},
];
out_p.push(p[corner]);
out_n.push(n);
out_t.push(
tangents
.as_ref()
.filter(|_| tangents_valid)
.map_or(generated, |x| x[ids[corner]]),
);
out_uv.push(uv[corner]);
}
}
let out_i = (0..u32::try_from(out_p.len())
.map_err(|_| ImportError::InvalidPrimitive("vertex count exceeds u32".into()))?)
.collect();
Ok((out_p, out_n, out_t, out_uv, out_i))
}
pub fn decode_gltf(bytes: &[u8]) -> Result<ImportedScene, ImportError> {
decode_gltf_model(Gltf::from_slice(bytes)?)
}
pub fn decode_gltf_owned(bytes: Vec<u8>) -> Result<ImportedScene, ImportError> {
let model = Gltf::from_slice(&bytes)?;
drop(bytes);
decode_gltf_model(model)
}
fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
// Reject external images before buffer import can turn them into a generic
// import error (or attempt to interpret a data/external URI).
for image in model.images() {
if let gltf::image::Source::Uri { uri, .. } = image.source() {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)));
}
}
let blob = model.blob.take();
let buffers = gltf::import_buffers(&model.document, None, blob)?;
let mut result = ImportedScene::default();
result.materials.push(Material::default());
for material in model.materials() {
let pbr = material.pbr_metallic_roughness();
let normal = material.normal_texture();
let normal_texture = normal.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
let occlusion = material.occlusion_texture();
let occlusion_texture = occlusion.as_ref().map(|x| TextureReference {
texture: x.texture().index(),
tex_coord: x.tex_coord(),
});
result.materials.push(Material {
key: MaterialKey::new(material.index().unwrap() as u32 + 1),
base_color_factor: pbr.base_color_factor(),
metallic_factor: pbr.metallic_factor(),
roughness_factor: pbr.roughness_factor(),
emissive_factor: material.emissive_factor(),
ior: decode_ior(material.ior())?,
alpha_mode: match material.alpha_mode() {
gltf::material::AlphaMode::Opaque => AlphaMode::Opaque,
gltf::material::AlphaMode::Mask => AlphaMode::Mask,
gltf::material::AlphaMode::Blend => AlphaMode::Blend,
},
alpha_cutoff: material.alpha_cutoff().unwrap_or(0.5),
double_sided: material.double_sided(),
base_color_texture: pbr.base_color_texture().map(texture_reference),
metallic_roughness_texture: pbr.metallic_roughness_texture().map(texture_reference),
normal_texture,
normal_scale: normal.map_or(1.0, |x| x.scale()),
occlusion_texture,
occlusion_strength: occlusion.map_or(1.0, |x| x.strength()),
emissive_texture: material.emissive_texture().map(texture_reference),
});
}
result.textures = model
.textures()
.map(|x| TextureMetadata {
image: x.source().index(),
sampler: x.sampler().index(),
})
.collect();
result.samplers = model
.samplers()
.map(|x| SamplerMetadata {
index: x.index().unwrap(),
mag_filter: x.mag_filter().map(|v| format!("{v:?}")),
min_filter: x.min_filter().map(|v| format!("{v:?}")),
wrap_s: format!("{:?}", x.wrap_s()),
wrap_t: format!("{:?}", x.wrap_t()),
})
.collect();
result.images = model
.images()
.map(|x| -> Result<_, ImportError> {
let (source, mime_type, encoded_data) = match x.source() {
gltf::image::Source::Uri { uri, .. } => {
return Err(ImportError::UnsupportedImage(format!(
"URI/external image '{uri}'"
)))
}
gltf::image::Source::View { view, mime_type } => {
let data = buffers.get(view.buffer().index()).ok_or_else(|| {
ImportError::UnsupportedImage("image buffer is missing".into())
})?;
let end = view.offset().checked_add(view.length()).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView overflows".into())
})?;
let bytes = data.0.get(view.offset()..end).ok_or_else(|| {
ImportError::UnsupportedImage("image bufferView is out of bounds".into())
})?;
(
ImageSource::BufferView(view.index()),
Some(mime_type.to_owned()),
bytes.to_vec(),
)
}
};
Ok(ImageMetadata {
index: x.index(),
name: x.name().map(str::to_owned),
mime_type,
source,
encoded_data,
})
})
.collect::<Result<_, _>>()?;
let mut seen = HashMap::new();
fn visit(
node: gltf::Node<'_>,
parent: Mat4,
buffers: &[gltf::buffer::Data],
result: &mut ImportedScene,
seen: &mut HashMap<(usize, usize), ()>,
) -> Result<(), ImportError> {
let world = parent * Mat4::from(node.transform().matrix());
if let Some(mesh) = node.mesh() {
for primitive in mesh.primitives() {
if primitive.mode() != gltf::mesh::Mode::Triangles {
return Err(ImportError::InvalidPrimitive(
"only triangle primitives are supported".into(),
));
}
let key = (mesh.index(), primitive.index());
if seen.insert(key, ()).is_none() {
let reader = primitive
.reader(|buffer| buffers.get(buffer.index()).map(|data| data.0.as_slice()));
let Some(read_positions) = reader.read_positions() else {
continue;
};
let positions: Vec<_> = read_positions.collect();
let count = positions.len();
if count == 0 {
continue;
}
let normals = reader.read_normals().map(|x| x.collect());
let tangents = reader.read_tangents().map(|x| x.collect());
let mut uvs: Vec<_> = reader
.read_tex_coords(0)
.map(|x| x.into_f32().collect())
.unwrap_or_default();
uvs.resize(count, [0., 0.]);
uvs.truncate(count);
let indices: Vec<u32> = if let Some(indices) = reader.read_indices() {
indices.into_u32().collect()
} else {
let count = u32::try_from(count).map_err(|_| {
ImportError::InvalidPrimitive("vertex count exceeds u32".into())
})?;
(0..count).collect()
};
if indices.is_empty() {
continue;
}
let (positions, normals, tangents, uvs, indices) =
repair_geometry(positions, normals, tangents, uvs, indices)?;
let primitive_material = primitive.material();
result.geometries.push(ImportedGeometry {
key,
material: primitive_material
.index()
.map_or(MaterialKey::DEFAULT, |index| {
MaterialKey::new(index as u32 + 1)
}),
double_sided: primitive_material.double_sided(),
positions,
normals,
tangents,
uvs,
indices,
});
} else if !result.geometries.iter().any(|geometry| geometry.key == key) {
continue;
}
result.occurrences.push(ImportedOccurrence {
key,
transform: world.into(),
});
}
}
for child in node.children() {
visit(child, world, buffers, result, seen)?
}
Ok(())
}
for scene in model.scenes() {
for node in scene.nodes() {
visit(node, Mat4::identity(), &buffers, &mut result, &mut seen)?
}
}
Ok(result)
}
pub fn install_imported(
target: &mut RenderData,
imported: &ImportedScene,
) -> Result<InstalledScene, ImportError> {
let mut stage = target.replacement_stage()?;
let mut handles = HashMap::new();
let mut mesh_handles = Vec::with_capacity(imported.geometries.len());
let mut instance_handles = Vec::new();
let mut first = HashMap::new();
for occurrence in &imported.occurrences {
first.entry(occurrence.key).or_insert(occurrence.transform);
}
for geometry in &imported.geometries {
let transform = *first
.get(&geometry.key)
.ok_or_else(|| ImportError::InvalidPrimitive("geometry has no occurrence".into()))?;
let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions,
normals: &geometry.normals,
tangents: &geometry.tangents,
uvs: &geometry.uvs,
indices: &geometry.indices,
material: geometry.material,
default_instance_type: InstanceType {
words: [
1 | 4 | (geometry.double_sided as u32) * 8,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
],
},
default_transform: transform,
})?;
handles.insert(geometry.key, created.mesh);
mesh_handles.push(created.mesh);
instance_handles.push(created.default_instance);
}
let mut consumed = HashMap::new();
let mut bounds: Option<ModelBounds> = None;
let geometries: HashMap<_, _> = imported
.geometries
.iter()
.map(|geometry| (geometry.key, geometry))
.collect();
let mut focus_points = Vec::new();
for occurrence in &imported.occurrences {
let mesh = *handles
.get(&occurrence.key)
.ok_or_else(|| ImportError::InvalidPrimitive("occurrence has no geometry".into()))?;
if consumed.insert(occurrence.key, ()).is_some() {
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
instance_handles.push(stage.create_instance(
mesh,
occurrence.transform,
instance_type,
)?);
}
let geometry = geometries
.get(&occurrence.key)
.expect("installed occurrence must have geometry");
let transform = Mat4::from(occurrence.transform);
focus_points.extend(geometry.positions.iter().map(|position| {
let point = transform.transform_point3(Vec3::from(*position));
[point.x, point.y, point.z]
}));
let local = stage.mesh(mesh).unwrap().local_aabb;
for x in [local.min[0], local.max[0]] {
for y in [local.min[1], local.max[1]] {
for z in [local.min[2], local.max[2]] {
let p = Mat4::from(occurrence.transform).transform_point3(Vec3::new(x, y, z));
let p = [p.x, p.y, p.z];
if let Some(b) = bounds.as_mut() {
b.include(p)
} else {
bounds = Some(ModelBounds { min: p, max: p })
}
}
}
}
}
bounds = focus_bounds(&focus_points).or(bounds);
target.replace_with(stage)?;
Ok(InstalledScene {
meshes: mesh_handles,
instances: instance_handles,
bounds,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uri_images_are_rejected_explicitly() {
let json = br#"{
"asset":{"version":"2.0"},
"images":[{"uri":"external.png"}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let result = decode_gltf(json);
assert!(
matches!(
result,
Err(ImportError::UnsupportedImage(ref message)) if message.contains("URI/external")
),
"unexpected result: {result:?}"
);
}
#[test]
fn owned_and_borrowed_decode_paths_remain_compatible() {
let json = br#"{
"asset":{"version":"2.0"},
"scenes":[{"nodes":[]}],"scene":0
}"#;
let borrowed = decode_gltf(json).unwrap();
let owned = decode_gltf_owned(json.to_vec()).unwrap();
assert_eq!(borrowed.geometries.len(), owned.geometries.len());
assert_eq!(borrowed.occurrences.len(), owned.occurrences.len());
assert_eq!(borrowed.materials.len(), owned.materials.len());
}
#[test]
fn repair_duplicates_corners_and_generates_flat_finite_frames() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[0.0, 0.0, 1.0],
];
let repaired = repair_geometry(
positions,
None,
None,
vec![[0.0; 2]; 4],
vec![0, 1, 2, 0, 3, 1],
)
.unwrap();
assert_eq!(repaired.0.len(), 6);
assert_eq!(repaired.4, (0..6).collect::<Vec<_>>());
assert_eq!(&repaired.1[..3], &[[0.0, 0.0, 1.0]; 3]);
assert_eq!(&repaired.1[3..], &[[0.0, 1.0, 0.0]; 3]);
assert!(repaired
.2
.iter()
.flatten()
.all(|component| component.is_finite()));
assert!(repaired.2.iter().all(|tangent| tangent[3].abs() == 1.0));
}
#[test]
fn generated_tangents_split_opposite_handedness_and_supplied_values_survive() {
let positions = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[-1.0, 0.0, 0.0],
];
let normals = vec![[0.0, 0.0, 1.0]; 4];
let generated = repair_geometry(
positions.clone(),
Some(normals.clone()),
None,
vec![[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 0.0]],
vec![0, 1, 2, 0, 2, 3],
)
.unwrap();
assert_ne!(generated.2[0][3], generated.2[3][3]);
let supplied = vec![[0.25, 0.5, 0.75, -1.0]; 4];
let preserved = repair_geometry(
positions,
Some(normals),
Some(supplied.clone()),
vec![[0.0; 2]; 4],
vec![0, 1, 2],
)
.unwrap();
assert_eq!(preserved.2, supplied);
}
#[test]
fn material_defaults_match_gltf_core_defaults() {
let material = Material::default();
assert_eq!(material.base_color_factor, [1.0; 4]);
assert_eq!(material.metallic_factor, 1.0);
assert_eq!(material.roughness_factor, 1.0);
assert_eq!(material.alpha_cutoff, 0.5);
assert_eq!(material.ior, 1.5);
assert_eq!(material.key, MaterialKey::DEFAULT);
}
#[test]
fn imports_khr_materials_ior() {
let json = br#"{
"asset":{"version":"2.0"},
"extensionsUsed":["KHR_materials_ior"],
"materials":[{"extensions":{"KHR_materials_ior":{"ior":1.33}}}],
"scenes":[{"nodes":[]}],"scene":0
}"#;
let imported = decode_gltf(json).unwrap();
assert_eq!(imported.materials[1].ior, 1.33);
}
#[test]
fn ior_gate_accepts_default_physical_values_and_explicit_zero_sentinel() {
assert_eq!(decode_ior(None).unwrap(), 1.5);
assert_eq!(decode_ior(Some(1.0)).unwrap(), 1.0);
assert_eq!(decode_ior(Some(1.33)).unwrap(), 1.33);
assert_eq!(decode_ior(Some(0.0)).unwrap(), 0.0);
}
#[test]
fn ior_gate_rejects_nonphysical_and_nonfinite_values() {
for ior in [-1.0, 0.5, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
assert!(matches!(
decode_ior(Some(ior)),
Err(ImportError::InvalidIor(_))
));
}
}
#[test]
fn malformed_but_parseable_json_ior_is_rejected_before_packing() {
for ior in ["-1", "0.5"] {
let json = format!(
r#"{{"asset":{{"version":"2.0"}},"extensionsUsed":["KHR_materials_ior"],"materials":[{{"extensions":{{"KHR_materials_ior":{{"ior":{ior}}}}}}}],"scenes":[{{"nodes":[]}}],"scene":0}}"#
);
assert!(matches!(
decode_gltf(json.as_bytes()),
Err(ImportError::InvalidIor(_))
));
}
}
}
+16 -3
View File
@@ -1,8 +1,5 @@
pub mod app_setup; pub mod app_setup;
pub mod camera;
pub mod command_ring; pub mod command_ring;
pub mod gltf;
pub mod message;
pub mod platform; pub mod platform;
pub mod render_data; pub mod render_data;
pub mod render_graph; pub mod render_graph;
@@ -10,6 +7,22 @@ pub mod renderer;
pub mod shared_snapshot; pub mod shared_snapshot;
pub mod shared_soa; pub mod shared_soa;
/// Start the core renderer inside its owning worker.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn worker_main() -> u32 {
std::panic::set_hook(Box::new(console_error_panic_hook::hook));
wasm_logger::init(wasm_logger::Config::default());
app_setup::worker_entrypoint()
}
/// Return this worker's shared WebAssembly memory to messaging clients.
#[cfg(target_arch = "wasm32")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn worker_memory() -> wasm_bindgen::JsValue {
wasm_bindgen::memory()
}
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); } thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); }
-157
View File
@@ -1,157 +0,0 @@
use core::fmt;
use std::cell::BorrowMutError;
use std::sync::mpsc::TryRecvError;
use wasm_bindgen::JsCast;
pub const RIGHT_BUTTON_MASK: u16 = 0x02;
pub const MIDDLE_BUTTON_MASK: u16 = 0x04;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CameraDrag {
Orbit,
Pan,
}
pub fn camera_drag(buttons: u16) -> Option<CameraDrag> {
if buttons & MIDDLE_BUTTON_MASK != 0 {
Some(CameraDrag::Orbit)
} else if buttons & RIGHT_BUTTON_MASK != 0 {
Some(CameraDrag::Pan)
} else {
None
}
}
pub fn normalize_wheel_delta(delta_y: f64, delta_mode: u32, viewport_height: f64) -> Option<f32> {
if !delta_y.is_finite() || !viewport_height.is_finite() {
return None;
}
let delta = match delta_mode {
0 => delta_y,
1 => delta_y * 16.0,
2 => delta_y * viewport_height.max(1.0),
_ => return None,
};
let delta = delta as f32;
delta.is_finite().then_some(delta)
}
#[derive(Debug)]
pub enum WindowEvent {
Resize(ResizeMessage),
PointerMove(MouseMessage),
PointerClick(MouseMessage),
PointerWheel(WheelMessage),
}
#[derive(Debug, Clone)]
pub struct ResizeMessage {
pub scale_factor: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone)]
pub struct MouseMessage {
pub scale_factor: f64,
pub buttons: u16,
pub movement_x: f64,
pub movement_y: f64,
pub offset_x: f64,
pub offset_y: f64,
pub viewport_height: f64,
}
impl MouseMessage {
pub fn from_evt(event: &web_sys::MouseEvent, viewport_height: f64) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
buttons: event.buttons(),
movement_x: event.movement_x() as f64,
movement_y: event.movement_y() as f64,
offset_x: event.offset_x() as f64,
offset_y: event.offset_y() as f64,
viewport_height,
}
}
pub fn from_pointer_evt(event: &web_sys::PointerEvent, viewport_height: f64) -> Self {
Self::from_evt(event.unchecked_ref(), viewport_height)
}
}
#[derive(Debug, Clone)]
pub struct WheelMessage {
pub delta_y_pixels: f32,
}
impl WheelMessage {
pub fn from_evt(event: &web_sys::WheelEvent, viewport_height: f64) -> Option<Self> {
normalize_wheel_delta(event.delta_y(), event.delta_mode(), viewport_height)
.map(|delta_y_pixels| Self { delta_y_pixels })
}
}
#[derive(Debug)]
pub enum DrainEventError {
BorrowError(BorrowMutError),
ChannelDisconnected,
ChannelEmpty,
}
impl fmt::Display for DrainEventError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
DrainEventError::BorrowError(err) => write!(f, "Failed to borrow renderer: {}", err),
DrainEventError::ChannelDisconnected => write!(f, "Event channel disconnected"),
DrainEventError::ChannelEmpty => write!(f, "Event channel empty"),
}
}
}
impl std::error::Error for DrainEventError {}
impl From<TryRecvError> for DrainEventError {
fn from(err: TryRecvError) -> Self {
match err {
TryRecvError::Empty => DrainEventError::ChannelEmpty,
TryRecvError::Disconnected => DrainEventError::ChannelDisconnected,
}
}
}
impl From<BorrowMutError> for DrainEventError {
fn from(err: BorrowMutError) -> Self {
DrainEventError::BorrowError(err)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wheel_delta_is_normalized_to_css_pixels() {
assert_eq!(normalize_wheel_delta(12.5, 0, 640.0), Some(12.5));
assert_eq!(normalize_wheel_delta(2.0, 1, 640.0), Some(32.0));
assert_eq!(normalize_wheel_delta(-1.0, 2, 640.0), Some(-640.0));
assert_eq!(normalize_wheel_delta(2.0, 2, 0.0), Some(2.0));
assert_eq!(normalize_wheel_delta(1.0, 3, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::NAN, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(f64::INFINITY, 0, 640.0), None);
assert_eq!(normalize_wheel_delta(1.0, 0, f64::NAN), None);
}
#[test]
fn camera_drag_prefers_orbit_when_both_buttons_are_down() {
assert_eq!(camera_drag(0), None);
assert_eq!(camera_drag(1), None);
assert_eq!(camera_drag(RIGHT_BUTTON_MASK), Some(CameraDrag::Pan));
assert_eq!(camera_drag(MIDDLE_BUTTON_MASK), Some(CameraDrag::Orbit));
assert_eq!(
camera_drag(RIGHT_BUTTON_MASK | MIDDLE_BUTTON_MASK),
Some(CameraDrag::Orbit)
);
}
}
@@ -1,4 +1,4 @@
// The level editor's render worker owns its only WASM and WebGPU runtime. // The render worker owns its only WASM and WebGPU runtime.
import initWasm, { import initWasm, {
clear_payloads, clear_payloads,
discard_payload, discard_payload,
@@ -6,7 +6,7 @@ import initWasm, {
worker_main, worker_main,
worker_memory, worker_memory,
worker_window_event, worker_window_event,
} from "/level-editor/pkg/level_editor.js"; } from "/renderer/pkg/renderer.js";
function listenerReady() { function listenerReady() {
if (state !== "waiting-listener") return; if (state !== "waiting-listener") return;
@@ -30,7 +30,7 @@ addEventListener("message", async (event) => {
} }
if (state !== "uninitialized") return; if (state !== "uninitialized") return;
state = "initializing"; state = "initializing";
const { canvas, profile } = message; const { canvas } = message;
// The renderer worker exclusively owns the one WASM instance. Other threads // The renderer worker exclusively owns the one WASM instance. Other threads
// receive only its shared memory and mutate the published SAB layouts. // receive only its shared memory and mutate the published SAB layouts.
@@ -43,7 +43,7 @@ addEventListener("message", async (event) => {
state = "waiting-listener"; state = "waiting-listener";
pending.push({ type: "canvas", canvas }); pending.push({ type: "canvas", canvas });
try { try {
const ringPtr = worker_main(profile); const ringPtr = worker_main();
postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr }); postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr });
setTimeout(listenerReady, 0); setTimeout(listenerReady, 0);
} catch (error) { } catch (error) {
+3 -9
View File
@@ -1,5 +1,5 @@
use crate::command_ring::CommandRing; use crate::command_ring::CommandRing;
use crate::message::WindowEvent; use crate::renderer::ResizeMessage;
use log::info; use log::info;
use std::sync::mpsc::Receiver; use std::sync::mpsc::Receiver;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
@@ -7,18 +7,12 @@ use wasm_bindgen::{prelude::*, JsValue};
use wasm_bindgen_futures::JsFuture; use wasm_bindgen_futures::JsFuture;
use web_sys::MessageEvent; use web_sys::MessageEvent;
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>( pub async fn run_render_loop(events_chan: Receiver<ResizeMessage>, ring: &'static CommandRing) {
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
profile: bool,
) {
use crate::renderer::Renderer; use crate::renderer::Renderer;
let canvas = wait_for_canvas_transfer().await; let canvas = wait_for_canvas_transfer().await;
let renderer = Rc::new(RefCell::new( let renderer = Rc::new(RefCell::new(Renderer::new(canvas, events_chan).await));
Renderer::<T>::new(canvas, events_chan, profile).await,
));
renderer.borrow_mut().command_ring = Some(ring); renderer.borrow_mut().command_ring = Some(ring);
Renderer::run_render_loop(renderer); Renderer::run_render_loop(renderer);
} }
+285
View File
@@ -0,0 +1,285 @@
use std::f32::consts::PI;
use ultraviolet::{projection, Mat4, Vec3};
#[cfg(target_arch = "wasm32")]
use wgpu::util::DeviceExt;
#[cfg(target_arch = "wasm32")]
use crate::renderer::frame_data::UniformResource;
/// A camera matrix cannot produce a safe, meaningful frustum.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum FrustumError {
#[error("frustum plane {plane} contains a non-finite component")]
NonFinite { plane: usize },
#[error("frustum plane {plane} has a near-degenerate normal")]
Degenerate { plane: usize },
}
const MIN_DISTANCE: f32 = 0.1;
/// SIMD-width shared camera row: eye, target, up, then projection parameters.
pub type SharedCameraState = [f32; 16];
#[repr(C)]
pub struct Camera {
// Hot data - cached computed matrix (64 bytes, 1 cache line)
pub view_proj: [[f32; 4]; 4],
// Warm data - frequently accessed vectors (36 bytes)
position: Vec3,
target: Vec3,
up: Vec3,
// Cold data - projection parameters (16 bytes)
fov: f32,
aspect_ratio: f32,
z_near: f32,
z_far: f32,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
impl Camera {
pub fn frustum_planes(&self) -> Result<[[f32; 4]; 6], FrustumError> {
extract_frustum_planes(self.view_proj)
}
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
position: Vec3::new(0.0, 0.5, 3.0),
target: Vec3::new(0.0, 0.0, 0.0),
up: Vec3::unit_y(),
fov: PI / 3.0,
aspect_ratio,
z_near: 0.1,
z_far: 100000.0,
};
camera.compute_view_proj_mat();
camera
}
pub fn compute_view_proj_mat(&mut self) {
let view = Mat4::look_at(self.position, self.target, self.up);
let proj = projection::rh_yup::perspective_wgpu_dx(
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
);
self.view_proj = (proj * view).into();
}
pub fn position(&self) -> Vec3 {
self.position
}
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
self.aspect_ratio = aspect_ratio;
self.compute_view_proj_mat();
}
/// Snapshot the canonical 64-byte shared row.
pub fn shared_state(&self) -> SharedCameraState {
[
self.position.x,
self.position.y,
self.position.z,
1.0,
self.target.x,
self.target.y,
self.target.z,
1.0,
self.up.x,
self.up.y,
self.up.z,
0.0,
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
]
}
/// Apply a complete shared row, rejecting malformed external writes.
pub fn apply_shared_state(&mut self, state: SharedCameraState) -> bool {
if !state.iter().all(|value| value.is_finite())
|| !(0.0..PI).contains(&state[12])
|| state[13] <= 0.0
|| state[14] <= 0.0
|| state[15] <= state[14]
{
return false;
}
let position = Vec3::new(state[0], state[1], state[2]);
let target = Vec3::new(state[4], state[5], state[6]);
let up = Vec3::new(state[8], state[9], state[10]);
let forward = target - position;
if forward.mag_sq() < MIN_DISTANCE * MIN_DISTANCE
|| up.mag_sq() <= f32::EPSILON
|| forward.cross(up).mag_sq() <= f32::EPSILON
{
return false;
}
self.position = position;
self.target = target;
self.up = up.normalized();
self.fov = state[12];
self.aspect_ratio = state[13];
self.z_near = state[14];
self.z_far = state[15];
self.compute_view_proj_mat();
true
}
#[cfg(target_arch = "wasm32")]
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: "camera uniform buffer".into(),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
contents: bytemuck::cast_slice(&[self.view_proj]),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Camera bind group layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Camera bind group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
}
/// Extracts inward-facing normalized WebGPU clip-space planes (zero-to-one depth).
pub fn extract_frustum_planes(m: [[f32; 4]; 4]) -> Result<[[f32; 4]; 6], FrustumError> {
let row = |r: usize| [m[0][r], m[1][r], m[2][r], m[3][r]];
let add = |a: [f32; 4], b: [f32; 4]| [a[0] + b[0], a[1] + b[1], a[2] + b[2], a[3] + b[3]];
let sub = |a: [f32; 4], b: [f32; 4]| [a[0] - b[0], a[1] - b[1], a[2] - b[2], a[3] - b[3]];
let r0 = row(0);
let r1 = row(1);
let r2 = row(2);
let r3 = row(3);
let mut planes = [
add(r3, r0),
sub(r3, r0),
add(r3, r1),
sub(r3, r1),
r2,
sub(r3, r2),
];
for (plane, p) in planes.iter_mut().enumerate() {
if !p.iter().all(|component| component.is_finite()) {
return Err(FrustumError::NonFinite { plane });
}
// Scale first: directly squaring very large/small coefficients can overflow or
// underflow even though the plane itself is normalizable.
let scale = p[0].abs().max(p[1].abs()).max(p[2].abs());
if scale < f32::MIN_POSITIVE {
return Err(FrustumError::Degenerate { plane });
}
let scaled = [p[0] / scale, p[1] / scale, p[2] / scale];
let length = (scaled[0] * scaled[0] + scaled[1] * scaled[1] + scaled[2] * scaled[2]).sqrt();
for v in p {
*v = (*v / scale) / length;
if !v.is_finite() {
return Err(FrustumError::NonFinite { plane });
}
}
}
Ok(planes)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frustum_extraction_rejects_nonfinite_and_degenerate_planes() {
let mut nonfinite = Camera::new(1.0).view_proj;
nonfinite[0][0] = f32::NAN;
assert!(matches!(
extract_frustum_planes(nonfinite),
Err(FrustumError::NonFinite { .. })
));
assert!(matches!(
extract_frustum_planes([[0.0; 4]; 4]),
Err(FrustumError::Degenerate { .. })
));
}
#[test]
fn frustum_extraction_normalizes_without_overflow() {
let mut matrix = Camera::new(1.0).view_proj;
for value in matrix.iter_mut().flatten() {
*value *= 1.0e20;
}
let planes = extract_frustum_planes(matrix).unwrap();
for plane in planes {
let length = (plane[0] * plane[0] + plane[1] * plane[1] + plane[2] * plane[2]).sqrt();
assert!((length - 1.0).abs() < 1.0e-5);
assert!(plane.iter().all(|value| value.is_finite()));
}
}
#[test]
fn shared_state_round_trips_and_rejects_invalid_projection() {
let mut camera = Camera::new(1.0);
let state = [
2.0,
3.0,
10.0,
1.0,
1.0,
-1.0,
0.5,
1.0,
0.0,
1.0,
0.0,
0.0,
PI / 4.0,
16.0 / 9.0,
0.25,
500.0,
];
assert!(camera.apply_shared_state(state));
assert_eq!(camera.shared_state(), state);
assert!(camera
.view_proj
.iter()
.flatten()
.all(|component| component.is_finite()));
let mut invalid = state;
invalid[15] = invalid[14];
assert!(!camera.apply_shared_state(invalid));
assert_eq!(camera.shared_state(), state);
}
}
+6 -1
View File
@@ -1,5 +1,7 @@
pub(crate) mod camera;
mod handle; mod handle;
mod range_allocator; mod range_allocator;
pub(crate) mod upload;
pub use handle::{InstanceHandle, MeshHandle}; pub use handle::{InstanceHandle, MeshHandle};
@@ -32,7 +34,7 @@ pub const IDENTITY_NORMAL_MATRIX: NormalMatrix =
pub struct MaterialKey(u32); pub struct MaterialKey(u32);
impl MaterialKey { impl MaterialKey {
/// The glTF/default material. /// The default material.
pub const DEFAULT: Self = Self(0); pub const DEFAULT: Self = Self(0);
pub const fn new(value: u32) -> Self { pub const fn new(value: u32) -> Self {
@@ -428,6 +430,9 @@ impl RenderData {
/// Creates an empty transactional successor whose handles cannot alias this data. /// Creates an empty transactional successor whose handles cannot alias this data.
pub fn replacement_stage(&self) -> Result<ReplacementStage, RenderDataError> { pub fn replacement_stage(&self) -> Result<ReplacementStage, RenderDataError> {
// Preflight the only fallible operation left at commit time. With the stage
// prepared synchronously, lineage and handle generations cannot drift.
self.next_revision()?;
let capacities = self.capacities(); let capacities = self.capacities();
let mut stage = Self::new(RenderDataConfig { let mut stage = Self::new(RenderDataConfig {
initial_vertices: capacities.vertices, initial_vertices: capacities.vertices,
+734
View File
@@ -0,0 +1,734 @@
use std::collections::{HashMap, HashSet};
use serde::Deserialize;
use ultraviolet::{Mat4, Vec3};
use super::{
InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform, RenderData,
RenderDataError, ReplacementStage,
};
const MAGIC: u32 = u32::from_le_bytes(*b"YRDP");
const VERSION: u32 = 1;
const HEADER_BYTES: usize = 16;
const BASE_COLOR_TEXTURE: u32 = 1 << 0;
const METALLIC_ROUGHNESS_TEXTURE: u32 = 1 << 1;
const NORMAL_TEXTURE: u32 = 1 << 2;
const OCCLUSION_TEXTURE: u32 = 1 << 3;
const EMISSIVE_TEXTURE: u32 = 1 << 4;
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AlphaMode {
#[default]
Opaque,
Mask,
Blend,
}
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextureReference {
pub texture: usize,
pub tex_coord: u32,
}
#[derive(Clone, Debug, PartialEq)]
pub struct Material {
pub key: MaterialKey,
pub base_color_factor: [f32; 4],
pub metallic_factor: f32,
pub roughness_factor: f32,
pub emissive_factor: [f32; 3],
pub ior: f32,
pub alpha_mode: AlphaMode,
pub alpha_cutoff: f32,
pub double_sided: bool,
pub base_color_texture: Option<TextureReference>,
pub metallic_roughness_texture: Option<TextureReference>,
pub normal_texture: Option<TextureReference>,
pub normal_scale: f32,
pub occlusion_texture: Option<TextureReference>,
pub occlusion_strength: f32,
pub emissive_texture: Option<TextureReference>,
}
impl Default for Material {
fn default() -> Self {
Self {
key: MaterialKey::DEFAULT,
base_color_factor: [1.0; 4],
metallic_factor: 1.0,
roughness_factor: 1.0,
emissive_factor: [0.0; 3],
ior: 1.5,
alpha_mode: AlphaMode::Opaque,
alpha_cutoff: 0.5,
double_sided: false,
base_color_texture: None,
metallic_roughness_texture: None,
normal_texture: None,
normal_scale: 1.0,
occlusion_texture: None,
occlusion_strength: 1.0,
emissive_texture: None,
}
}
}
/// SIMD-aligned material row shared with external render-data writers and the GPU.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct MaterialState {
pub base_color_factor: [f32; 4],
pub emissive_factor: [f32; 4],
pub surface_factors: [f32; 4],
pub alpha_optics: [f32; 4],
pub flags: [u32; 4],
pub uv_sets: [u32; 4],
pub debug_extras: [u32; 4],
}
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
reference
.filter(|value| value.tex_coord == 0)
.map_or(0, |_| bit)
}
impl From<&Material> for MaterialState {
fn from(value: &Material) -> Self {
Self {
base_color_factor: value.base_color_factor,
emissive_factor: [
value.emissive_factor[0],
value.emissive_factor[1],
value.emissive_factor[2],
0.0,
],
surface_factors: [
value.metallic_factor,
value.roughness_factor,
value.normal_scale,
value.occlusion_strength,
],
alpha_optics: [
match value.alpha_mode {
AlphaMode::Opaque => 0.0,
AlphaMode::Mask => 1.0,
AlphaMode::Blend => 2.0,
},
value.alpha_cutoff,
value.ior,
if value.ior == 0.0 {
1.0
} else {
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
},
],
flags: [
enabled(value.base_color_texture, BASE_COLOR_TEXTURE)
| enabled(value.metallic_roughness_texture, METALLIC_ROUGHNESS_TEXTURE)
| enabled(value.normal_texture, NORMAL_TEXTURE)
| enabled(value.occlusion_texture, OCCLUSION_TEXTURE)
| enabled(value.emissive_texture, EMISSIVE_TEXTURE),
u32::from(value.double_sided),
0,
0,
],
uv_sets: [
value.base_color_texture.map_or(0, |value| value.tex_coord),
value
.metallic_roughness_texture
.map_or(0, |value| value.tex_coord),
value.normal_texture.map_or(0, |value| value.tex_coord),
value.occlusion_texture.map_or(0, |value| value.tex_coord),
],
debug_extras: [
value.emissive_texture.map_or(0, |value| value.tex_coord),
0,
0,
0,
],
}
}
}
impl MaterialState {
pub const LANES: u32 = 28;
pub fn words(self) -> [u32; Self::LANES as usize] {
bytemuck::cast(self)
}
pub fn from_words(words: [u32; Self::LANES as usize]) -> Self {
bytemuck::cast(words)
}
}
const _: [(); 112] = [(); std::mem::size_of::<MaterialState>()];
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum FilterMode {
Nearest,
#[default]
Linear,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AddressMode {
ClampToEdge,
MirrorRepeat,
#[default]
Repeat,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct TextureMetadata {
pub image: usize,
pub sampler: Option<usize>,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SamplerMetadata {
#[serde(default)]
pub mag_filter: FilterMode,
#[serde(default)]
pub min_filter: FilterMode,
#[serde(default)]
pub mipmap_filter: FilterMode,
#[serde(default)]
pub address_u: AddressMode,
#[serde(default)]
pub address_v: AddressMode,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImageMetadata {
pub mime_type: String,
pub encoded_data: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct UploadedGeometry {
pub id: u32,
pub material: MaterialKey,
pub instance_type: InstanceType,
pub positions: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub tangents: Vec<[f32; 4]>,
pub uvs: Vec<[f32; 2]>,
pub indices: Vec<u32>,
}
#[derive(Clone, Debug)]
pub struct UploadedOccurrence {
pub geometry: u32,
pub transform: ModelTransform,
}
#[derive(Clone, Debug, Default)]
pub struct RenderDataUpload {
pub geometries: Vec<UploadedGeometry>,
pub occurrences: Vec<UploadedOccurrence>,
pub materials: Vec<Material>,
pub textures: Vec<TextureMetadata>,
pub samplers: Vec<SamplerMetadata>,
pub images: Vec<ImageMetadata>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ModelBounds {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl ModelBounds {
fn include(&mut self, point: [f32; 3]) {
for axis in 0..3 {
self.min[axis] = self.min[axis].min(point[axis]);
self.max[axis] = self.max[axis].max(point[axis]);
}
}
}
#[derive(Clone, Debug)]
pub struct InstalledRenderData {
pub meshes: Vec<MeshHandle>,
pub bounds: Option<ModelBounds>,
}
pub struct PreparedRenderData {
pub stage: ReplacementStage,
pub installed: InstalledRenderData,
}
#[derive(Debug, thiserror::Error)]
pub enum RenderDataUploadError {
#[error("render-data packet is malformed: {0}")]
Malformed(&'static str),
#[error("render-data packet metadata is invalid: {0}")]
Metadata(#[from] serde_json::Error),
#[error("render-data packet contains invalid geometry: {0}")]
InvalidGeometry(&'static str),
#[error("render-data packet contains invalid material data")]
InvalidMaterial,
#[error("failed to install uploaded render data")]
Install(#[from] RenderDataError),
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct DataSlice {
offset: u32,
count: u32,
}
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ByteSlice {
offset: u32,
byte_length: u32,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct GeometryMetadata {
id: u32,
material: u32,
instance_type: [u32; 16],
positions: DataSlice,
normals: DataSlice,
tangents: DataSlice,
uvs: DataSlice,
indices: DataSlice,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct OccurrenceMetadata {
geometry: u32,
transform: [f32; 16],
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct MaterialMetadata {
key: u32,
base_color_factor: [f32; 4],
metallic_factor: f32,
roughness_factor: f32,
emissive_factor: [f32; 3],
ior: f32,
alpha_mode: AlphaMode,
alpha_cutoff: f32,
double_sided: bool,
base_color_texture: Option<TextureReference>,
metallic_roughness_texture: Option<TextureReference>,
normal_texture: Option<TextureReference>,
normal_scale: f32,
occlusion_texture: Option<TextureReference>,
occlusion_strength: f32,
emissive_texture: Option<TextureReference>,
}
impl TryFrom<MaterialMetadata> for Material {
type Error = RenderDataUploadError;
fn try_from(value: MaterialMetadata) -> Result<Self, Self::Error> {
let finite = value
.base_color_factor
.iter()
.chain(value.emissive_factor.iter())
.chain([
&value.metallic_factor,
&value.roughness_factor,
&value.ior,
&value.alpha_cutoff,
&value.normal_scale,
&value.occlusion_strength,
])
.all(|component| component.is_finite());
if !finite || (value.ior != 0.0 && value.ior < 1.0) {
return Err(RenderDataUploadError::InvalidMaterial);
}
Ok(Self {
key: MaterialKey::new(value.key),
base_color_factor: value.base_color_factor,
metallic_factor: value.metallic_factor,
roughness_factor: value.roughness_factor,
emissive_factor: value.emissive_factor,
ior: value.ior,
alpha_mode: value.alpha_mode,
alpha_cutoff: value.alpha_cutoff,
double_sided: value.double_sided,
base_color_texture: value.base_color_texture,
metallic_roughness_texture: value.metallic_roughness_texture,
normal_texture: value.normal_texture,
normal_scale: value.normal_scale,
occlusion_texture: value.occlusion_texture,
occlusion_strength: value.occlusion_strength,
emissive_texture: value.emissive_texture,
})
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct ImageMetadataPacket {
mime_type: String,
data: ByteSlice,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct PacketMetadata {
#[serde(default)]
geometries: Vec<GeometryMetadata>,
#[serde(default)]
occurrences: Vec<OccurrenceMetadata>,
#[serde(default)]
materials: Vec<MaterialMetadata>,
#[serde(default)]
textures: Vec<TextureMetadata>,
#[serde(default)]
samplers: Vec<SamplerMetadata>,
#[serde(default)]
images: Vec<ImageMetadataPacket>,
}
fn word(bytes: &[u8], offset: usize) -> Result<u32, RenderDataUploadError> {
let raw: [u8; 4] = bytes
.get(offset..offset + 4)
.ok_or(RenderDataUploadError::Malformed("header is truncated"))?
.try_into()
.unwrap();
Ok(u32::from_le_bytes(raw))
}
fn range(payload: &[u8], offset: u32, byte_length: usize) -> Result<&[u8], RenderDataUploadError> {
let start = usize::try_from(offset)
.map_err(|_| RenderDataUploadError::Malformed("data offset exceeds usize"))?;
let end = start
.checked_add(byte_length)
.ok_or(RenderDataUploadError::Malformed("data range overflows"))?;
payload
.get(start..end)
.ok_or(RenderDataUploadError::Malformed(
"data range is out of bounds",
))
}
fn f32_vectors<const N: usize>(
payload: &[u8],
slice: DataSlice,
) -> Result<Vec<[f32; N]>, RenderDataUploadError> {
if slice.offset % 4 != 0 {
return Err(RenderDataUploadError::Malformed("float data is unaligned"));
}
let count = usize::try_from(slice.count)
.map_err(|_| RenderDataUploadError::Malformed("element count exceeds usize"))?;
let byte_length = count
.checked_mul(N)
.and_then(|value| value.checked_mul(4))
.ok_or(RenderDataUploadError::Malformed(
"float data size overflows",
))?;
let bytes = range(payload, slice.offset, byte_length)?;
Ok(bytes
.chunks_exact(N * 4)
.map(|chunk| {
std::array::from_fn(|lane| {
f32::from_le_bytes(chunk[lane * 4..lane * 4 + 4].try_into().unwrap())
})
})
.collect())
}
fn u32_values(payload: &[u8], slice: DataSlice) -> Result<Vec<u32>, RenderDataUploadError> {
if slice.offset % 4 != 0 {
return Err(RenderDataUploadError::Malformed(
"integer data is unaligned",
));
}
let byte_length = usize::try_from(slice.count)
.ok()
.and_then(|count| count.checked_mul(4))
.ok_or(RenderDataUploadError::Malformed(
"integer data size overflows",
))?;
Ok(range(payload, slice.offset, byte_length)?
.chunks_exact(4)
.map(|chunk| u32::from_le_bytes(chunk.try_into().unwrap()))
.collect())
}
/// Decode the generic binary render-data packet accepted by core.
pub fn decode_render_data_packet(bytes: &[u8]) -> Result<RenderDataUpload, RenderDataUploadError> {
if bytes.len() < HEADER_BYTES || word(bytes, 0)? != MAGIC {
return Err(RenderDataUploadError::Malformed("magic is invalid"));
}
if word(bytes, 4)? != VERSION {
return Err(RenderDataUploadError::Malformed("version is unsupported"));
}
let metadata_len = usize::try_from(word(bytes, 8)?)
.map_err(|_| RenderDataUploadError::Malformed("metadata size exceeds usize"))?;
let payload_len = usize::try_from(word(bytes, 12)?)
.map_err(|_| RenderDataUploadError::Malformed("payload size exceeds usize"))?;
let metadata_end = HEADER_BYTES
.checked_add(metadata_len)
.ok_or(RenderDataUploadError::Malformed("metadata size overflows"))?;
let payload_start = metadata_end.checked_add(3).map(|value| value & !3).ok_or(
RenderDataUploadError::Malformed("payload alignment overflows"),
)?;
let packet_end = payload_start
.checked_add(payload_len)
.ok_or(RenderDataUploadError::Malformed("packet size overflows"))?;
if packet_end != bytes.len() {
return Err(RenderDataUploadError::Malformed(
"packet length is not exact",
));
}
let metadata: PacketMetadata = serde_json::from_slice(
bytes
.get(HEADER_BYTES..metadata_end)
.ok_or(RenderDataUploadError::Malformed("metadata is truncated"))?,
)?;
let payload = &bytes[payload_start..packet_end];
let mut geometry_ids = HashSet::new();
let geometries = metadata
.geometries
.into_iter()
.map(|geometry| {
if !geometry_ids.insert(geometry.id) {
return Err(RenderDataUploadError::InvalidGeometry(
"geometry id is duplicated",
));
}
Ok(UploadedGeometry {
id: geometry.id,
material: MaterialKey::new(geometry.material),
instance_type: InstanceType {
words: geometry.instance_type,
},
positions: f32_vectors(payload, geometry.positions)?,
normals: f32_vectors(payload, geometry.normals)?,
tangents: f32_vectors(payload, geometry.tangents)?,
uvs: f32_vectors(payload, geometry.uvs)?,
indices: u32_values(payload, geometry.indices)?,
})
})
.collect::<Result<Vec<_>, _>>()?;
let occurrences = metadata
.occurrences
.into_iter()
.map(|occurrence| UploadedOccurrence {
geometry: occurrence.geometry,
transform: std::array::from_fn(|column| {
std::array::from_fn(|row| occurrence.transform[column * 4 + row])
}),
})
.collect();
let materials = metadata
.materials
.into_iter()
.map(Material::try_from)
.collect::<Result<Vec<_>, _>>()?;
let images = metadata
.images
.into_iter()
.map(|image| -> Result<_, RenderDataUploadError> {
let byte_length = usize::try_from(image.data.byte_length)
.map_err(|_| RenderDataUploadError::Malformed("image size exceeds usize"))?;
Ok(ImageMetadata {
mime_type: image.mime_type,
encoded_data: range(payload, image.data.offset, byte_length)?.to_vec(),
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(RenderDataUpload {
geometries,
occurrences,
materials,
textures: metadata.textures,
samplers: metadata.samplers,
images,
})
}
fn focus_bounds(points: &[[f32; 3]]) -> Option<ModelBounds> {
let first = *points.first()?;
if points.len() < 200 {
let mut bounds = ModelBounds {
min: first,
max: first,
};
for point in &points[1..] {
bounds.include(*point);
}
return Some(bounds);
}
let trim = points.len() / 100;
let mut min = [0.0; 3];
let mut max = [0.0; 3];
for axis in 0..3 {
let mut values: Vec<_> = points.iter().map(|point| point[axis]).collect();
values.sort_by(f32::total_cmp);
min[axis] = values[trim];
max[axis] = values[values.len() - trim - 1];
}
Some(ModelBounds { min, max })
}
/// Prepare a complete CPU-side replacement without changing live render data.
pub fn prepare_render_data(
target: &RenderData,
upload: &RenderDataUpload,
) -> Result<PreparedRenderData, RenderDataUploadError> {
let mut stage = target.replacement_stage()?;
let mut handles = HashMap::new();
let mut mesh_handles = Vec::with_capacity(upload.geometries.len());
let mut first = HashMap::new();
for occurrence in &upload.occurrences {
first
.entry(occurrence.geometry)
.or_insert(occurrence.transform);
}
for geometry in &upload.geometries {
let transform = *first
.get(&geometry.id)
.ok_or(RenderDataUploadError::InvalidGeometry(
"geometry has no occurrence",
))?;
let created = stage.create_mesh(MeshCreateInfo {
positions: &geometry.positions,
normals: &geometry.normals,
tangents: &geometry.tangents,
uvs: &geometry.uvs,
indices: &geometry.indices,
material: geometry.material,
default_instance_type: geometry.instance_type,
default_transform: transform,
})?;
handles.insert(geometry.id, created.mesh);
mesh_handles.push(created.mesh);
}
let geometries: HashMap<_, _> = upload
.geometries
.iter()
.map(|geometry| (geometry.id, geometry))
.collect();
let mut consumed = HashSet::new();
let mut focus_points = Vec::new();
let mut bounds: Option<ModelBounds> = None;
for occurrence in &upload.occurrences {
let mesh =
*handles
.get(&occurrence.geometry)
.ok_or(RenderDataUploadError::InvalidGeometry(
"occurrence has no geometry",
))?;
if !consumed.insert(occurrence.geometry) {
let instance_type = stage.mesh(mesh).unwrap().default_instance_type;
stage.create_instance(mesh, occurrence.transform, instance_type)?;
}
let geometry = geometries[&occurrence.geometry];
let transform = Mat4::from(occurrence.transform);
focus_points.extend(geometry.positions.iter().map(|position| {
let point = transform.transform_point3(Vec3::from(*position));
[point.x, point.y, point.z]
}));
let local = stage.mesh(mesh).unwrap().local_aabb;
for x in [local.min[0], local.max[0]] {
for y in [local.min[1], local.max[1]] {
for z in [local.min[2], local.max[2]] {
let point = transform.transform_point3(Vec3::new(x, y, z));
let point = [point.x, point.y, point.z];
if let Some(existing) = bounds.as_mut() {
existing.include(point);
} else {
bounds = Some(ModelBounds {
min: point,
max: point,
});
}
}
}
}
}
bounds = focus_bounds(&focus_points).or(bounds);
Ok(PreparedRenderData {
stage,
installed: InstalledRenderData {
meshes: mesh_handles,
bounds,
},
})
}
#[cfg(test)]
mod tests {
use super::*;
fn packet(metadata: serde_json::Value, payload: &[u8]) -> Vec<u8> {
let metadata = serde_json::to_vec(&metadata).unwrap();
let payload_offset = (HEADER_BYTES + metadata.len() + 3) & !3;
let mut packet = vec![0; payload_offset + payload.len()];
packet[0..4].copy_from_slice(&MAGIC.to_le_bytes());
packet[4..8].copy_from_slice(&VERSION.to_le_bytes());
packet[8..12].copy_from_slice(&(metadata.len() as u32).to_le_bytes());
packet[12..16].copy_from_slice(&(payload.len() as u32).to_le_bytes());
packet[HEADER_BYTES..HEADER_BYTES + metadata.len()].copy_from_slice(&metadata);
packet[payload_offset..].copy_from_slice(payload);
packet
}
#[test]
fn generic_packet_decodes_typed_streams_without_format_knowledge() {
let floats: Vec<f32> = [
0., 0., 0., 1., 0., 0., 0., 1., 0., // positions
0., 0., 1., 0., 0., 1., 0., 0., 1., // normals
1., 0., 0., 1., 1., 0., 0., 1., 1., 0., 0., 1., // tangents
0., 0., 1., 0., 0., 1., // uvs
]
.into();
let mut payload = bytemuck::cast_slice(&floats).to_vec();
payload.extend_from_slice(bytemuck::cast_slice(&[0u32, 1, 2]));
let upload = decode_render_data_packet(&packet(
serde_json::json!({
"geometries":[{
"id":7,"material":0,"instanceType":[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
"positions":{"offset":0,"count":3},
"normals":{"offset":36,"count":3},
"tangents":{"offset":72,"count":3},
"uvs":{"offset":120,"count":3},
"indices":{"offset":144,"count":3}
}],
"occurrences":[{"geometry":7,"transform":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}],
"materials":[],"textures":[],"samplers":[],"images":[]
}),
&payload,
))
.unwrap();
assert_eq!(upload.geometries[0].positions.len(), 3);
assert_eq!(upload.geometries[0].indices, [0, 1, 2]);
assert_eq!(upload.occurrences[0].geometry, 7);
}
#[test]
fn packet_length_and_ranges_are_exact() {
let mut invalid = packet(serde_json::json!({}), &[]);
invalid.push(0);
assert!(matches!(
decode_render_data_packet(&invalid),
Err(RenderDataUploadError::Malformed(
"packet length is not exact"
))
));
}
}
+8 -3
View File
@@ -322,10 +322,12 @@ pub fn parse(bytes: &[u8]) -> Result<Graph, GraphError> {
}) })
} }
#[cfg(test)]
fn push_string(out: &mut String, value: &str) { fn push_string(out: &mut String, value: &str) {
out.push_str(&serde_json::to_string(value).expect("strings always serialize")); out.push_str(&serde_json::to_string(value).expect("strings always serialize"));
} }
#[cfg(test)]
fn push_json(out: &mut String, value: &Value) { fn push_json(out: &mut String, value: &Value) {
match value { match value {
Value::Null => out.push_str("null"), Value::Null => out.push_str("null"),
@@ -357,6 +359,7 @@ fn push_json(out: &mut String, value: &Value) {
} }
/// Serializes an internal graph for fixtures and cross-language conformance tests. /// Serializes an internal graph for fixtures and cross-language conformance tests.
#[cfg(test)]
pub fn serialize(graph: &Graph) -> String { pub fn serialize(graph: &Graph) -> String {
let mut out = format!("(yawn-graph {AST_VERSION}\n (id "); let mut out = format!("(yawn-graph {AST_VERSION}\n (id ");
push_string(&mut out, &graph.graph_id); push_string(&mut out, &graph.graph_id);
@@ -425,12 +428,14 @@ pub(crate) fn validate_pipeline_declarations(graph: &Graph) -> Result<(), GraphE
)); ));
} }
} }
if !super::contract(name).is_some_and(|contract| { if super::contract(name).is_some_and(|contract| {
contract.is_raster_draw() || contract.fullscreen_policy.is_some() || name == "frame_out" !contract.is_raster_draw()
&& contract.fullscreen_policy.is_none()
&& name != "frame_out"
}) { }) {
return Err(GraphError::new( return Err(GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED", "GRAPH_EXECUTION_UNSUPPORTED",
format!("authored render pipeline '{name}' has no render executor"), format!("authored render pipeline '{name}' conflicts with a core executor"),
)); ));
} }
shader_bytes = shader_bytes.saturating_add(shader.len()); shader_bytes = shader_bytes.saturating_add(shader.len());
+10 -6
View File
@@ -460,7 +460,11 @@ fn normalize_texture(
}) })
} }
fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> { fn decode(
node: &Node,
i: usize,
pipelines: &PipelineDeclarations,
) -> Result<NormalizedParameters, GraphError> {
let base = format!("nodes[{i}].parameters"); let base = format!("nodes[{i}].parameters");
let invalid = let invalid =
|e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone()); |e: serde_json::Error| error("GRAPH_PARAMETERS_INVALID", &e.to_string(), base.clone());
@@ -733,7 +737,7 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
descriptor: normalize_texture(p.texture, &base)?, descriptor: normalize_texture(p.texture, &base)?,
} }
} }
key if contract(key).is_some_and(|contract| contract.is_raster_draw()) => { key if contract_for(key, pipelines).is_some_and(|contract| contract.is_raster_draw()) => {
let p: RasterParameters = let p: RasterParameters =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?; serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) { if !p.clear_depth.is_finite() || !(0.0..=1.0).contains(&p.clear_depth) {
@@ -758,10 +762,10 @@ fn decode(node: &Node, i: usize) -> Result<NormalizedParameters, GraphError> {
predicate_default: p.predicate_default, predicate_default: p.predicate_default,
} }
} }
key if contract(key) key if contract_for(key, pipelines)
.is_some_and(|contract| contract.execution == ExecutionClass::Expression) => .is_some_and(|contract| contract.execution == ExecutionClass::Expression) =>
{ {
let contract = contract(key).unwrap(); let contract = contract_for(key, pipelines).unwrap();
let object = node.parameters.as_object().ok_or_else(|| { let object = node.parameters.as_object().ok_or_else(|| {
error( error(
"GRAPH_PARAMETERS_INVALID", "GRAPH_PARAMETERS_INVALID",
@@ -909,7 +913,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, n)| { .map(|(i, n)| {
contract(&n.executor.key).ok_or_else(|| { contract_for(&n.executor.key, &graph.pipelines).ok_or_else(|| {
error( error(
"GRAPH_UNKNOWN_EXECUTOR", "GRAPH_UNKNOWN_EXECUTOR",
"unknown executor", "unknown executor",
@@ -931,7 +935,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.nodes .nodes
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, n)| decode(n, i)) .map(|(i, n)| decode(n, i, &graph.pipelines))
.collect::<Result<_, _>>()?; .collect::<Result<_, _>>()?;
if graph if graph
.nodes .nodes
+24 -11
View File
@@ -202,17 +202,6 @@ pub static CONTRACTS: &[Contract] = &[
c!("mesh", 2, Source, NONE_I, MESH_O, false, None), c!("mesh", 2, Source, NONE_I, MESH_O, false, None),
c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None), c!("texture", 2, Source, NONE_I, TEXTURE_O, false, None),
c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None), c!("frustum_cull", 2, Expression, CULL_I, CULL_O, false, None),
c!("ground_plane", 2, Render, RASTER_I, RASTER_O, false, None),
c!("gltf_standard", 2, Render, RASTER_I, RASTER_O, false, None),
c!(
"gltf_standard_double_sided",
2,
Render,
RASTER_I,
RASTER_O,
false,
None
),
c!( c!(
"and", "and",
2, 2,
@@ -443,3 +432,27 @@ pub static CONTRACTS: &[Contract] = &[
pub fn contract(key: &str) -> Option<&'static Contract> { pub fn contract(key: &str) -> Option<&'static Contract> {
CONTRACTS.iter().find(|c| c.key == key) CONTRACTS.iter().find(|c| c.key == key)
} }
static AUTHORED_RENDER_PIPELINE: Contract = c!(
"authored_render_pipeline",
2,
Render,
RASTER_I,
RASTER_O,
false,
None
);
/// Resolve static core executors or a render pipeline declared by this graph.
pub fn contract_for(
key: &str,
pipelines: &crate::render_graph::PipelineDeclarations,
) -> Option<&'static Contract> {
contract(key).or_else(|| {
pipelines
.render
.iter()
.any(|pipeline| pipeline.name == key)
.then_some(&AUTHORED_RENDER_PIPELINE)
})
}
-1
View File
@@ -10,7 +10,6 @@ mod registry;
mod runtime; mod runtime;
mod schema; mod schema;
pub use ast::{parse as parse_ast, serialize as serialize_ast};
pub use compiler::{compile, parse_and_compile}; pub use compiler::{compile, parse_and_compile};
pub use contracts::*; pub use contracts::*;
pub use expression::*; pub use expression::*;
+15 -12
View File
@@ -366,8 +366,8 @@ fn invalid(message: impl Into<String>, path: impl Into<String>) -> GraphError {
error("GRAPH_RUNTIME_PLAN_INVALID", message, path) error("GRAPH_RUNTIME_PLAN_INVALID", message, path)
} }
fn execution_supported(key: &str) -> bool { fn execution_supported(graph: &CompiledGraph, key: &str) -> bool {
contract(key).is_some_and(|contract| { contract_for(key, &graph.pipelines).is_some_and(|contract| {
contract.fullscreen_policy.is_some() || contract.is_raster_draw() || key == "frame_out" contract.fullscreen_policy.is_some() || contract.is_raster_draw() || key == "frame_out"
}) })
} }
@@ -742,7 +742,7 @@ fn validate_pipeline_resolve(
.filter(|access| matches!(access.mode, AccessMode::ColorResolve { .. })) .filter(|access| matches!(access.mode, AccessMode::ColorResolve { .. }))
.count() .count()
== 1; == 1;
if !contract(&producer.executor.key).is_some_and(Contract::is_raster_draw) if !contract_for(&producer.executor.key, &graph.pipelines).is_some_and(Contract::is_raster_draw)
|| producer.original_node_index != *producer_node_index || producer.original_node_index != *producer_node_index
|| !exact_output || !exact_output
|| !matches!(&source.origin, || !matches!(&source.origin,
@@ -787,7 +787,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
} }
} }
for (i, execution) in graph.executions.iter().enumerate() { for (i, execution) in graph.executions.iter().enumerate() {
if !execution_supported(&execution.executor.key) { if !execution_supported(graph, &execution.executor.key) {
return Err(error( return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED", "GRAPH_EXECUTION_UNSUPPORTED",
"unsupported execution", "unsupported execution",
@@ -926,7 +926,8 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
Ok(()) Ok(())
} }
for (i, execution) in graph.executions.iter().enumerate() { for (i, execution) in graph.executions.iter().enumerate() {
let contract = contract(&execution.executor.key).expect("supported executor has contract"); let contract = contract_for(&execution.executor.key, &graph.pipelines)
.expect("supported executor has contract");
if execution.executor.version != contract.version { if execution.executor.version != contract.version {
return Err(invalid( return Err(invalid(
"executor version does not match its contract", "executor version does not match its contract",
@@ -1011,7 +1012,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
)); ));
} }
let producer_execution = &graph.executions[producer as usize]; let producer_execution = &graph.executions[producer as usize];
let input_contract = contract(&execution.executor.key) let input_contract = contract_for(&execution.executor.key, &graph.pipelines)
.expect("supported executor has contract") .expect("supported executor has contract")
.inputs .inputs
.iter() .iter()
@@ -1125,7 +1126,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
.count() .count()
== 1 == 1
}) })
&& contract(&owner_executions[0].executor.key) && contract_for(&owner_executions[0].executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw) .is_some_and(Contract::is_raster_draw)
&& owner_executions[0].executor.version == 2 && owner_executions[0].executor.version == 2
&& graph && graph
@@ -1135,7 +1136,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
.filter(|input| input.resource == source) .filter(|input| input.resource == source)
.count() .count()
== 1 == 1
&& contract(&owner_executions[0].executor.key) && contract_for(&owner_executions[0].executor.key, &graph.pipelines)
.and_then(|contract| contract.inputs.get(*input_ordinal as usize)) .and_then(|contract| contract.inputs.get(*input_ordinal as usize))
.is_some_and(|input| { .is_some_and(|input| {
input.name == *socket input.name == *socket
@@ -1365,7 +1366,7 @@ fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError>
.iter() .iter()
.enumerate() .enumerate()
.filter_map(|(i, e)| { .filter_map(|(i, e)| {
contract(&e.executor.key) contract_for(&e.executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw) .is_some_and(Contract::is_raster_draw)
.then_some(i as u32) .then_some(i as u32)
}) })
@@ -1693,8 +1694,8 @@ pub fn prepare_runtime_plan(
for (i, execution) in graph.executions.iter().enumerate() { for (i, execution) in graph.executions.iter().enumerate() {
let path = format!("executions[{i}]"); let path = format!("executions[{i}]");
match execution.executor.key.as_str() { match execution.executor.key.as_str() {
key if contract(key).is_some_and(Contract::is_raster_draw) => {} key if contract_for(key, &graph.pipelines).is_some_and(Contract::is_raster_draw) => {}
_ if contract(&execution.executor.key) _ if contract_for(&execution.executor.key, &graph.pipelines)
.is_some_and(|contract| contract.fullscreen_policy.is_some()) => {} .is_some_and(|contract| contract.fullscreen_policy.is_some()) => {}
"frame_out" => { "frame_out" => {
if frame_out_index.replace(i).is_some() { if frame_out_index.replace(i).is_some() {
@@ -1728,7 +1729,9 @@ pub fn prepare_runtime_plan(
validate_instance_traversal(graph)?; validate_instance_traversal(graph)?;
for (i, execution) in graph.executions.iter().enumerate() { for (i, execution) in graph.executions.iter().enumerate() {
if !contract(&execution.executor.key).is_some_and(Contract::is_raster_draw) { if !contract_for(&execution.executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw)
{
continue; continue;
} }
let NormalizedParameters::Raster { let NormalizedParameters::Raster {
+2
View File
@@ -43,6 +43,8 @@ pub struct RenderPipelineDeclaration {
pub fragment_entry: String, pub fragment_entry: String,
#[serde(default)] #[serde(default)]
pub double_sided: bool, pub double_sided: bool,
#[serde(default)]
pub material: bool,
} }
/// A binding-free compute pass dispatched before the graph's render passes. /// A binding-free compute pass dispatched before the graph's render passes.
+30 -19
View File
@@ -42,8 +42,17 @@ fn node(id: &str, key: &str, version: u32, parameters: Value, inputs: Value) ->
"parameters": parameters, "inputs": inputs }) "parameters": parameters, "inputs": inputs })
} }
fn render_pipeline_declarations() -> Value {
json!({"render":[
{"name":"unlit","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":false,"material":false},
{"name":"material","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":false,"material":true},
{"name":"material_double_sided","shader":"shader","vertexEntry":"vs_main","fragmentEntry":"fs_main","doubleSided":true,"material":true}
],"compute":[]})
}
pub(crate) fn full_cull_graph() -> Value { pub(crate) fn full_cull_graph() -> Value {
json!({ "schemaVersion": 3, "graphId": "typed", "revision": 1, "nodes": [ json!({ "schemaVersion": 3, "graphId": "typed", "revision": 1,
"pipelines": render_pipeline_declarations(), "nodes": [
texture("color", "rgba16_float"), texture("depth", "depth32_float"), texture("color", "rgba16_float"), texture("depth", "depth32_float"),
node("mesh", "mesh", 2, json!({}), json!({})), node("mesh", "mesh", 2, json!({}), json!({})),
node("words", "separate_u32x16", 1, json!({"valueDefault":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}), node("words", "separate_u32x16", 1, json!({"valueDefault":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}),
@@ -54,7 +63,7 @@ pub(crate) fn full_cull_graph() -> Value {
node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})), node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})),
node("class", "and", 2, json!({}), node("class", "and", 2, json!({}),
json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})), json!({"inputs":[input("bits","bit0")[0].clone(),input("visible","value")[0].clone()]})),
node("pipeline", "gltf_standard", 2, node("pipeline", "material", 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"color":input("color","texture"),"depth":input("depth","texture")})), json!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"color":input("color","texture"),"depth":input("depth","texture")})),
node("frame", "frame_out", 3, node("frame", "frame_out", 3,
@@ -68,12 +77,11 @@ pub(crate) fn full_cull_graph() -> Value {
fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() { fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
assert_eq!(contract("mesh").unwrap().version, 2); assert_eq!(contract("mesh").unwrap().version, 2);
assert!(contract("pipeline").is_none()); assert!(contract("pipeline").is_none());
for key in [ assert!(contract("material").is_none());
"ground_plane", let pipelines: PipelineDeclarations =
"gltf_standard", serde_json::from_value(render_pipeline_declarations()).unwrap();
"gltf_standard_double_sided", for key in ["unlit", "material", "material_double_sided"] {
] { let contract = contract_for(key, &pipelines).unwrap();
let contract = contract(key).unwrap();
assert_eq!(contract.version, 2); assert_eq!(contract.version, 2);
assert!(contract.is_raster_draw()); assert!(contract.is_raster_draw());
} }
@@ -90,7 +98,7 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
("localAabb", SemanticType::LocalAabb) ("localAabb", SemanticType::LocalAabb)
] ]
); );
let predicate = contract("gltf_standard") let predicate = contract_for("material", &pipelines)
.unwrap() .unwrap()
.inputs .inputs
.iter() .iter()
@@ -376,7 +384,7 @@ fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() {
#[test] #[test]
fn raster_executors_reject_removed_pipeline_parameter() { fn raster_executors_reject_removed_pipeline_parameter() {
let mut graph = full_cull_graph(); let mut graph = full_cull_graph();
graph["nodes"][8]["parameters"]["pipeline"] = json!("gltf_standard"); graph["nodes"][8]["parameters"]["pipeline"] = json!("material");
let error = compile_value(graph).unwrap_err(); let error = compile_value(graph).unwrap_err();
assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID"); assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID");
assert_eq!(error.details["path"], "nodes[8].parameters"); assert_eq!(error.details["path"], "nodes[8].parameters");
@@ -426,7 +434,7 @@ fn sibling_raster_writers_form_one_ordered_physical_pass() {
9, 9,
node( node(
"sibling", "sibling",
"ground_plane", "unlit",
2, 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("color","texture"),"depth":input("depth","texture")}), json!({"mesh":input("mesh","mesh"),"color":input("color","texture"),"depth":input("depth","texture")}),
@@ -476,12 +484,13 @@ fn expression_provenance_rejects_cross_mesh_values() {
} }
fn implicit_pipeline_graph() -> Value { fn implicit_pipeline_graph() -> Value {
json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1, "nodes": [ json!({ "schemaVersion": 3, "graphId": "implicit", "revision": 1,
"pipelines": render_pipeline_declarations(), "nodes": [
node("mesh", "mesh", 2, json!({}), json!({})), node("mesh", "mesh", 2, json!({}), json!({})),
node("first", "ground_plane", 2, node("first", "unlit", 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh")})), json!({"mesh":input("mesh","mesh")})),
node("second", "gltf_standard", 2, node("second", "material", 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("first","color"),"depth":input("first","depth")})), json!({"mesh":input("mesh","mesh"),"color":input("first","color"),"depth":input("first","depth")})),
node("frame", "frame_out", 3, node("frame", "frame_out", 3,
@@ -493,12 +502,12 @@ fn implicit_pipeline_graph() -> Value {
fn three_raster_graph() -> Value { fn three_raster_graph() -> Value {
let mut graph = full_cull_graph(); let mut graph = full_cull_graph();
let nodes = graph["nodes"].as_array_mut().unwrap(); let nodes = graph["nodes"].as_array_mut().unwrap();
nodes[8]["executor"] = json!({"key":"ground_plane","version":2}); nodes[8]["executor"] = json!({"key":"unlit","version":2});
nodes.insert( nodes.insert(
9, 9,
node( node(
"standard", "standard",
"gltf_standard", "material",
2, 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[1,0,0,1],"predicateDefault":true}), json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[1,0,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("pipeline","color"),"depth":input("pipeline","depth")}), json!({"mesh":input("mesh","mesh"),"color":input("pipeline","color"),"depth":input("pipeline","depth")}),
@@ -508,7 +517,7 @@ fn three_raster_graph() -> Value {
10, 10,
node( node(
"double", "double",
"gltf_standard_double_sided", "material_double_sided",
2, 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":0.5,"clearColor":[0,1,0,1],"predicateDefault":true}), json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":0.5,"clearColor":[0,1,0,1],"predicateDefault":true}),
json!({"mesh":input("mesh","mesh"),"color":input("standard","color"),"depth":input("standard","depth")}), json!({"mesh":input("mesh","mesh"),"color":input("standard","color"),"depth":input("standard","depth")}),
@@ -857,7 +866,9 @@ fn runtime_rejects_noncanonical_physical_passes() {
#[test] #[test]
fn contract_v4_declares_strict_default_policies() { fn contract_v4_declares_strict_default_policies() {
let pipeline = contract("gltf_standard").unwrap(); let declarations: PipelineDeclarations =
serde_json::from_value(render_pipeline_declarations()).unwrap();
let pipeline = contract_for("material", &declarations).unwrap();
assert_eq!(pipeline.version, 2); assert_eq!(pipeline.version, 2);
assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None); assert_eq!(pipeline.inputs[0].default_policy, InputDefaultPolicy::None);
assert_eq!( assert_eq!(
@@ -1115,7 +1126,7 @@ fn fullscreen_output_is_a_valid_raster_attachment_root() {
11, 11,
node( node(
"later", "later",
"ground_plane", "unlit",
2, 2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}), json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({ json!({
+1 -1
View File
@@ -1,3 +1,3 @@
mod pipeline; mod pipeline;
pub(super) use pipeline::{encode_compiled, encode_immediate}; pub(super) use pipeline::encode_compiled;
+7 -47
View File
@@ -1,28 +1,23 @@
use crate::renderer::{ use crate::renderer::{
gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledGraph, PipelineLibrary, frame_data::FrameData, gpu_scene::GpuSceneCache, material::MaterialResources,
PreparedExecution, ActiveCompiledGraph, PipelineLibrary, PreparedExecution,
}; };
use super::super::scene::Scene; pub(crate) fn encode_compiled(
pub(crate) fn encode_compiled<T: Scene>(
encoder: &mut wgpu::CommandEncoder, encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::TextureView, surface: &wgpu::TextureView,
active: &ActiveCompiledGraph, active: &ActiveCompiledGraph,
scene: &T, frame_data: &FrameData,
gpu: &GpuSceneCache, gpu: &GpuSceneCache,
pipelines: &PipelineLibrary, pipelines: &PipelineLibrary,
materials: &MaterialResources, materials: &MaterialResources,
planes: Option<&[[f32; 4]; 6]>, planes: Option<&[[f32; 4]; 6]>,
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) -> Result<(), &'static str> { ) -> Result<(), &'static str> {
use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp}; use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
for compute in &active.compute { for compute in &active.compute {
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(&compute.name), label: Some(&compute.name),
timestamp_writes: profile timestamp_writes: None,
.as_deref_mut()
.and_then(|profile| profile.compute_writes(&compute.name)),
}); });
pass.set_pipeline(&compute.pipeline); pass.set_pipeline(&compute.pipeline);
pass.dispatch_workgroups( pass.dispatch_workgroups(
@@ -150,7 +145,7 @@ pub(crate) fn encode_compiled<T: Scene>(
color_attachments: &colors, color_attachments: &colors,
depth_stencil_attachment: depth, depth_stencil_attachment: depth,
occlusion_query_set: None, occlusion_query_set: None,
timestamp_writes: profile.as_deref_mut().and_then(|p| p.render_writes(&label)), timestamp_writes: None,
}); });
for &member in &physical.executions { for &member in &physical.executions {
match active match active
@@ -178,7 +173,7 @@ pub(crate) fn encode_compiled<T: Scene>(
.instance_traversal .instance_traversal
.as_ref() .as_ref()
.ok_or("compiled graph instance traversal missing")?; .ok_or("compiled graph instance traversal missing")?;
for (i, group) in scene.bind_groups().iter().enumerate() { for (i, group) in frame_data.bind_groups().iter().enumerate() {
pass.set_bind_group(i as u32, group, &[]); pass.set_bind_group(i as u32, group, &[]);
} }
if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = ( if let (Some(p), Some(n), Some(u), Some(t), Some(ix), Some(inst)) = (
@@ -229,38 +224,3 @@ pub(crate) fn encode_compiled<T: Scene>(
} }
Ok(()) Ok(())
} }
pub(crate) fn encode_immediate(
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) {
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("No active render graph"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
view: color,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.,
g: 0.,
b: 0.,
a: 1.,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: profile.and_then(|p| p.render_writes("no-active-graph")),
});
}
+146
View File
@@ -0,0 +1,146 @@
#[cfg(target_arch = "wasm32")]
use wgpu::util::DeviceExt;
use crate::render_data::camera::Camera;
#[cfg(target_arch = "wasm32")]
use crate::renderer::{self, PipelineLibrary};
#[cfg(target_arch = "wasm32")]
pub struct UniformResource {
pub buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
pub bind_group_layout: wgpu::BindGroupLayout,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
pub struct FrameMetadata {
pub resolution: [f32; 2],
time: f32,
_padding0: f32,
pub camera_position: [f32; 4],
}
impl FrameMetadata {
#[cfg(target_arch = "wasm32")]
pub fn new(dimension: ultraviolet::Vec2) -> Self {
Self {
resolution: dimension.into(),
camera_position: [0., 0., 0., 1.],
..Default::default()
}
}
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
self.camera_position = [p.x, p.y, p.z, 1.];
}
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
self.resolution = d.into();
}
#[cfg(target_arch = "wasm32")]
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("frame metadata"),
contents: bytemuck::bytes_of(&self),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("frame layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("frame group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
}
pub(crate) struct FrameData {
uniform_buffers: [wgpu::Buffer; 2],
bind_groups: [wgpu::BindGroup; 2],
metadata: FrameMetadata,
camera: Camera,
}
impl FrameData {
#[cfg(target_arch = "wasm32")]
pub(crate) fn new(
context: &renderer::RendererContext,
resources: &mut PipelineLibrary,
) -> Self {
let dimensions = ultraviolet::Vec2::new(
context.surface_config.width as f32,
context.surface_config.height as f32,
);
let mut metadata = FrameMetadata::new(dimensions);
let camera = Camera::new(dimensions.x / dimensions.y);
metadata.set_camera_position(camera.position());
let frame = metadata.create_uniform_resource(&context.device);
let camera_uniform = camera.create_uniform_resource(&context.device);
resources
.set_bind_group_layouts(&[frame.bind_group_layout, camera_uniform.bind_group_layout]);
Self {
uniform_buffers: [frame.buffer, camera_uniform.buffer],
bind_groups: [frame.bind_group, camera_uniform.bind_group],
metadata,
camera,
}
}
pub(crate) fn bind_groups(&self) -> &[wgpu::BindGroup] {
&self.bind_groups
}
pub(crate) fn camera_mut(&mut self) -> &mut Camera {
&mut self.camera
}
pub(crate) fn frustum_planes(
&mut self,
) -> Result<[[f32; 4]; 6], crate::render_data::camera::FrustumError> {
self.camera.frustum_planes()
}
pub(crate) fn resize(&mut self, width: f64, height: f64, queue: &wgpu::Queue) {
self.metadata
.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
self.camera
.update_aspect_ratio(width as f32 / height as f32);
self.write_uniforms(queue);
}
pub(crate) fn update(&mut self, queue: &wgpu::Queue) {
self.metadata.time = js_sys::Date::now() as f32 * 0.001;
self.metadata.set_camera_position(self.camera.position());
self.write_uniforms(queue);
}
fn write_uniforms(&self, queue: &wgpu::Queue) {
queue.write_buffer(
&self.uniform_buffers[0],
0,
bytemuck::bytes_of(&self.metadata),
);
queue.write_buffer(
&self.uniform_buffers[1],
0,
bytemuck::bytes_of(&self.camera.view_proj),
);
}
}
+72 -139
View File
@@ -1,92 +1,15 @@
use std::collections::HashMap; use std::collections::HashMap;
use bytemuck::{Pod, Zeroable};
use image::DynamicImage; use image::DynamicImage;
use wgpu::util::DeviceExt; use wgpu::util::DeviceExt;
use crate::{ use crate::render_data::{
gltf::{AlphaMode, ImageSource, ImportedScene, Material, SamplerMetadata, TextureReference}, upload::{AddressMode, FilterMode, Material, MaterialState, RenderDataUpload, SamplerMetadata},
render_data::MaterialKey, MaterialKey,
}; };
const BASE: u32 = 1 << 0;
const MR: u32 = 1 << 1;
const NORMAL: u32 = 1 << 2;
const OCCLUSION: u32 = 1 << 3;
const EMISSIVE: u32 = 1 << 4;
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable)]
pub struct GpuMaterial {
pub base_color_factor: [f32; 4],
pub emissive_factor: [f32; 4],
pub surface_factors: [f32; 4],
pub alpha_optics: [f32; 4],
pub flags: [u32; 4],
pub uv_sets: [u32; 4],
/// Internal shader diagnostics; zero means the normal shaded view.
pub debug_extras: [u32; 4],
}
fn enabled(reference: Option<TextureReference>, bit: u32) -> u32 {
reference.filter(|r| r.tex_coord == 0).map_or(0, |_| bit)
}
impl From<&Material> for GpuMaterial {
fn from(value: &Material) -> Self {
Self {
base_color_factor: value.base_color_factor,
emissive_factor: [
value.emissive_factor[0],
value.emissive_factor[1],
value.emissive_factor[2],
0.0,
],
surface_factors: [
value.metallic_factor,
value.roughness_factor,
value.normal_scale,
value.occlusion_strength,
],
alpha_optics: [
match value.alpha_mode {
AlphaMode::Opaque => 0.0,
AlphaMode::Mask => 1.0,
AlphaMode::Blend => 2.0,
},
value.alpha_cutoff,
value.ior,
if value.ior == 0.0 {
1.0
} else {
((value.ior - 1.0) / (value.ior + 1.0)).powi(2)
},
],
flags: [
enabled(value.base_color_texture, BASE)
| enabled(value.metallic_roughness_texture, MR)
| enabled(value.normal_texture, NORMAL)
| enabled(value.occlusion_texture, OCCLUSION)
| enabled(value.emissive_texture, EMISSIVE),
u32::from(value.double_sided),
0,
0,
],
uv_sets: [
value.base_color_texture.map_or(0, |x| x.tex_coord),
value.metallic_roughness_texture.map_or(0, |x| x.tex_coord),
value.normal_texture.map_or(0, |x| x.tex_coord),
value.occlusion_texture.map_or(0, |x| x.tex_coord),
],
debug_extras: [value.emissive_texture.map_or(0, |x| x.tex_coord), 0, 0, 0],
}
}
}
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
pub enum MaterialError { pub enum MaterialError {
#[error("external image sources are unsupported")]
ExternalImage,
#[error("unsupported image MIME type: {0}")] #[error("unsupported image MIME type: {0}")]
Mime(String), Mime(String),
#[error("image decode failed: {0}")] #[error("image decode failed: {0}")]
@@ -97,8 +20,13 @@ pub enum MaterialError {
InvalidRgba(&'static str), InvalidRgba(&'static str),
} }
struct MaterialBinding {
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
pub(super) struct PreparedMaterials { pub(super) struct PreparedMaterials {
groups: HashMap<MaterialKey, wgpu::BindGroup>, groups: HashMap<MaterialKey, MaterialBinding>,
textures: Vec<wgpu::Texture>, textures: Vec<wgpu::Texture>,
views: Vec<[wgpu::TextureView; 2]>, views: Vec<[wgpu::TextureView; 2]>,
samplers: Vec<wgpu::Sampler>, samplers: Vec<wgpu::Sampler>,
@@ -106,8 +34,8 @@ pub(super) struct PreparedMaterials {
pub struct MaterialResources { pub struct MaterialResources {
pub layout: wgpu::BindGroupLayout, pub layout: wgpu::BindGroupLayout,
groups: HashMap<MaterialKey, wgpu::BindGroup>, groups: HashMap<MaterialKey, MaterialBinding>,
fallback: wgpu::BindGroup, fallback: MaterialBinding,
fallback_views: Vec<wgpu::TextureView>, fallback_views: Vec<wgpu::TextureView>,
fallback_sampler: wgpu::Sampler, fallback_sampler: wgpu::Sampler,
textures: Vec<wgpu::Texture>, textures: Vec<wgpu::Texture>,
@@ -208,12 +136,18 @@ fn slot_uses_srgb(slot: usize) -> bool {
matches!(slot, 0 | 4) matches!(slot, 0 | 4)
} }
fn address(value: &str) -> wgpu::AddressMode { fn address(value: AddressMode) -> wgpu::AddressMode {
match value { match value {
"ClampToEdge" => wgpu::AddressMode::ClampToEdge, AddressMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
"MirroredRepeat" => wgpu::AddressMode::MirrorRepeat, AddressMode::MirrorRepeat => wgpu::AddressMode::MirrorRepeat,
"Repeat" => wgpu::AddressMode::Repeat, AddressMode::Repeat => wgpu::AddressMode::Repeat,
_ => unreachable!("gltf crate returned unknown wrap"), }
}
fn filter(value: FilterMode) -> wgpu::FilterMode {
match value {
FilterMode::Nearest => wgpu::FilterMode::Nearest,
FilterMode::Linear => wgpu::FilterMode::Linear,
} }
} }
@@ -227,29 +161,13 @@ fn sampler_descriptor(metadata: Option<&SamplerMetadata>) -> wgpu::SamplerDescri
wgpu::AddressMode::Repeat, wgpu::AddressMode::Repeat,
), ),
|m| { |m| {
let mag = match m.mag_filter.as_deref() { (
Some("Nearest") => wgpu::FilterMode::Nearest, filter(m.mag_filter),
Some("Linear") | None => wgpu::FilterMode::Linear, filter(m.min_filter),
_ => unreachable!(), filter(m.mipmap_filter),
}; address(m.address_u),
let (min, mip) = match m.min_filter.as_deref() { address(m.address_v),
Some("Nearest") => (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest), )
Some("Linear") => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
Some("NearestMipmapNearest") => {
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
}
Some("LinearMipmapNearest") => {
(wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
}
Some("NearestMipmapLinear") => {
(wgpu::FilterMode::Nearest, wgpu::FilterMode::Linear)
}
Some("LinearMipmapLinear") | None => {
(wgpu::FilterMode::Linear, wgpu::FilterMode::Linear)
}
_ => unreachable!(),
};
(mag, min, mip, address(&m.wrap_s), address(&m.wrap_t))
}, },
); );
wgpu::SamplerDescriptor { wgpu::SamplerDescriptor {
@@ -289,7 +207,7 @@ impl MaterialResources {
)); ));
} }
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("glTF material group 2"), label: Some("render-data material group 2"),
entries: &entries, entries: &entries,
}); });
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]]; let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
@@ -333,11 +251,11 @@ impl MaterialResources {
material: &Material, material: &Material,
views: [&wgpu::TextureView; 5], views: [&wgpu::TextureView; 5],
samplers: [&wgpu::Sampler; 5], samplers: [&wgpu::Sampler; 5],
) -> wgpu::BindGroup { ) -> MaterialBinding {
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("material uniform"), label: Some("material uniform"),
contents: bytemuck::bytes_of(&GpuMaterial::from(material)), contents: bytemuck::bytes_of(&MaterialState::from(material)),
usage: wgpu::BufferUsages::UNIFORM, usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
}); });
let mut entries = vec![wgpu::BindGroupEntry { let mut entries = vec![wgpu::BindGroupEntry {
binding: 0, binding: 0,
@@ -355,30 +273,28 @@ impl MaterialResources {
resource: wgpu::BindingResource::Sampler(sampler), resource: wgpu::BindingResource::Sampler(sampler),
}); });
} }
device.create_bind_group(&wgpu::BindGroupDescriptor { let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("material bind group"), label: Some("material bind group"),
layout, layout,
entries: &entries, entries: &entries,
}) });
MaterialBinding { group, uniform }
} }
pub(super) fn prepare( pub(super) fn prepare(
&self, &self,
device: &wgpu::Device, device: &wgpu::Device,
queue: &wgpu::Queue, queue: &wgpu::Queue,
scene: &ImportedScene, scene: &RenderDataUpload,
) -> Result<PreparedMaterials, MaterialError> { ) -> Result<PreparedMaterials, MaterialError> {
let max_dimension = device.limits().max_texture_dimension_2d; let max_dimension = device.limits().max_texture_dimension_2d;
let mut textures = Vec::with_capacity(scene.images.len()); let mut textures = Vec::with_capacity(scene.images.len());
let mut views = Vec::with_capacity(scene.images.len()); let mut views = Vec::with_capacity(scene.images.len());
for image in &scene.images { for image in &scene.images {
if !matches!(image.source, ImageSource::BufferView(_)) { let format = match image.mime_type.as_str() {
return Err(MaterialError::ExternalImage); "image/png" => image::ImageFormat::Png,
} "image/jpeg" => image::ImageFormat::Jpeg,
let format = match image.mime_type.as_deref() { other => return Err(MaterialError::Mime(other.into())),
Some("image/png") => image::ImageFormat::Png,
Some("image/jpeg") => image::ImageFormat::Jpeg,
other => return Err(MaterialError::Mime(other.unwrap_or("missing").into())),
}; };
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?; let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
let (width, height, rgba) = normalize_rgba(decoded); let (width, height, rgba) = normalize_rgba(decoded);
@@ -386,7 +302,7 @@ impl MaterialResources {
let (texture, image_views) = upload_rgba( let (texture, image_views) = upload_rgba(
device, device,
queue, queue,
"glTF embedded image", "render-data image",
width, width,
height, height,
bytes_per_row, bytes_per_row,
@@ -464,7 +380,24 @@ impl MaterialResources {
} }
pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup { pub fn group(&self, key: MaterialKey) -> &wgpu::BindGroup {
self.groups.get(&key).unwrap_or(&self.fallback) &self.groups.get(&key).unwrap_or(&self.fallback).group
}
pub fn synchronize(
&self,
queue: &wgpu::Queue,
rows: &[(MaterialKey, [u32; MaterialState::LANES as usize])],
) {
for (key, words) in rows {
let binding = self
.groups
.get(key)
.or_else(|| (*key == MaterialKey::DEFAULT).then_some(&self.fallback));
if let Some(binding) = binding {
let state = MaterialState::from_words(*words);
queue.write_buffer(&binding.uniform, 0, bytemuck::bytes_of(&state));
}
}
} }
} }
@@ -510,30 +443,30 @@ mod tests {
} }
#[test] #[test]
fn material_uniform_is_112_bytes() { fn material_uniform_is_112_bytes() {
assert_eq!(std::mem::size_of::<GpuMaterial>(), 112); assert_eq!(std::mem::size_of::<MaterialState>(), 112);
} }
#[test] #[test]
fn texcoord_one_disables_slot() { fn texcoord_one_disables_slot() {
let mut m = Material::default(); let mut m = Material::default();
m.base_color_texture = Some(TextureReference { m.base_color_texture = Some(crate::render_data::upload::TextureReference {
texture: 0, texture: 0,
tex_coord: 1, tex_coord: 1,
}); });
assert_eq!(GpuMaterial::from(&m).flags[0] & BASE, 0); assert_eq!(MaterialState::from(&m).flags[0] & 1, 0);
} }
#[test] #[test]
fn material_packing_includes_ior_f0_flags_and_uv_sets() { fn material_packing_includes_ior_f0_flags_and_uv_sets() {
let mut m = Material::default(); let mut m = Material::default();
m.ior = 2.0; m.ior = 2.0;
m.double_sided = true; m.double_sided = true;
m.normal_texture = Some(TextureReference { m.normal_texture = Some(crate::render_data::upload::TextureReference {
texture: 4, texture: 4,
tex_coord: 0, tex_coord: 0,
}); });
let gpu = GpuMaterial::from(&m); let gpu = MaterialState::from(&m);
assert_eq!(gpu.alpha_optics[2], 2.0); assert_eq!(gpu.alpha_optics[2], 2.0);
assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6); assert!((gpu.alpha_optics[3] - 1.0 / 9.0).abs() < 1e-6);
assert_eq!(gpu.flags[0] & NORMAL, NORMAL); assert_eq!(gpu.flags[0] & (1 << 2), 1 << 2);
assert_eq!(gpu.flags[1], 1); assert_eq!(gpu.flags[1], 1);
assert_eq!(gpu.uv_sets[2], 0); assert_eq!(gpu.uv_sets[2], 0);
} }
@@ -541,18 +474,18 @@ mod tests {
fn explicit_ior_sentinel_packs_unit_f0() { fn explicit_ior_sentinel_packs_unit_f0() {
let mut material = Material::default(); let mut material = Material::default();
material.ior = 0.0; material.ior = 0.0;
let gpu = GpuMaterial::from(&material); let gpu = MaterialState::from(&material);
assert_eq!(gpu.alpha_optics[2], 0.0); assert_eq!(gpu.alpha_optics[2], 0.0);
assert_eq!(gpu.alpha_optics[3], 1.0); assert_eq!(gpu.alpha_optics[3], 1.0);
} }
#[test] #[test]
fn sampler_translation_is_exact() { fn sampler_translation_is_exact() {
let m = SamplerMetadata { let m = SamplerMetadata {
index: 0, mag_filter: FilterMode::Nearest,
mag_filter: Some("Nearest".into()), min_filter: FilterMode::Linear,
min_filter: Some("LinearMipmapNearest".into()), mipmap_filter: FilterMode::Nearest,
wrap_s: "ClampToEdge".into(), address_u: AddressMode::ClampToEdge,
wrap_t: "MirroredRepeat".into(), address_v: AddressMode::MirrorRepeat,
}; };
let d = sampler_descriptor(Some(&m)); let d = sampler_descriptor(Some(&m));
assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest); assert_eq!(d.mag_filter, wgpu::FilterMode::Nearest);
File diff suppressed because it is too large Load Diff
+19 -13
View File
@@ -171,11 +171,11 @@ impl PipelineLibrary {
key key
} }
/// Registers the glTF-only layout, preserving scene groups at 0 and 1. /// Registers the optional material layout after the frame and render-data groups.
pub fn set_material_bind_group_layout(&mut self, layout: &wgpu::BindGroupLayout) { pub fn set_material_bind_group_layout(&mut self, layout: &wgpu::BindGroupLayout) {
let base = self let base = self
.default_layout .default_layout
.expect("scene layouts must be registered first"); .expect("render-data layouts must be registered first");
let mut layouts = self.layout_bindings[&base].clone(); let mut layouts = self.layout_bindings[&base].clone();
layouts.push(layout.clone()); layouts.push(layout.clone());
let key = PipelineLayoutKey(self.next_layout); let key = PipelineLayoutKey(self.next_layout);
@@ -184,12 +184,13 @@ impl PipelineLibrary {
self.material_layout = Some(key); self.material_layout = Some(key);
} }
fn compatibility_spec( fn authored_spec(
&self, &self,
name: &str,
layouts: &[wgpu::VertexBufferLayout], layouts: &[wgpu::VertexBufferLayout],
shader: &str, shader: &str,
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
material: bool,
double_sided: bool,
) -> RenderPipelineSpec { ) -> RenderPipelineSpec {
let stage = |entry: &str| OwnedProgrammableStage { let stage = |entry: &str| OwnedProgrammableStage {
shader_source: shader.to_owned(), shader_source: shader.to_owned(),
@@ -198,7 +199,7 @@ impl PipelineLibrary {
zero_initialize_workgroup_memory: true, zero_initialize_workgroup_memory: true,
}; };
RenderPipelineSpec { RenderPipelineSpec {
layout: if name.starts_with("gltf_") { layout: if material {
self.material_layout.or(self.default_layout) self.material_layout.or(self.default_layout)
} else { } else {
self.default_layout self.default_layout
@@ -214,7 +215,7 @@ impl PipelineLibrary {
.collect(), .collect(),
fragment: Some(stage("fs_main")), fragment: Some(stage("fs_main")),
primitive: wgpu::PrimitiveState { primitive: wgpu::PrimitiveState {
cull_mode: (name != "gltf_standard_double_sided").then_some(wgpu::Face::Back), cull_mode: (!double_sided).then_some(wgpu::Face::Back),
..Default::default() ..Default::default()
}, },
depth_stencil: Some(wgpu::DepthStencilState { depth_stencil: Some(wgpu::DepthStencilState {
@@ -330,7 +331,7 @@ impl PipelineLibrary {
pipeline_key pipeline_key
} }
/// Creates a graph-owned scene pipeline from its authored declaration. /// Creates a graph-owned render-data pipeline from its authored declaration.
pub(crate) fn get_or_create_authored_pipeline( pub(crate) fn get_or_create_authored_pipeline(
&mut self, &mut self,
device: &wgpu::Device, device: &wgpu::Device,
@@ -338,14 +339,18 @@ impl PipelineLibrary {
layouts: &[wgpu::VertexBufferLayout], layouts: &[wgpu::VertexBufferLayout],
format: wgpu::TextureFormat, format: wgpu::TextureFormat,
) -> PipelineKey { ) -> PipelineKey {
let mut spec = let mut spec = self.authored_spec(
self.compatibility_spec(&declaration.name, layouts, &declaration.shader, format); layouts,
&declaration.shader,
format,
declaration.material,
declaration.double_sided,
);
spec.vertex.entry_point = declaration.vertex_entry.clone(); spec.vertex.entry_point = declaration.vertex_entry.clone();
spec.fragment spec.fragment
.as_mut() .as_mut()
.expect("scene pipelines have fragment stages") .expect("render-data pipelines have fragment stages")
.entry_point = declaration.fragment_entry.clone(); .entry_point = declaration.fragment_entry.clone();
spec.primitive.cull_mode = (!declaration.double_sided).then_some(wgpu::Face::Back);
self.get_or_create_from_spec(device, &spec, Some(&declaration.name)) self.get_or_create_from_spec(device, &spec, Some(&declaration.name))
} }
@@ -463,11 +468,12 @@ mod tests {
use super::*; use super::*;
fn spec() -> RenderPipelineSpec { fn spec() -> RenderPipelineSpec {
PipelineLibrary::new().compatibility_spec( PipelineLibrary::new().authored_spec(
"x",
&[], &[],
"shader", "shader",
wgpu::TextureFormat::Rgba8Unorm, wgpu::TextureFormat::Rgba8Unorm,
false,
false,
) )
} }
-537
View File
@@ -1,537 +0,0 @@
use std::{
collections::{HashMap, VecDeque},
sync::{Arc, Mutex},
};
use wasm_bindgen::JsValue;
// A frame can contain every logical execution as a singleton physical pass and
// one instance-traversal compute pass.
pub const MAX_PROFILE_PASSES: usize = crate::render_graph::MAX_EXECUTIONS + 1;
#[cfg(any(target_arch = "wasm32", test))]
const SLOT_COUNT: usize = 4;
#[cfg(any(target_arch = "wasm32", test))]
const QUERY_COUNT: u32 = (MAX_PROFILE_PASSES * 2) as u32;
#[cfg(any(target_arch = "wasm32", test))]
const USED_RESOLVE_SIZE: u64 = QUERY_COUNT as u64 * 8;
#[cfg(any(target_arch = "wasm32", test))]
const RESOLVE_SIZE: u64 = USED_RESOLVE_SIZE.next_multiple_of(wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SlotState {
Free,
Encoding,
Mapping,
}
struct Slot {
queries: wgpu::QuerySet,
resolve: wgpu::Buffer,
read: wgpu::Buffer,
state: SlotState,
}
struct Completion {
slot: usize,
epoch: u64,
ids: Vec<String>,
values: Option<Vec<u64>>,
}
pub(crate) struct ProfileFrame {
pub query_set: wgpu::QuerySet,
slot: usize,
identity: String,
ids: Vec<String>,
invalid: bool,
}
pub(crate) struct ProfileMap {
slot: usize,
epoch: u64,
ids: Vec<String>,
count: u32,
}
impl ProfileFrame {
fn allocate(&mut self, id: &str) -> Option<u32> {
allocate_id(&mut self.ids, &mut self.invalid, id)
}
pub fn render_writes(&mut self, id: &str) -> Option<wgpu::RenderPassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::RenderPassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
pub fn compute_writes(&mut self, id: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let first = self.allocate(id)?;
Some(wgpu::ComputePassTimestampWrites {
query_set: &self.query_set,
beginning_of_pass_write_index: Some(first),
end_of_pass_write_index: Some(first + 1),
})
}
}
pub(crate) struct Profiler {
enabled: bool,
available: bool,
slots: Vec<Slot>,
completions: Arc<Mutex<Vec<Completion>>>,
epoch: u64,
identity: String,
period_ns: f64,
samples: HashMap<String, VecDeque<(f64, f64)>>,
last_snapshot_ms: f64,
dropped: u64,
}
impl Profiler {
#[cfg(any(target_arch = "wasm32", test))]
pub fn requested_features(requested: bool, supported: wgpu::Features) -> wgpu::Features {
if requested && supported.contains(wgpu::Features::TIMESTAMP_QUERY) {
wgpu::Features::TIMESTAMP_QUERY
} else {
wgpu::Features::empty()
}
}
#[cfg(target_arch = "wasm32")]
pub async fn new(requested: bool, device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
let available = requested && device.features().contains(wgpu::Features::TIMESTAMP_QUERY);
let mut slots = Vec::new();
if available {
device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
device.push_error_scope(wgpu::ErrorFilter::Validation);
for _ in 0..SLOT_COUNT {
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("profile timestamps"),
ty: wgpu::QueryType::Timestamp,
count: QUERY_COUNT,
});
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("profile resolve"),
size: RESOLVE_SIZE,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("profile readback"),
size: RESOLVE_SIZE,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
slots.push(Slot {
queries,
resolve,
read,
state: SlotState::Free,
});
}
}
let allocation_failed = if available {
// Pop both scopes before yielding: WebGPU's scope stack must be unwound
// synchronously, even though completion of each pop is asynchronous.
let validation = device.pop_error_scope();
let oom = device.pop_error_scope();
let (validation, oom) = futures::join!(validation, oom);
validation.is_some() || oom.is_some()
} else {
false
};
if allocation_failed {
slots.clear();
}
Self {
enabled: requested,
available: available && !allocation_failed,
slots,
completions: Default::default(),
epoch: 0,
identity: String::new(),
period_ns: queue.get_timestamp_period() as f64,
samples: Default::default(),
last_snapshot_ms: 0.0,
dropped: 0,
}
}
pub fn begin(&mut self, identity: impl FnOnce() -> String) -> Option<ProfileFrame> {
self.drain();
let Some((slot, identity)) = profile_gate(self.enabled, self.available, || {
begin_transition(self.slots.iter_mut().map(|slot| &mut slot.state))
.map(|slot| (slot, identity()))
}) else {
if self.enabled && self.available {
self.dropped += 1;
}
return None;
};
Some(ProfileFrame {
query_set: self.slots[slot].queries.clone(),
slot,
identity,
ids: Vec::new(),
invalid: false,
})
}
pub fn cancel(&mut self, frame: ProfileFrame) {
cancel_state(&mut self.slots[frame.slot].state);
}
pub fn finish(
&mut self,
encoder: &mut wgpu::CommandEncoder,
frame: ProfileFrame,
) -> Option<ProfileMap> {
let count = match finish_transition(
&mut self.slots[frame.slot].state,
frame.invalid,
frame.ids.len(),
) {
FinishAction::Cancel => return None,
FinishAction::Resolve(count) => count,
};
if frame.identity != self.identity {
self.identity = frame.identity;
self.epoch = self.epoch.wrapping_add(1);
self.samples.clear();
}
let slot = frame.slot;
let epoch = self.epoch;
let ids = frame.ids;
encoder.resolve_query_set(
&self.slots[slot].queries,
0..count,
&self.slots[slot].resolve,
0,
);
encoder.copy_buffer_to_buffer(
&self.slots[slot].resolve,
0,
&self.slots[slot].read,
0,
count as u64 * 8,
);
Some(ProfileMap {
slot,
epoch,
ids,
count,
})
}
pub fn map(&mut self, request: ProfileMap) {
let ProfileMap {
slot,
epoch,
ids,
count,
} = request;
self.slots[slot].state = SlotState::Mapping;
let buffer = self.slots[slot].read.clone();
let completions = self.completions.clone();
buffer
.clone()
.slice(..count as u64 * 8)
.map_async(wgpu::MapMode::Read, move |result| {
let values = result.ok().map(|_| {
let bytes = buffer.slice(..count as u64 * 8).get_mapped_range();
let values = bytes
.chunks_exact(8)
.map(|x| u64::from_le_bytes(x.try_into().unwrap()))
.collect();
drop(bytes);
buffer.unmap();
values
});
if let Ok(mut completions) = completions.lock() {
completions.push(Completion {
slot,
epoch,
ids,
values,
});
}
});
}
fn drain(&mut self) {
let completions = if let Ok(mut queue) = self.completions.lock() {
queue.drain(..).collect::<Vec<_>>()
} else {
return;
};
for c in completions {
let Some(v) = completion_transition(
&mut self.slots[c.slot].state,
c.values,
c.epoch,
self.epoch,
&mut self.available,
&mut self.samples,
) else {
continue;
};
for (id, pair) in c.ids.into_iter().zip(v.chunks_exact(2)) {
if let Some(ms) = validate(pair[0], pair[1], self.period_ns) {
let q = self.samples.entry(id).or_default();
q.push_back((js_sys::Date::now(), ms));
}
}
}
}
pub fn snapshot_json(&mut self, now: f64) -> Option<JsValue> {
self.drain();
profile_gate(self.enabled, self.available, || Some(()))?;
(now - self.last_snapshot_ms >= 250.0).then_some(())?;
self.last_snapshot_ms = now;
let cutoff = now - 1000.0;
let mut passes = serde_json::Map::new();
for (id, q) in &mut self.samples {
while q.front().is_some_and(|x| x.0 < cutoff) {
q.pop_front();
}
if !q.is_empty() {
passes.insert(
id.clone(),
serde_json::json!(q.iter().map(|x| x.1).sum::<f64>() / q.len() as f64),
);
}
}
let value = serde_json::json!({"type":"profile-snapshot","requested":self.enabled,"available":self.available,"epoch":self.epoch,"graph":self.identity,"passes":passes,"dropped":self.dropped});
js_sys::JSON::parse(&value.to_string()).ok()
}
}
fn allocate_id(ids: &mut Vec<String>, invalid: &mut bool, id: &str) -> Option<u32> {
if ids.len() >= MAX_PROFILE_PASSES {
*invalid = true;
return None;
}
let first = ids.len() as u32 * 2;
ids.push(id.to_owned());
Some(first)
}
fn profile_gate<T>(enabled: bool, available: bool, f: impl FnOnce() -> Option<T>) -> Option<T> {
(enabled && available).then(f).flatten()
}
fn begin_transition<'a>(states: impl IntoIterator<Item = &'a mut SlotState>) -> Option<usize> {
for (slot, state) in states.into_iter().enumerate() {
if *state == SlotState::Free {
*state = SlotState::Encoding;
return Some(slot);
}
}
None
}
#[derive(Debug, PartialEq, Eq)]
enum FinishAction {
Cancel,
Resolve(u32),
}
fn finish_transition(state: &mut SlotState, invalid: bool, id_count: usize) -> FinishAction {
if *state != SlotState::Encoding || invalid || id_count == 0 {
cancel_state(state);
FinishAction::Cancel
} else {
FinishAction::Resolve(id_count as u32 * 2)
}
}
fn completion_transition(
state: &mut SlotState,
values: Option<Vec<u64>>,
completion_epoch: u64,
current_epoch: u64,
available: &mut bool,
samples: &mut HashMap<String, VecDeque<(f64, f64)>>,
) -> Option<Vec<u64>> {
*state = SlotState::Free;
match values {
None => {
*available = false;
samples.clear();
None
}
Some(_) if !*available || completion_epoch != current_epoch => None,
Some(values) => Some(values),
}
}
fn cancel_state(state: &mut SlotState) {
if *state == SlotState::Encoding {
*state = SlotState::Free;
}
}
fn validate(start: u64, end: u64, period: f64) -> Option<f64> {
if (start == 0 && end == 0) || end < start || !period.is_finite() || period <= 0.0 {
return None;
}
let ms = (end - start) as f64 * period / 1_000_000.0;
if ms.is_finite() && ms >= 0.0 && ms <= 1000.0 {
Some(ms)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_gate() {
assert!(Profiler::requested_features(false, wgpu::Features::TIMESTAMP_QUERY).is_empty());
assert!(Profiler::requested_features(true, wgpu::Features::empty()).is_empty());
assert_eq!(
Profiler::requested_features(true, wgpu::Features::TIMESTAMP_QUERY),
wgpu::Features::TIMESTAMP_QUERY
)
}
#[test]
fn validation() {
assert_eq!(validate(1, 2, 1_000_000.0), Some(1.0));
assert_eq!(validate(2, 2, 1.0), Some(0.0));
assert_eq!(validate(0, 0, 1.0), None);
assert_eq!(validate(2, 1, 1.0), None);
assert_eq!(validate(0, 1, 1_000_000_000.0), Some(1000.0));
assert_eq!(validate(0, 2, 1_000_000_000.0), None);
assert_eq!(validate(1, 2, f64::NAN), None);
assert_eq!(validate(1, 2, f64::INFINITY), None);
assert_eq!(validate(1, 2, 0.0), None);
assert_eq!(validate(1, 2, -1.0), None);
}
#[test]
fn capacity_is_aligned() {
assert_eq!(RESOLVE_SIZE % wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT, 0);
assert!(RESOLVE_SIZE >= USED_RESOLVE_SIZE);
assert!(RESOLVE_SIZE - USED_RESOLVE_SIZE < wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT);
assert_eq!(QUERY_COUNT as usize, MAX_PROFILE_PASSES * 2)
}
#[test]
fn lazy_identity_when_disabled_unavailable_or_full() {
let mut calls = 0;
for (enabled, available) in [(false, true), (true, false)] {
assert_eq!(
profile_gate(enabled, available, || {
calls += 1;
Some(7)
}),
None
);
}
assert_eq!(calls, 0);
let mut full = [SlotState::Mapping; SLOT_COUNT];
assert_eq!(
profile_gate(true, true, || {
begin_transition(&mut full).map(|slot| {
calls += 1;
slot
})
}),
None
);
assert_eq!(calls, 0);
assert_eq!(full, [SlotState::Mapping; SLOT_COUNT]);
let mut states = [SlotState::Mapping, SlotState::Free];
assert_eq!(
profile_gate(true, true, || {
begin_transition(&mut states).map(|slot| {
calls += 1;
slot
})
}),
Some(1)
);
assert_eq!(calls, 1);
assert_eq!(states, [SlotState::Mapping, SlotState::Encoding]);
}
#[test]
fn compact_ids_and_query_pairs_resolve_four() {
let mut ids = Vec::new();
let mut invalid = false;
assert_eq!(allocate_id(&mut ids, &mut invalid, "a"), Some(0));
assert_eq!(allocate_id(&mut ids, &mut invalid, "b"), Some(2));
assert_eq!(ids, ["a", "b"]);
let mut state = SlotState::Encoding;
assert_eq!(
finish_transition(&mut state, invalid, ids.len()),
FinishAction::Resolve(4)
);
assert!(!invalid);
}
#[test]
fn capacity_overflow_marks_frame_invalid() {
let mut ids = (0..MAX_PROFILE_PASSES).map(|i| i.to_string()).collect();
let mut invalid = false;
assert_eq!(allocate_id(&mut ids, &mut invalid, "overflow"), None);
assert!(invalid);
assert_eq!(ids.len(), MAX_PROFILE_PASSES);
let mut overflow = SlotState::Encoding;
assert_eq!(
finish_transition(&mut overflow, invalid, ids.len()),
FinishAction::Cancel
);
assert_eq!(overflow, SlotState::Free);
let mut empty = SlotState::Encoding;
assert_eq!(
finish_transition(&mut empty, false, 0),
FinishAction::Cancel
);
assert_eq!(empty, SlotState::Free);
}
#[test]
fn repeated_cancel_and_mapping_guard() {
let mut encoding = SlotState::Encoding;
for _ in 0..=SLOT_COUNT {
cancel_state(&mut encoding);
assert_eq!(encoding, SlotState::Free);
encoding = SlotState::Encoding;
}
let mut mapping = SlotState::Mapping;
cancel_state(&mut mapping);
assert_eq!(mapping, SlotState::Mapping);
let mut free = SlotState::Free;
cancel_state(&mut free);
assert_eq!(free, SlotState::Free);
}
#[test]
fn stale_epoch_map_failure_is_terminal_and_prevents_later_aggregation() {
let mut state = SlotState::Mapping;
let mut available = true;
let mut samples = HashMap::from([
("a".into(), VecDeque::from([(1.0, 2.0)])),
("b".into(), VecDeque::from([(3.0, 4.0)])),
]);
assert_eq!(
completion_transition(&mut state, None, 1, 2, &mut available, &mut samples),
None
);
assert_eq!(state, SlotState::Free);
assert!(!available);
assert!(samples.is_empty());
for epoch in [2, 1] {
state = SlotState::Mapping;
assert_eq!(
completion_transition(
&mut state,
Some(vec![1, 2]),
epoch,
2,
&mut available,
&mut samples
),
None
);
assert_eq!(state, SlotState::Free);
assert!(samples.is_empty());
}
}
#[test]
fn snapshot_gate_is_silent_when_disabled_or_unavailable() {
for enabled in [false, true] {
for available in [false, true] {
assert_eq!(
profile_gate(enabled, available, || Some("snapshot")),
(enabled && available).then_some("snapshot")
);
}
}
}
}
-130
View File
@@ -1,130 +0,0 @@
use wgpu::util::DeviceExt;
use crate::{
camera::Camera,
render_data::RenderData,
renderer::{self, PipelineLibrary},
};
pub struct UniformResource {
pub buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
pub bind_group_layout: wgpu::BindGroupLayout,
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
pub struct FrameMetadata {
pub mouse_move: [f32; 2],
pub mouse_click: [f32; 2],
pub resolution: [f32; 2],
time: f32,
_padding0: f32,
pub camera_position: [f32; 4],
}
impl FrameMetadata {
pub fn new(dimension: ultraviolet::Vec2) -> Self {
Self {
resolution: dimension.into(),
mouse_move: [f32::MIN; 2],
mouse_click: [f32::MIN; 2],
camera_position: [0., 0., 0., 1.],
..Default::default()
}
}
pub fn set_camera_position(&mut self, p: ultraviolet::Vec3) {
self.camera_position = [p.x, p.y, p.z, 1.];
}
pub fn update_dimension(&mut self, d: ultraviolet::Vec2) {
self.resolution = d.into();
}
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("frame metadata"),
contents: bytemuck::bytes_of(&self),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("frame layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("frame group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
}
pub trait Scene: Sized {
fn setup(
context: &renderer::RendererContext,
resources: &mut PipelineLibrary,
data: &mut RenderData,
) -> Self;
fn bind_groups(&self) -> &[wgpu::BindGroup];
fn handle_mouse_click(&mut self, x: f32, y: f32);
fn handle_zoom(&mut self, delta_y: f32);
fn handle_orbit(&mut self, dx: f32, dy: f32);
fn handle_pan(&mut self, dx: f32, dy: f32, viewport_height: f32);
fn set_camera_depth_range(&mut self, near: f32, far: f32);
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
None
}
fn camera_mut(&mut self) -> Option<&mut Camera> {
None
}
fn frustum_planes(&mut self) -> Option<Result<[[f32; 4]; 6], crate::camera::FrustumError>> {
self.camera_mut().map(|camera| camera.frustum_planes())
}
fn uniform_buffers(&self) -> Option<[&wgpu::Buffer; 2]> {
None
}
fn resize(&mut self, width: f64, height: f64, _: f64, queue: &wgpu::Queue) {
if let Some(f) = self.frame_metadata_mut() {
f.update_dimension(ultraviolet::Vec2::new(width as f32, height as f32));
}
if let Some(c) = self.camera_mut() {
c.update_aspect_ratio(width as f32 / height as f32)
}
self.write_uniforms(queue);
}
fn update_cpu(&mut self) {
let position = match self.camera_mut() {
Some(c) => c.position(),
None => return,
};
if let Some(f) = self.frame_metadata_mut() {
f.time = js_sys::Date::now() as f32 * 0.001;
f.set_camera_position(position)
}
}
fn write_uniforms(&mut self, queue: &wgpu::Queue) {
let frame = self.frame_metadata_mut().copied();
let view = self.camera_mut().map(|c| c.view_proj);
if let (Some(f), Some(v), Some([frame_buffer, camera_buffer])) =
(frame, view, self.uniform_buffers())
{
queue.write_buffer(frame_buffer, 0, bytemuck::bytes_of(&f));
queue.write_buffer(camera_buffer, 0, bytemuck::bytes_of(&v));
}
}
}
+232 -7
View File
@@ -8,7 +8,11 @@ use std::{
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::render_data::{InstanceHandle, RenderData, RenderDataCapacities}; use crate::render_data::{
camera::Camera,
upload::{Material, MaterialState},
InstanceHandle, MaterialKey, RenderData, RenderDataCapacities,
};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSOA"); pub const MAGIC: u32 = u32::from_le_bytes(*b"YSOA");
pub const VERSION: u32 = 1; pub const VERSION: u32 = 1;
@@ -344,6 +348,8 @@ fn valid_name(value: &str) -> bool {
pub struct SharedSoaRegistry { pub struct SharedSoaRegistry {
arrays: BTreeMap<String, SharedArray>, arrays: BTreeMap<String, SharedArray>,
next_id: u32, next_id: u32,
layout_changed: bool,
material_keys: Vec<MaterialKey>,
published_instance_generations: BTreeMap<u32, u32>, published_instance_generations: BTreeMap<u32, u32>,
published_mesh_generations: BTreeMap<u32, u32>, published_mesh_generations: BTreeMap<u32, u32>,
} }
@@ -353,6 +359,8 @@ impl SharedSoaRegistry {
let mut registry = Self { let mut registry = Self {
arrays: BTreeMap::new(), arrays: BTreeMap::new(),
next_id: 1, next_id: 1,
layout_changed: false,
material_keys: Vec::new(),
published_instance_generations: BTreeMap::new(), published_instance_generations: BTreeMap::new(),
published_mesh_generations: BTreeMap::new(), published_mesh_generations: BTreeMap::new(),
}; };
@@ -389,9 +397,28 @@ impl SharedSoaRegistry {
stride: Some(16), stride: Some(16),
length: None, length: None,
}, },
ArrayRequest {
name: "camera.state".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::F32,
lanes: 16,
stride: Some(64),
length: Some(1),
},
ArrayRequest {
name: "material.state".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::U32,
lanes: MaterialState::LANES,
stride: Some(112),
length: Some(1),
},
] { ] {
registry.allocate(request, capacities)?; registry.allocate(request, capacities)?;
} }
registry.publish_materials(&[Material::default()])?;
registry.material_keys.clear();
registry.layout_changed = false;
Ok(registry) Ok(registry)
} }
@@ -447,7 +474,7 @@ impl SharedSoaRegistry {
return Err(SharedSoaError::LayoutConflict); return Err(SharedSoaError::LayoutConflict);
} }
if request.domain == ArrayDomain::Fixed { if request.domain == ArrayDomain::Fixed {
existing.resize(capacity)?; self.layout_changed |= existing.resize(capacity)?;
} }
return existing.descriptor(); return existing.descriptor();
} }
@@ -460,6 +487,7 @@ impl SharedSoaRegistry {
let array = SharedArray::new(id, request, stride / 4, capacity)?; let array = SharedArray::new(id, request, stride / 4, capacity)?;
let descriptor = array.descriptor()?; let descriptor = array.descriptor()?;
self.arrays.insert(name, array); self.arrays.insert(name, array);
self.layout_changed = true;
Ok(descriptor) Ok(descriptor)
} }
@@ -514,12 +542,132 @@ impl SharedSoaRegistry {
Ok(bytes) Ok(bytes)
} }
/// Publish an infrequent worker-owned camera reset into the canonical shared row.
pub fn publish_camera(&mut self, camera: &Camera) -> Result<(), SharedSoaError> {
let array = self
.arrays
.get_mut("camera.state")
.ok_or(SharedSoaError::UnknownArray)?;
let sequence = array.try_lock().ok_or(SharedSoaError::Busy)?;
for (lane, value) in camera.shared_state().into_iter().enumerate() {
array
.data_word(0, lane as u32)
.store(value.to_bits(), Ordering::Relaxed);
}
array.unlock(sequence);
Ok(())
}
/// Apply a newly published shared camera row. Invalid external rows are replaced
/// with the last valid worker state so all writers can recover on their next read.
pub fn synchronize_camera(&mut self, camera: &mut Camera) -> Result<(), SharedSoaError> {
let Some(array) = self.arrays.get_mut("camera.state") else {
return Err(SharedSoaError::UnknownArray);
};
if !array.changed() {
return Ok(());
}
let sequence = array.try_lock().ok_or(SharedSoaError::Busy)?;
let state = std::array::from_fn(|lane| {
f32::from_bits(array.data_word(0, lane as u32).load(Ordering::Acquire))
});
array.unlock(sequence);
if !camera.apply_shared_state(state) {
self.publish_camera(camera)?;
}
Ok(())
}
/// Replaces the packed material rows after a transactional render-data upload.
pub fn publish_materials(&mut self, materials: &[Material]) -> Result<(), SharedSoaError> {
let length = materials
.iter()
.map(|material| material.key.get())
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or(SharedSoaError::SizeOverflow)?;
self.allocate(
ArrayRequest {
name: "material.state".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::U32,
lanes: MaterialState::LANES,
stride: Some(112),
length: Some(length),
},
RenderDataCapacities {
vertices: 0,
indices: 0,
meshes: 0,
instances: 0,
},
)?;
let array = self
.arrays
.get_mut("material.state")
.ok_or(SharedSoaError::UnknownArray)?;
let sequence = (0..1024)
.find_map(|_| array.try_lock())
.ok_or(SharedSoaError::Busy)?;
let fallback = MaterialState::from(&Material::default()).words();
for slot in 0..length {
for (lane, word) in fallback.iter().copied().enumerate() {
array
.data_word(slot, lane as u32)
.store(word, Ordering::Relaxed);
}
}
for material in materials {
for (lane, word) in MaterialState::from(material)
.words()
.into_iter()
.enumerate()
{
array
.data_word(material.key.get(), lane as u32)
.store(word, Ordering::Relaxed);
}
}
array.unlock(sequence);
self.material_keys = materials.iter().map(|material| material.key).collect();
self.material_keys.sort_by_key(|key| key.get());
self.material_keys.dedup();
Ok(())
}
/// Takes complete changed material rows for one batched queue write per material.
pub fn take_material_words(
&mut self,
) -> Option<Vec<(MaterialKey, [u32; MaterialState::LANES as usize])>> {
let array = self.arrays.get_mut("material.state")?;
if !array.changed() {
return None;
}
let sequence = array.try_lock()?;
let rows = self
.material_keys
.iter()
.copied()
.map(|key| {
let words = std::array::from_fn(|lane| {
array
.data_word(key.get(), lane as u32)
.load(Ordering::Acquire)
});
(key, words)
})
.collect();
array.unlock(sequence);
Some(rows)
}
/// Reallocates matching-domain columns before a frame. Old blocks stay pinned. /// Reallocates matching-domain columns before a frame. Old blocks stay pinned.
pub fn sync_capacities( pub fn sync_capacities(
&mut self, &mut self,
capacities: RenderDataCapacities, capacities: RenderDataCapacities,
) -> Result<bool, SharedSoaError> { ) -> Result<bool, SharedSoaError> {
let mut changed = false; let mut changed = std::mem::take(&mut self.layout_changed);
for array in self.arrays.values_mut() { for array in self.arrays.values_mut() {
if array.request.domain == ArrayDomain::Fixed { if array.request.domain == ArrayDomain::Fixed {
continue; continue;
@@ -718,6 +866,83 @@ mod tests {
} }
} }
#[test]
fn camera_is_one_aligned_row_and_external_updates_are_validated() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let descriptor = registry.arrays["camera.state"].descriptor().unwrap();
assert_eq!(descriptor.domain, ArrayDomain::Fixed);
assert_eq!(descriptor.scalar, ScalarType::F32);
assert_eq!(
(descriptor.lanes, descriptor.stride, descriptor.length),
(16, 64, 1)
);
let mut camera = Camera::new(1.5);
registry.publish_camera(&camera).unwrap();
let mut external = camera.shared_state();
external[0..3].copy_from_slice(&[2.0, 1.0, 4.0]);
external[13] = 2.0;
let array = registry.arrays.get_mut("camera.state").unwrap();
let sequence = array.word(9).load(Ordering::Acquire);
array.word(9).store(sequence + 1, Ordering::Release);
for (lane, value) in external.into_iter().enumerate() {
array
.data_word(0, lane as u32)
.store(value.to_bits(), Ordering::Relaxed);
}
array.word(9).store(sequence + 2, Ordering::Release);
registry.synchronize_camera(&mut camera).unwrap();
assert_eq!(camera.shared_state(), external);
let valid = camera.shared_state();
let array = registry.arrays.get_mut("camera.state").unwrap();
let sequence = array.word(9).load(Ordering::Acquire);
array.word(9).store(sequence + 1, Ordering::Release);
array
.data_word(0, 13)
.store(f32::NAN.to_bits(), Ordering::Relaxed);
array.word(9).store(sequence + 2, Ordering::Release);
registry.synchronize_camera(&mut camera).unwrap();
let recovered = registry.arrays["camera.state"].data_word(0, 13);
assert_eq!(f32::from_bits(recovered.load(Ordering::Acquire)), valid[13]);
}
#[test]
fn material_rows_are_packed_resized_and_consumed_after_external_writes() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let mut material = Material {
key: MaterialKey::new(3),
..Material::default()
};
material.base_color_factor = [0.2, 0.4, 0.8, 1.0];
material.roughness_factor = 0.75;
registry.publish_materials(&[material.clone()]).unwrap();
let descriptor = registry.arrays["material.state"].descriptor().unwrap();
assert_eq!(
(
descriptor.scalar,
descriptor.lanes,
descriptor.stride,
descriptor.length
),
(ScalarType::U32, 28, 112, 4)
);
let array = registry.arrays.get_mut("material.state").unwrap();
let sequence = array.word(9).load(Ordering::Acquire);
array.word(9).store(sequence + 1, Ordering::Release);
array
.data_word(3, 9)
.store(0.25f32.to_bits(), Ordering::Relaxed);
array.word(9).store(sequence + 2, Ordering::Release);
let rows = registry.take_material_words().unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, MaterialKey::new(3));
assert_eq!(f32::from_bits(rows[0].1[9]), 0.25);
assert!(registry.take_material_words().is_none());
}
#[test] #[test]
fn custom_layouts_are_aligned_idempotent_and_conflict_checked() { fn custom_layouts_are_aligned_idempotent_and_conflict_checked() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap(); let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
@@ -761,7 +986,7 @@ mod tests {
fn fixed_arrays_grow_and_publish_stable_byte_uploads() { fn fixed_arrays_grow_and_publish_stable_byte_uploads() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap(); let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let request = |length| ArrayRequest { let request = |length| ArrayRequest {
name: "upload.gltf".into(), name: "upload.renderData".into(),
domain: ArrayDomain::Fixed, domain: ArrayDomain::Fixed,
scalar: ScalarType::U32, scalar: ScalarType::U32,
lanes: 4, lanes: 4,
@@ -773,18 +998,18 @@ mod tests {
assert_eq!(first.id, second.id); assert_eq!(first.id, second.id);
assert_eq!((second.length, second.capacity), (2, 2)); assert_eq!((second.length, second.capacity), (2, 2));
let array = registry.arrays.get_mut("upload.gltf").unwrap(); let array = registry.arrays.get_mut("upload.renderData").unwrap();
let sequence = array.try_lock().unwrap(); let sequence = array.try_lock().unwrap();
array array
.data_word(0, 0) .data_word(0, 0)
.store(u32::from_le_bytes(*b"glTF"), Ordering::Relaxed); .store(u32::from_le_bytes(*b"YRDP"), Ordering::Relaxed);
array array
.data_word(0, 1) .data_word(0, 1)
.store(u32::from_le_bytes([2, 0, 0, 0]), Ordering::Relaxed); .store(u32::from_le_bytes([2, 0, 0, 0]), Ordering::Relaxed);
array.unlock(sequence); array.unlock(sequence);
assert_eq!( assert_eq!(
registry.read_fixed_bytes(first.id, 8).unwrap(), registry.read_fixed_bytes(first.id, 8).unwrap(),
b"glTF\x02\0\0\0" b"YRDP\x02\0\0\0"
); );
} }
+1 -1
View File
@@ -32,7 +32,7 @@ dir = "my-template"
#! When there is only `name`, other fields will use the default configuration #! When there is only `name`, other fields will use the default configuration
[[crates]] [[crates]]
name = "level-editor" name = "renderer"
root = "." root = "."
out-dir = "pkg" out-dir = "pkg"
target = "web" target = "web"
+9 -13
View File
@@ -12,13 +12,15 @@ import {
createVelocityColumn, createVelocityColumn,
customRenderPipelineExample, customRenderPipelineExample,
defaultPipelineExample, defaultPipelineExample,
dropActiveGraph,
fluentGraphExample, fluentGraphExample,
fxNodeExportExample, fxNodeExportExample,
importGltf, importGltf,
installCameraRenderDataControls,
jsoGraphExample, jsoGraphExample,
loadCompleteScene, loadCompleteScene,
pickNearest, pickNearest,
conventionalSceneHandles,
restyleScene,
setVelocity, setVelocity,
simpleSceneShader, simpleSceneShader,
updateInstance, updateInstance,
@@ -63,7 +65,7 @@ test("cookbook graph recipes produce canonical addon-owned ASTs", () => {
); );
}); });
test("cookbook graph lifecycle recipe serializes, switches, and drops", async () => { test("cookbook graph lifecycle recipe serializes and switches", async () => {
const calls = []; const calls = [];
const core = { const core = {
compileGraph(source) { compileGraph(source) {
@@ -74,10 +76,6 @@ test("cookbook graph lifecycle recipe serializes, switches, and drops", async ()
calls.push(["switch", id]); calls.push(["switch", id]);
return Promise.resolve(); return Promise.resolve();
}, },
switchToImmediate() {
calls.push(["immediate"]);
return Promise.resolve();
},
dropCompiledGraph(id) { dropCompiledGraph(id) {
calls.push(["drop", id]); calls.push(["drop", id]);
return Promise.resolve(); return Promise.resolve();
@@ -85,13 +83,8 @@ test("cookbook graph lifecycle recipe serializes, switches, and drops", async ()
}; };
const graph = { id: "lifecycle", revision: 1, nodes: [] }; const graph = { id: "lifecycle", revision: 1, nodes: [] };
const compiled = await compileAndSwitch(core, graph); const compiled = await compileAndSwitch(core, graph);
await dropActiveGraph(core, compiled);
assert.match(calls[0][1], /^\(yawn-graph 1/); assert.match(calls[0][1], /^\(yawn-graph 1/);
assert.deepEqual(calls.slice(1), [ assert.deepEqual(calls.slice(1), [["switch", [3, 4]]]);
["switch", [3, 4]],
["immediate"],
["drop", [3, 4]],
]);
}); });
test("cookbook mutation recipes use handles and SOA writes directly", async () => { test("cookbook mutation recipes use handles and SOA writes directly", async () => {
@@ -163,7 +156,7 @@ test("cookbook picking and worker-to-worker recipes use public facades", async (
[0, 0, 0], [0, 0, 0],
[0, 0, -1], [0, 0, -1],
); );
assert.deepEqual(picked.hits[0].instance.handle, [9, 3]); assert.deepEqual(picked.hits[0].instance, [9, 3]);
class Port extends EventTarget { class Port extends EventTarget {
postMessage() {} postMessage() {}
@@ -183,5 +176,8 @@ test("cookbook picking and worker-to-worker recipes use public facades", async (
core.dispose(); core.dispose();
assert.equal(typeof importGltf, "function"); assert.equal(typeof importGltf, "function");
assert.equal(typeof installCameraRenderDataControls, "function");
assert.equal(typeof conventionalSceneHandles, "function");
assert.equal(typeof restyleScene, "function");
assert.equal(typeof loadCompleteScene, "function"); assert.equal(typeof loadCompleteScene, "function");
}); });
+1 -1
View File
@@ -6,6 +6,6 @@ function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getU
test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}}); test("procedural cube and sphere have complete indexed vertex streams",()=>{const cube=createCubeGeometry(),sphere=createUvSphereGeometry();assert.deepEqual([cube.positions.length/3,cube.indices.length],[24,36]);assert.ok(sphere.positions.length/3>300);for(const geometry of [cube,sphere]){assert.equal(geometry.normals.length,geometry.positions.length);assert.equal(geometry.texcoords.length,geometry.positions.length/3*2);assert.ok(geometry.indices.every(i=>i>=0&&i<geometry.positions.length/3));}});
test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}}); test("every cube triangle has counter-clockwise outward winding",()=>{const g=createCubeGeometry();for(let i=0;i<g.indices.length;i+=3){const ids=g.indices.slice(i,i+3),p=ids.map(id=>g.positions.slice(id*3,id*3+3)),a=p[1].map((v,j)=>v-p[0][j]),b=p[2].map((v,j)=>v-p[0][j]),cross=[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]],normal=g.normals.slice(ids[0]*3,ids[0]*3+3);assert.ok(cross.reduce((sum,v,j)=>sum+v*normal[j],0)>0)}});
test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}}); test("loadouts return fresh deterministic GLBs with one mesh and nine nodes",async()=>{for(const name of ["cubes","spheres"]){const a=await loadDemoLoadout(name),b=await loadDemoLoadout(name);assert.notStrictEqual(a,b);assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.meshes.length,1);assert.equal(json.nodes.length,9);assert.ok(json.nodes.every(n=>n.mesh===0));assert.deepEqual(json.meshes[0].primitives[0].attributes,{POSITION:0,NORMAL:1,TEXCOORD_0:2});assert.equal(json.accessors[3].componentType,5125);}});
test("Phase 6 gallery is deterministic and covers core PBR shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-phase6-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"PBR material gallery");}); test("PBR gallery is deterministic and covers the default material shader semantics",async()=>{const a=createMaterialGalleryGlb(),b=await loadDemoLoadout("materials");assert.deepEqual(new Uint8Array(a),new Uint8Array(b));const json=parseGlb(a);assert.equal(json.asset.generator,"yawn-pbr-gallery");assert.deepEqual(json.extensionsUsed,["KHR_materials_ior"]);assert.equal(json.materials.length,16);assert.equal(json.nodes.length,16);assert.equal(json.images.length,3);assert.ok(json.images.every(image=>image.mimeType==="image/png"&&image.bufferView!==undefined));assert.ok(json.meshes.every(mesh=>JSON.stringify(mesh.primitives[0].attributes)===JSON.stringify({POSITION:0,NORMAL:1,TEXCOORD_0:2})));assert.deepEqual(json.materials.slice(0,4).map(x=>x.pbrMetallicRoughness.roughnessFactor),[.08,.3,.6,1]);assert.ok(json.materials.slice(0,4).every(x=>x.pbrMetallicRoughness.metallicFactor===0));assert.ok(json.materials.slice(4,8).every(x=>x.pbrMetallicRoughness.metallicFactor===1));assert.deepEqual(json.materials.slice(8,11).map(x=>x.extensions.KHR_materials_ior.ior),[1,1.5,2]);assert.equal(json.materials[11].normalTexture.index,2);assert.equal(json.materials[12].occlusionTexture.index,1);assert.equal(json.materials[13].emissiveTexture.index,0);assert.equal(json.materials[14].alphaMode,"MASK");assert.equal(json.materials[15].doubleSided,true);assert.ok(json.nodes[15].scale[0]<0);assert.equal(loadouts.materials.label,"PBR material gallery");});
test("the example offers only self-contained procedural scenes",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../examples/render-graph-studio/index.html",import.meta.url),"utf8");assert.deepEqual(Object.keys(loadouts),["cubes","spheres","materials"]);for(const id of Object.keys(loadouts))assert.match(html,new RegExp(`<option value="${id}">`));await assert.rejects(loadDemoLoadout("unknown"),RangeError);}); test("the example offers only self-contained procedural scenes",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../examples/render-graph-studio/index.html",import.meta.url),"utf8");assert.deepEqual(Object.keys(loadouts),["cubes","spheres","materials"]);for(const id of Object.keys(loadouts))assert.match(html,new RegExp(`<option value="${id}">`));await assert.rejects(loadDemoLoadout("unknown"),RangeError);});
test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)}); test("GLB encoder rejects non-finite and out-of-range geometry",()=>{const valid=createCubeGeometry();assert.throws(()=>encodeGeometryGlb({...valid,positions:[...valid.positions.slice(0,-1),NaN]}),/Invalid/);assert.throws(()=>encodeGeometryGlb({...valid,indices:[...valid.indices,0,1,999]}),/Invalid/)});
+25 -5
View File
@@ -2,7 +2,9 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { GltfImporter } from "@yawn/gltf-import"; import { GltfImporter } from "@yawn/gltf-import";
import { gltfToRenderDataPacket } from "../addons/gltf-import/src/gltf.js";
import { writeSharedUpload } from "../addons/gltf-import/src/shared-upload.js"; import { writeSharedUpload } from "../addons/gltf-import/src/shared-upload.js";
import { createCubeGeometry, encodeGeometryGlb } from "../examples/render-graph-studio/demo-loadouts.js";
class WorkerMock extends EventTarget { class WorkerMock extends EventTarget {
messages = []; messages = [];
@@ -18,7 +20,7 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
const memory = new SharedArrayBuffer(4096); const memory = new SharedArrayBuffer(4096);
const descriptor = { const descriptor = {
id: 9, id: 9,
name: "upload.gltf", name: "upload.renderData",
domain: "fixed", domain: "fixed",
scalar: "u32", scalar: "u32",
lanes: 4, lanes: 4,
@@ -36,8 +38,8 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
const calls = []; const calls = [];
const core = { const core = {
async allocateArray(layout) { calls.push(["allocate", layout]); return array; }, async allocateArray(layout) { calls.push(["allocate", layout]); return array; },
async commitGlbUpload(value, byteLength, options) { async commitRenderDataUpload(value, byteLength) {
calls.push(["commit", value, byteLength, options]); calls.push(["commit", value, byteLength]);
return { meshes: [] }; return { meshes: [] };
}, },
}; };
@@ -54,7 +56,7 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
worker.reply({ type: "allocate", request: 1, byteLength: 20 }); worker.reply({ type: "allocate", request: 1, byteLength: 20 });
await tick(); await tick();
assert.deepEqual(calls[0], ["allocate", { assert.deepEqual(calls[0], ["allocate", {
name: "upload.gltf", domain: "fixed", scalar: "u32", lanes: 4, stride: 16, length: 2, name: "upload.renderData", domain: "fixed", scalar: "u32", lanes: 4, stride: 16, length: 2,
}]); }]);
assert.equal(worker.messages[1].buffer, memory); assert.equal(worker.messages[1].buffer, memory);
assert.equal(worker.messages[1].descriptor, descriptor); assert.equal(worker.messages[1].descriptor, descriptor);
@@ -65,7 +67,25 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
worker.reply({ type: "ready", request: 1, byteLength: bytes.byteLength }); worker.reply({ type: "ready", request: 1, byteLength: bytes.byteLength });
assert.deepEqual(await loading, { meshes: [] }); assert.deepEqual(await loading, { meshes: [] });
assert.deepEqual(new Uint8Array(memory, 64, 20), bytes); assert.deepEqual(new Uint8Array(memory, 64, 20), bytes);
assert.deepEqual(calls[1], ["commit", array, 20, { framing: "interior" }]); assert.deepEqual(calls[1], ["commit", array, 20]);
importer.dispose(); importer.dispose();
assert.equal(worker.terminated, true); assert.equal(worker.terminated, true);
}); });
test("glTF parsing produces a format-neutral typed render-data packet in the addon", async () => {
const glb = encodeGeometryGlb(createCubeGeometry());
const packet = await gltfToRenderDataPacket(
new Uint8Array(glb),
"https://example.test/scene.glb",
);
const header = new DataView(packet.buffer, packet.byteOffset, packet.byteLength);
assert.equal(header.getUint32(0, true), 0x50445259);
assert.equal(header.getUint32(4, true), 1);
const metadataLength = header.getUint32(8, true);
const payloadLength = header.getUint32(12, true);
const metadata = JSON.parse(new TextDecoder().decode(packet.subarray(16, 16 + metadataLength)));
assert.equal(metadata.geometries.length, 1);
assert.equal(metadata.occurrences.length, 9);
assert.deepEqual(metadata.geometries[0].instanceType.slice(0, 2), [5, 0]);
assert.equal(((16 + metadataLength + 3) & ~3) + payloadLength, packet.byteLength);
});
+8 -5
View File
@@ -1,7 +1,8 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { SnapshotReader, SnapshotProtocolError } from "../packages/yawn-core/src/snapshot.js"; import { SnapshotReader, SnapshotProtocolError } from "../addons/mesh-handles/src/snapshot.js";
import { DerivedBvh } from "../addons/mesh-handles/src/bvh-core.js"; import { DerivedBvh } from "../addons/mesh-handles/src/bvh-core.js";
import { MeshHandles } from "../addons/mesh-handles/src/index.js";
import { YawnCore } from "../packages/yawn-core/src/index.js"; import { YawnCore } from "../packages/yawn-core/src/index.js";
const align16 = value => (value + 15) & ~15; const align16 = value => (value + 15) & ~15;
@@ -108,25 +109,27 @@ class WorkerMock extends EventTarget {
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); } reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
} }
test("core picking returns protocol handles and exact epoch", async () => { test("mesh addon owns picking and returns wrapped handles at the exact epoch", async () => {
const scene = snapshotFixture(); const scene = snapshotFixture();
const ring = 8192; const ring = 8192;
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16); const ringHeader = new Int32Array(scene.memory.buffer, ring, 16);
ringHeader.set([0x4e574159, 2, 1024, 40]); ringHeader.set([0x4e574159, 2, 1024, 40]);
const rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock(); const rendererWorker = new WorkerMock(), bvhWorker = new WorkerMock();
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, pickingWorkerFactory: () => bvhWorker, free() {} }; const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, free() {} };
const client = new YawnCore(bridge); const client = new YawnCore(bridge);
const handles = new MeshHandles(client, { pickingWorkerFactory: () => bvhWorker });
rendererWorker.reply({ type: "soa-init", arrays: [] }); rendererWorker.reply({ type: "soa-init", arrays: [] });
await client.ready; await client.ready;
rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 2 }); rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 2 });
rendererWorker.reply({ type: "snapshot-published", epoch: 1 }); rendererWorker.reply({ type: "snapshot-published", epoch: 1 });
const picking = client.pickRay([0, 0, 0], [1, 0, 0]); const picking = handles.pickRay([0, 0, 0], [1, 0, 0]);
const request = bvhWorker.messages.find(message => message.type === "pick"); const request = bvhWorker.messages.find(message => message.type === "pick");
bvhWorker.reply({ type: "pick", request: request.request, epoch: 1, stale: false, hits: [{ slot: 10, generation: 3, distance: 2 }] }); bvhWorker.reply({ type: "pick", request: request.request, epoch: 1, stale: false, hits: [{ slot: 10, generation: 3, distance: 2 }] });
const result = await picking; const result = await picking;
assert.equal(result.epoch, 1); assert.equal(result.epoch, 1);
assert.equal(result.hits[0].distance, 2); assert.equal(result.hits[0].distance, 2);
assert.deepEqual(result.hits[0].instance, [10, 3]); assert.deepEqual(result.hits[0].instance.handle, [10, 3]);
handles.dispose();
client.dispose(); client.dispose();
assert.equal(bvhWorker.terminated, true); assert.equal(bvhWorker.terminated, true);
}); });
+80 -20
View File
@@ -1,10 +1,12 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { YawnCore, RendererError } from "@yawn/core"; import * as coreApi from "@yawn/core";
import { MeshHandles } from "@yawn/mesh-handles"; import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast"; import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
const { YawnCore, RendererError } = coreApi;
const TYPE = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 0x80000000, 0xffffffff]; const TYPE = [0, 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 0x80000000, 0xffffffff];
const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; const IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
@@ -70,19 +72,37 @@ function setup() {
byteLength: 256, layoutEpoch: 1, writable: false, byteLength: 256, layoutEpoch: 1, writable: false,
}); });
const upload = installArray(memory, { const upload = installArray(memory, {
id: 5, name: "upload.gltf", domain: "fixed", scalar: "u32", lanes: 4, id: 5, name: "upload.renderData", domain: "fixed", scalar: "u32", lanes: 4,
stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64, stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64,
byteLength: 256, layoutEpoch: 1, writable: true, byteLength: 256, layoutEpoch: 1, writable: true,
}); });
const camera = installArray(memory, {
id: 6, name: "camera.state", domain: "fixed", scalar: "f32", lanes: 16,
stride: 64, length: 1, capacity: 1, controlPtr: 200384, dataOffset: 64,
byteLength: 64, layoutEpoch: 1, writable: true,
});
new Float32Array(memory.buffer, camera.controlPtr + camera.dataOffset, 16).set([
4, 3, 6, 1,
0, 0, 0, 1,
0, 1, 0, 0,
Math.PI / 4, 16 / 9, 0.1, 1000,
]);
const material = installArray(memory, {
id: 7, name: "material.state", domain: "fixed", scalar: "u32", lanes: 28,
stride: 112, length: 4, capacity: 4, controlPtr: 200512, dataOffset: 64,
byteLength: 448, layoutEpoch: 1, writable: true,
});
const materialFloats = new Float32Array(memory.buffer, material.controlPtr + material.dataOffset, material.byteLength / 4);
materialFloats.set([1, 1, 1, 1, 0, 0, 0, 0, 1, 0.5, 1, 1, 0, 0.5, 1.5, 0.04], 28);
const worker = new WorkerMock(); const worker = new WorkerMock();
const bridge = { memory, ringPtr: 0, worker, freed: false, free() { this.freed = true; } }; const bridge = { memory, ringPtr: 0, worker, freed: false, free() { this.freed = true; } };
const core = new YawnCore(bridge); const core = new YawnCore(bridge);
worker.reply({ type: "soa-init", arrays: [transform, type, generation, meshGeneration, upload] }); worker.reply({ type: "soa-init", arrays: [transform, type, generation, meshGeneration, upload, camera, material] });
return { memory, ring, worker, bridge, core, handles: new MeshHandles(core), transform, type, generation, upload }; return { memory, ring, worker, bridge, core, handles: new MeshHandles(core), transform, type, generation, upload, camera, material };
} }
async function imported(fixture) { async function imported(fixture) {
const loading = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8); const loading = fixture.core.commitRenderDataUpload(fixture.core.array("upload.renderData"), 8);
fixture.worker.reply({ fixture.worker.reply({
type: "reply", type: "reply",
request: 1, request: 1,
@@ -93,6 +113,7 @@ async function imported(fixture) {
defaultInstance: [8, 5], defaultInstance: [8, 5],
defaultType: TYPE, defaultType: TYPE,
}], }],
materials: [{ key: 1 }],
}, },
}); });
const [mesh] = fixture.handles.fromImportedScene(await loading); const [mesh] = fixture.handles.fromImportedScene(await loading);
@@ -105,10 +126,10 @@ async function imported(fixture) {
return mesh; return mesh;
} }
test("core commits shared scene uploads through metadata-only opcode 1", async () => { test("core commits generic shared render-data packets through metadata-only opcode 1", async () => {
const fixture = setup(); const fixture = setup();
const pending = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8, { framing: "interior" }); const pending = fixture.core.commitRenderDataUpload(fixture.core.array("upload.renderData"), 8);
assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 6)], [2, 1, 1, 5, 8, 1]); assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 6)], [2, 1, 1, 5, 8, 0]);
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { meshes: [] } }); fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { meshes: [] } });
assert.deepEqual(await pending, { meshes: [] }); assert.deepEqual(await pending, { meshes: [] });
}); });
@@ -127,17 +148,6 @@ test("mesh handles are a separate conventional facade over core commands", async
assert.deepEqual(instance.handle, [4, 2]); assert.deepEqual(instance.handle, [4, 2]);
}); });
test("mesh handle picking wraps core protocol handles", async () => {
const core = {
async pickRay() {
return { epoch: 4, hits: [{ instance: [3, 9], distance: 2 }] };
},
};
const result = await new MeshHandles(core).pickRay([0, 0, 0], [1, 0, 0]);
assert.deepEqual(result.hits[0].instance.handle, [3, 9]);
assert.equal(result.hits[0].distance, 2);
});
test("frequent instance mutations write guarded SOA lanes without ring messages", async () => { test("frequent instance mutations write guarded SOA lanes without ring messages", async () => {
const fixture = setup(); const fixture = setup();
const mesh = await imported(fixture); const mesh = await imported(fixture);
@@ -158,6 +168,55 @@ test("frequent instance mutations write guarded SOA lanes without ring messages"
assert.equal(Atomics.load(type, base + 17) >>> 0, 1); assert.equal(Atomics.load(type, base + 17) >>> 0, 1);
}); });
test("camera is render data with no dedicated core API", () => {
const fixture = setup();
const camera = fixture.core.array("camera.state");
const ringBefore = Atomics.load(fixture.ring, 5);
const control = new Int32Array(fixture.memory.buffer, fixture.camera.controlPtr, 16);
const sequenceBefore = Atomics.load(control, 9);
const state = camera.read(0);
state[0] = 5;
state[1] = 4;
camera.write(0, state);
assert.equal("SharedCamera" in coreApi, false);
assert.deepEqual(camera.read(0).slice(0, 3), [5, 4, 6]);
assert.equal(Atomics.load(fixture.ring, 5), ringBefore);
assert.equal(Atomics.load(control, 9), sequenceBefore + 2);
});
test("camera handle exposes conventional properties using only the shared row", () => {
const fixture = setup();
const camera = new CameraHandle(fixture.core);
const ringBefore = Atomics.load(fixture.ring, 5);
camera.update({ position: [5, 4, 7], target: [1, 0, 0], fovY: Math.PI / 3 });
assert.deepEqual(camera.position, [5, 4, 7]);
assert.deepEqual(camera.target, [1, 0, 0]);
assert.ok(Math.abs(camera.fovY - Math.PI / 3) < 1e-6);
assert.equal(Atomics.load(fixture.ring, 5), ringBefore);
assert.throws(() => camera.update({ near: 2, far: 1 }), RangeError);
});
test("material handles mutate packed GPU material rows without messages", () => {
const fixture = setup();
const [material] = new MaterialHandles(fixture.core).fromImportedScene({ materials: [{ key: 1 }] });
const ringBefore = Atomics.load(fixture.ring, 5);
const control = new Int32Array(fixture.memory.buffer, fixture.material.controlPtr, 16);
const sequenceBefore = Atomics.load(control, 9);
material.update({ baseColor: [0.2, 0.4, 0.8, 1], metallic: 0.25, roughness: 0.75, ior: 2 });
assert.equal(material.key, 1);
assert.deepEqual(material.baseColor.map(value => Math.round(value * 10) / 10), [0.2, 0.4, 0.8, 1]);
assert.equal(material.metallic, 0.25);
assert.equal(material.roughness, 0.75);
assert.equal(material.ior, 2);
assert.equal(Atomics.load(fixture.ring, 5), ringBefore);
assert.equal(Atomics.load(control, 9), sequenceBefore + 2);
});
test("generation columns reject stale handles and are read-only", async () => { test("generation columns reject stale handles and are read-only", async () => {
const fixture = setup(); const fixture = setup();
const mesh = await imported(fixture); const mesh = await imported(fixture);
@@ -221,6 +280,7 @@ test("graph lifecycle remains FIFO and uses opcodes 8 and 9", async () => {
const first = fixture.core.switchCompiledGraph([9, 4]); const first = fixture.core.switchCompiledGraph([9, 4]);
const second = fixture.core.dropCompiledGraph([2, 1]); const second = fixture.core.dropCompiledGraph([2, 1]);
assert.equal(Atomics.load(fixture.ring, 5), 1); assert.equal(Atomics.load(fixture.ring, 5), 1);
assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 5)], [2, 9, 1, 9, 4]);
fixture.worker.reply({ type: "reply", request: 1, ok: true }); fixture.worker.reply({ type: "reply", request: 1, ok: true });
await first; await first;
await new Promise(queueMicrotask); await new Promise(queueMicrotask);
+26 -11
View File
@@ -12,9 +12,10 @@ const portalHost = process.env.PUBLIC_URL
? new URL(process.env.PUBLIC_URL).hostname ? new URL(process.env.PUBLIC_URL).hostname
: undefined; : undefined;
const serverPort = Number(process.env.PORT) || 8080; const serverPort = Number(process.env.PORT) || 8080;
const wasmSource = path.resolve(__dirname, "level-editor/pkg"); const wasmSource = path.resolve(__dirname, "renderer/pkg");
const exampleRoot = path.resolve(__dirname, "examples/render-graph-studio"); const exampleRoot = path.resolve(__dirname, "examples");
const wasmDestination = path.resolve(exampleRoot, "level-editor/pkg"); const wasmDestination = path.resolve(exampleRoot, "renderer/pkg");
const buildOutput = path.resolve(__dirname, "dist");
// Vite resolves entry imports before plugin build hooks. Mirror synchronously while // Vite resolves entry imports before plugin build hooks. Mirror synchronously while
// loading the config so a clean build always sees the complete generated package. // loading the config so a clean build always sees the complete generated package.
@@ -29,6 +30,21 @@ function freshWasmPackage() {
return { return {
name: "fresh-wasm-package", name: "fresh-wasm-package",
async buildStart() { await mirror(); }, async buildStart() { await mirror(); },
async writeBundle() {
const wasmBuildDestination = path.resolve(buildOutput, "renderer/pkg");
const cookbookBuildDestination = path.resolve(buildOutput, "cookbook");
await Promise.all([
mkdir(wasmBuildDestination, { recursive: true }),
mkdir(cookbookBuildDestination, { recursive: true }),
]);
await Promise.all([
cp(wasmSource, wasmBuildDestination, { recursive: true, force: true }),
cp(path.resolve(exampleRoot, "cookbook"), cookbookBuildDestination, {
recursive: true,
force: true,
}),
]);
},
configureServer(server) { configureServer(server) {
server.watcher.add(wasmSource); server.watcher.add(wasmSource);
const update = file => { const update = file => {
@@ -46,14 +62,18 @@ export default defineConfig({
build: { build: {
rollupOptions: { rollupOptions: {
input: { input: {
app: path.resolve(exampleRoot, "index.html"), examples: path.resolve(exampleRoot, "index.html"),
renderGraphStudio: path.resolve(
exampleRoot,
"render-graph-studio/index.html",
),
}, },
}, },
// Relative to 'root'. // Relative to 'root'.
outDir: "../../dist", outDir: "../dist",
copyPublicDir: true, copyPublicDir: true,
}, },
// Build output stays at the repository root, outside the nested example root. // Build output stays at the repository root, outside the examples root.
root: exampleRoot, root: exampleRoot,
// The example is source code, not Vite's untransformed public directory. Treating it // The example is source code, not Vite's untransformed public directory. Treating it
// as both makes dev mode reject the generated WASM worker's module imports. // as both makes dev mode reject the generated WASM worker's module imports.
@@ -80,10 +100,6 @@ export default defineConfig({
__dirname, __dirname,
"addons/render-graph-fxnode/src/index.js", "addons/render-graph-fxnode/src/index.js",
), ),
"@yawn/core/snapshot": path.resolve(
__dirname,
"packages/yawn-core/src/snapshot.js",
),
"@yawn/core": path.resolve(__dirname, "packages/yawn-core/src/index.js"), "@yawn/core": path.resolve(__dirname, "packages/yawn-core/src/index.js"),
"@yawn/mesh-handles": path.resolve( "@yawn/mesh-handles": path.resolve(
__dirname, __dirname,
@@ -97,7 +113,6 @@ export default defineConfig({
__dirname, __dirname,
"addons/default-pipelines/src/index.js", "addons/default-pipelines/src/index.js",
), ),
pkg: path.resolve(__dirname, "level-editor/pkg"),
}, },
}, },
plugins: [ plugins: [