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:
yawn-dev:
command: npm run dev
yawn-examples:
command: npm run examples
portal:
title: Yawn
description: WebGPU level editor development server with hot reload.
title: Yawn examples
description: Example index and WebGPU render graph studio with hot reload.
+1 -1
View File
@@ -1,5 +1,5 @@
# Build, Lint, and Test Commands
- `npm run dev`: Start Vite dev server with hot reload for WASM bundle
- `npm run examples`: Start the examples index with Vite and WASM hot reload
- `npm run build`: Build optimized WASM and JS in `dist/` for development
- `npm run build-release`: Build optimized WASM and JS for production
- `cargo check`: Validate Rust sources quickly before full builds
Generated
-227
View File
@@ -17,15 +17,6 @@ dependencies = [
"libc",
]
[[package]]
name = "approx"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f2a05fd1bd10b2527e20a2cd32d8873d115b8b39fe219ee25f42a8aca6ba278"
dependencies = [
"num-traits",
]
[[package]]
name = "arrayvec"
version = "0.7.6"
@@ -47,12 +38,6 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -115,12 +100,6 @@ dependencies = [
"syn",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -139,16 +118,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "cgmath"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a98d30140e3296250832bbaaff83b27dcd6fa3cc70fb6f1f3e5c9c0023b5317"
dependencies = [
"approx",
"num-traits",
]
[[package]]
name = "codespan-reporting"
version = "0.12.0"
@@ -170,16 +139,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "console_log"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f"
dependencies = [
"log",
"web-sys",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -289,95 +248,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "gl_generator"
version = "0.14.0"
@@ -401,45 +271,6 @@ dependencies = [
"web-sys",
]
[[package]]
name = "gltf"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
dependencies = [
"base64",
"byteorder",
"gltf-json",
"image",
"lazy_static",
"serde_json",
"urlencoding",
]
[[package]]
name = "gltf-derive"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
dependencies = [
"inflections",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "gltf-json"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
dependencies = [
"gltf-derive",
"serde",
"serde_derive",
"serde_json",
]
[[package]]
name = "glutin_wgl_sys"
version = "0.6.1"
@@ -550,12 +381,6 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "inflections"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
[[package]]
name = "itoa"
version = "1.0.15"
@@ -595,29 +420,6 @@ version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "level-editor"
version = "0.1.0"
dependencies = [
"bytemuck",
"console_error_panic_hook",
"js-sys",
"log",
"renderer",
"ultraviolet",
"wasm-bindgen",
"wasm-bindgen-futures",
"wasm-logger",
"web-sys",
"wgpu",
]
[[package]]
name = "libc"
version = "0.2.174"
@@ -800,18 +602,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkg-config"
version = "0.3.32"
@@ -908,15 +698,10 @@ name = "renderer"
version = "0.1.0"
dependencies = [
"bytemuck",
"cgmath",
"console_error_panic_hook",
"console_log",
"futures",
"gltf",
"image",
"js-sys",
"log",
"raw-window-handle",
"serde",
"serde_json",
"thiserror 2.0.15",
@@ -999,12 +784,6 @@ version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "slotmap"
version = "1.0.7"
@@ -1116,12 +895,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "version_check"
version = "0.9.5"
+6 -28
View File
@@ -1,47 +1,25 @@
[workspace]
members = ["renderer", "level-editor"]
members = ["renderer"]
resolver = "2"
[workspace.dependencies]
wasm-bindgen = { version = "0.2.100", features = ["enable-interning"] }
wasm-bindgen-futures = "0.4.50"
console_error_panic_hook = "0.1.7"
console_log = "1.0.0"
log = "0.4.27"
wasm-logger = "0.2.0"
web-sys = { version = "0.3.77", features = [
"Window",
"Document",
"Element",
"HtmlCanvasElement",
"HtmlInputElement",
"File",
"FileList",
"OffscreenCanvas",
"MouseEvent",
"PointerEvent",
"WheelEvent",
"Worker",
"DedicatedWorkerGlobalScope",
"Event",
"MessageEvent",
"Blob",
"BlobPropertyBag",
"Url",
"Request",
"RequestInit",
"RequestMode",
"Response",
"Headers",
"AddEventListenerOptions"
"MessageEvent"
]}
js-sys = "0.3.77"
bytemuck = { version = "1.23.1", features = ["derive"] }
cgmath = "0.18"
raw-window-handle = "0.6.2"
wgpu = "26.0.1"
thiserror = "2.0.15"
ultraviolet = "0.10.0"
futures = "0.3"
gltf = { version = "1.4", features = ["extras", "names", "KHR_lights_punctual", "KHR_materials_ior"] }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
[profile.release]
opt-level = "z"
lto = true
+30 -11
View File
@@ -16,7 +16,7 @@ JavaScript objects ──┘ │
Any browser thread ── infrequent commands ──────────────────> worker
Any browser thread ── atomic SOA writes ────────────────────> shared WASM memory
glTF import worker ── fetch URL ──> fixed shared SOA upload ─┘
glTF import worker ── parse URL ──> generic render-data packet ─> fixed shared SOA ─┘
```
The canonical AST is the only public render-graph wire format. Nodes are named
@@ -29,17 +29,19 @@ pipelines before activating a graph.
Authored render shaders use Yawn's fixed scene ABI. Render and compute declarations
carry source, entry points, and dispatch/state metadata and are prepared with the
graph loadout. Core contains no built-in shader source or pipeline declarations.
Its public responsibility stops at shared render data and render-graph compilation,
loadouts, lifecycle, and transient resource management; conveniences live outside it.
## Packages
- `packages/yawn-core` (`@yawn/core`) — the worker command transport, serialized
graph lifecycle, and shared SOA views; it returns `[slot, generation]` handles.
- `packages/yawn-core` (`@yawn/core`) — render-data shared arrays and render-graph
lifecycle transport; it returns `[slot, generation]` render-data handles.
- `addons/render-graph-ast` — canonical immutable DAG AST and S-expression serializer.
- `addons/render-graph-js` — plain-object/fluent graph APIs that serialize and load ASTs.
- `addons/render-graph-fxnode` — FXNode snapshot exporter and diagnostic mapping.
- `addons/default-pipelines` — optional scene/frame shader and compute declarations.
- `addons/gltf-import`URL-fetching worker that writes GLB bytes directly to a fixed SOA.
- `addons/mesh-handles` — conventional `Mesh`/`Instance` objects and optional BVH picking.
- `addons/gltf-import`glTF worker that writes format-neutral render-data packets directly to a fixed SOA.
- `addons/mesh-handles` — conventional mesh, instance, camera, and material objects plus optional BVH picking.
The integration example in `examples/render-graph-studio` consumes every package
through its public API; no example source or shader lives in core.
@@ -80,7 +82,8 @@ await graph.load(core);
`@yawn/core` exposes 64-byte-aligned shared SOA columns. Every stride is a multiple
of 16 bytes and scalar lanes are atomic `u32`, `i32`, or IEEE-754 `f32` bits. The
built-in instance transform/type columns are generation-guarded so a stale handle
cannot mutate a reused slot.
cannot mutate a reused slot. The built-in `camera.state` column is one 64-byte,
16-lane `f32` row containing eye, target, up, and projection parameters.
Allocate application columns infrequently through the worker:
@@ -95,16 +98,32 @@ const velocity = await core.allocateArray({
velocity.write(instanceSlot, [1, 0, 0, 0]);
```
Camera state has no dedicated core API. Read and write it through the same render-data
SOA interface as every other hot value; these mutations do not enqueue worker messages:
```js
const camera = core.array("camera.state");
const state = camera.read(0);
state[0] = nextEye[0];
state[1] = nextEye[1];
state[2] = nextEye[2];
camera.write(0, state);
```
Import a GLB without transferring its bytes through renderer messages:
```js
import { GltfImporter } from "@yawn/gltf-import";
import { MeshHandles } from "@yawn/mesh-handles";
import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
const importer = new GltfImporter(core);
const handles = new MeshHandles(core);
const meshes = handles.fromImportedScene(await importer.load(gltfUrl));
const imported = await importer.load(gltfUrl);
const meshes = new MeshHandles(core).fromImportedScene(imported);
const materials = new MaterialHandles(core).fromImportedScene(imported);
const camera = new CameraHandle(core);
meshes[0].defaultInstance.setTransform(nextTransform); // direct shared-SOA write
materials[0].roughness = 0.35; // direct shared-SOA write
camera.position = [4, 3, 6]; // direct shared-SOA write
```
The renderer grows mesh/instance-domain columns with render-data capacity and
@@ -115,7 +134,7 @@ use shared memory; a GLB commit message contains only an array ID and byte count
`YawnCore` accepts a transport bridge whose worker endpoint can be a `Worker` or a
started `MessagePort`, so the same API can run on the browser main thread or another
worker. Optional picking is installed with the mesh addon's `createPickingWorker`.
worker. Optional snapshot/BVH picking is owned entirely by the mesh-handles addon.
Cross-origin isolation is required (`COOP: same-origin`, `COEP: require-corp`). The
Vite development and preview servers already set both headers.
@@ -123,7 +142,7 @@ Vite development and preview servers already set both headers.
## Development
```sh
npm run dev
npm run examples
npm run test:js
cargo check --workspace
```
+3 -3
View File
@@ -1,5 +1,5 @@
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> }
@group(0) @binding(0) var<uniform> uni: UniformData;
@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({
render: Object.freeze([
Object.freeze({ name: "ground_plane", shader: groundShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }),
Object.freeze({ name: "gltf_standard", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main" }),
Object.freeze({ name: "gltf_standard_double_sided", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", doubleSided: true }),
Object.freeze({ name: "gltf_standard", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", material: true }),
Object.freeze({ name: "gltf_standard_double_sided", shader: gltfShader, vertexEntry: "vs_main", fragmentEntry: "fs_main", doubleSided: true, material: true }),
Object.freeze({ name: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }),
]),
compute: Object.freeze([
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@yawn/gltf-import",
"version": "0.1.0",
"description": "glTF fetch worker that uploads directly into Yawn shared SOA memory",
"description": "glTF worker that publishes generic render data directly into Yawn shared SOA memory",
"type": "module",
"exports": "./src/index.js"
}
+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 {
#core;
#worker;
@@ -16,7 +40,8 @@ export class GltfImporter {
#disposed = false;
constructor(core, { workerFactory } = {}) {
if (!core?.allocateArray || !core?.commitGlbUpload) throw new TypeError("core must implement the Yawn shared upload protocol");
if (!core?.allocateArray || !core?.commitRenderDataUpload)
throw new TypeError("core must implement the Yawn shared render-data protocol");
this.#core = core;
this.#worker = workerFactory
? workerFactory()
@@ -55,7 +80,7 @@ export class GltfImporter {
if (message.type === "allocate") {
const length = Math.ceil(message.byteLength / 16);
pending.array = await this.#core.allocateArray({
name: "upload.gltf",
name: "upload.renderData",
domain: "fixed",
scalar: "u32",
lanes: 4,
@@ -68,11 +93,11 @@ export class GltfImporter {
...pending.array.share(),
});
} else if (message.type === "ready") {
const result = await this.#core.commitGlbUpload(
const result = await this.#core.commitRenderDataUpload(
pending.array,
message.byteLength,
pending.options,
);
frameCamera(this.#core, result.bounds, pending.options.framing);
this.#pending.delete(message.request);
pending.resolve(result);
} else if (message.type === "error") {
+8 -6
View File
@@ -1,4 +1,5 @@
import { writeSharedUpload } from "./shared-upload.js";
import { gltfToRenderDataPacket } from "./gltf.js";
const downloads = new Map();
@@ -10,16 +11,17 @@ addEventListener("message", async ({ data: message }) => {
if (!response.ok) throw new Error(`HTTP_${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.byteLength) throw new Error("GLTF_EMPTY");
downloads.set(request, bytes);
postMessage({ type: "allocate", request, byteLength: bytes.byteLength });
const packet = await gltfToRenderDataPacket(bytes, message.url);
downloads.set(request, packet);
postMessage({ type: "allocate", request, byteLength: packet.byteLength });
return;
}
if (message?.type === "storage") {
const bytes = downloads.get(request);
if (!bytes) throw new Error("GLTF_REQUEST_UNKNOWN");
writeSharedUpload(message.buffer, message.descriptor, bytes);
const packet = downloads.get(request);
if (!packet) throw new Error("GLTF_REQUEST_UNKNOWN");
writeSharedUpload(message.buffer, message.descriptor, packet);
downloads.delete(request);
postMessage({ type: "ready", request, byteLength: bytes.byteLength });
postMessage({ type: "ready", request, byteLength: packet.byteLength });
}
} catch (error) {
downloads.delete(request);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@yawn/mesh-handles",
"version": "0.1.0",
"description": "Conventional mesh handles over the Yawn worker and shared-SOA protocol",
"description": "Conventional mesh, instance, camera, and material handles over Yawn render data",
"type": "module",
"exports": "./src/index.js",
"dependencies": {
+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";
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. */
export const createPickingWorker = () => new Worker(
const TOKEN = Symbol("yawn mesh handle addon");
const SNAPSHOT_EVENT = "yawn-render-data-snapshot";
const PUBLISHED_EVENT = "yawn-render-data-snapshot-published";
const createPickingWorker = () => new Worker(
new URL("./bvh-worker.js", import.meta.url),
{ type: "module", name: "yawn-spatial-query" },
);
@@ -9,9 +12,17 @@ export const createPickingWorker = () => new Worker(
/** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */
export class MeshHandles {
#core;
#factory; #worker; #reader; #snapshot; #epoch = 0; #next = 1; #picks = new Map(); #disposed = false;
#onSnapshot; #onPublished;
constructor(core) {
constructor(core, { pickingWorkerFactory = createPickingWorker } = {}) {
this.#core = core;
this.#factory = pickingWorkerFactory;
this.#onSnapshot = event => this.#installSnapshot(event.detail);
this.#onPublished = event => this.#publish(event.detail?.epoch);
core.addEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
core.addEventListener?.(PUBLISHED_EVENT, this.#onPublished);
if (core.renderDataSnapshot) this.#installSnapshot(core.renderDataSnapshot);
}
fromImportedScene(result) {
@@ -20,7 +31,7 @@ export class MeshHandles {
}
async pickRay(origin, direction, options) {
const result = await this.#core.pickRay(origin, direction, options);
const result = await this.#pickRay(origin, direction, options);
return {
...result,
hits: result.hits.map((hit) => ({
@@ -29,6 +40,124 @@ export class MeshHandles {
})),
};
}
#installSnapshot(snapshot) {
try {
if (snapshot?.controlVersion !== 1 || snapshot?.schemaVersion !== 2) throw new Error("version");
this.#snapshot = snapshot;
this.#reader = new SnapshotReader(snapshot.memory, snapshot.controlPtr);
this.#epoch = this.#reader.latest().epoch;
this.#worker?.postMessage({ type: "init", ...snapshot });
} catch {
this.#disable("PICK_PROTOCOL_MISMATCH");
}
}
#publish(epoch) {
if (this.#disposed || !this.#reader) return;
try {
this.#epoch = this.#reader.latest().epoch;
this.#worker?.postMessage({ type: "update", epoch: this.#epoch || (epoch >>> 0) });
} catch {
this.#disable("PICK_PROTOCOL_MISMATCH");
}
}
#ensureWorker() {
if (this.#worker) return true;
if (!this.#reader || !this.#factory) return false;
try {
this.#worker = this.#factory();
this.#worker.addEventListener("message", event => this.#workerMessage(event.data));
this.#worker.addEventListener("error", () => this.#disable("PICK_WORKER_ERROR"));
this.#worker.addEventListener("messageerror", () => this.#disable("PICK_WORKER_ERROR"));
this.#worker.start?.();
this.#worker.postMessage({ type: "init", ...this.#snapshot });
if (this.#epoch) this.#worker.postMessage({ type: "update", epoch: this.#epoch });
return true;
} catch {
this.#disable("PICK_WORKER_ERROR");
return false;
}
}
#disable(code) {
const error = new RendererError(code);
for (const pending of this.#picks.values()) pending.reject(error);
this.#picks.clear();
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
this.#worker = null;
}
#workerMessage(message) {
if (message?.type === "fatal") { this.#disable(message.code || "PICK_WORKER_ERROR"); return; }
if (message?.type !== "pick") return;
const pending = this.#picks.get(message.request);
if (!pending) return;
this.#picks.delete(message.request);
let latest;
try { latest = this.#reader.latest().epoch; this.#epoch = latest; }
catch { pending.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); this.#disable("PICK_PROTOCOL_MISMATCH"); return; }
if (message.stale || pending.epoch !== message.epoch || message.epoch !== latest) {
if (!pending.retried && latest) this.#sendPick({ ...pending, retried: true }, latest);
else pending.reject(new RendererError("PICK_STALE"));
return;
}
pending.resolve({
epoch: latest,
hits: (message.hits || []).map(hit => ({
instance: [hit.slot >>> 0, hit.generation >>> 0],
distance: hit.distance,
})),
});
}
#sendPick(pending, epoch) {
const request = this.#next++ >>> 0 || this.#next++;
pending.epoch = epoch;
this.#picks.set(request, pending);
try {
this.#worker.postMessage({
type: "pick", request, epoch,
origin: pending.origin, direction: pending.direction,
maxDistance: pending.maxDistance, maxHits: pending.maxHits,
});
} catch {
this.#picks.delete(request);
pending.reject(new RendererError("PICK_WORKER_ERROR"));
}
}
#pickRay(origin, direction, { maxDistance = Infinity, maxHits = 1 } = {}) {
const vector = (value, name) => {
if (!value || value.length !== 3 || [...value].some(x => typeof x !== "number" || !Number.isFinite(x)))
throw new TypeError(`${name} must contain 3 finite numbers`);
return [...value];
};
origin = vector(origin, "origin");
direction = vector(direction, "direction");
if (direction.every(x => x === 0)) throw new TypeError("direction must be nonzero");
if (typeof maxDistance !== "number" || (!(Number.isFinite(maxDistance) && maxDistance >= 0) && maxDistance !== Infinity) || !Number.isInteger(maxHits) || maxHits < 1 || maxHits > 64)
throw new TypeError("invalid pick options");
if (this.#disposed) return Promise.reject(new RendererError("DISPOSED"));
if (!this.#ensureWorker()) return Promise.reject(new RendererError("PICK_UNAVAILABLE"));
let epoch;
try { epoch = this.#reader.latest().epoch; this.#epoch = epoch; }
catch { return Promise.reject(new RendererError("PICK_PROTOCOL_MISMATCH")); }
if (!epoch) return Promise.reject(new RendererError("PICK_STALE"));
return new Promise((resolve, reject) => this.#sendPick({
resolve, reject, origin, direction, maxDistance, maxHits, retried: false,
}, epoch));
}
dispose() {
if (this.#disposed) return;
this.#disposed = true;
this.#core.removeEventListener?.(SNAPSHOT_EVENT, this.#onSnapshot);
this.#core.removeEventListener?.(PUBLISHED_EVENT, this.#onPublished);
try { this.#worker?.postMessage?.({ type: "dispose" }); } catch { /* best effort */ }
this.#disable("DISPOSED");
}
}
export class Mesh {
@@ -65,3 +194,169 @@ export class Instance {
setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); }
async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; }
}
function requireArray(core, name, { domain, scalar, lanes }) {
if (!core?.array) throw new TypeError("core must implement the Yawn shared render-data protocol");
const array = core.array(name);
if (array.domain !== domain || array.scalar !== scalar || array.lanes !== lanes)
throw new RendererError("SOA_PROTOCOL_MISMATCH");
return array;
}
function vector(value, length, name) {
if (!value || value.length !== length || [...value].some(item => typeof item !== "number" || !Number.isFinite(item)))
throw new TypeError(`${name} must contain ${length} finite numbers`);
return Array.from(value);
}
function finite(value, name) {
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
return value;
}
/** Conventional camera properties backed by the canonical SIMD-width camera SOA row. */
export class CameraHandle {
#array;
constructor(core) {
this.#array = requireArray(core, "camera.state", { domain: "fixed", scalar: "f32", lanes: 16 });
}
get state() { return this.#array.read(0); }
set state(value) { this.#write(vector(value, 16, "state")); }
get position() { return this.state.slice(0, 3); }
set position(value) { this.update({ position: value }); }
get target() { return this.state.slice(4, 7); }
set target(value) { this.update({ target: value }); }
get up() { return this.state.slice(8, 11); }
set up(value) { this.update({ up: value }); }
get fovY() { return this.state[12]; }
set fovY(value) { this.update({ fovY: value }); }
get aspect() { return this.state[13]; }
set aspect(value) { this.update({ aspect: value }); }
get near() { return this.state[14]; }
set near(value) { this.update({ near: value }); }
get far() { return this.state[15]; }
set far(value) { this.update({ far: value }); }
update(properties = {}) {
if (!properties || typeof properties !== "object") throw new TypeError("camera properties must be an object");
const known = new Set(["position", "target", "up", "fovY", "aspect", "near", "far"]);
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown camera property '${key}'`);
const state = this.state;
if (properties.position !== undefined) state.splice(0, 3, ...vector(properties.position, 3, "position"));
if (properties.target !== undefined) state.splice(4, 3, ...vector(properties.target, 3, "target"));
if (properties.up !== undefined) state.splice(8, 3, ...vector(properties.up, 3, "up"));
if (properties.fovY !== undefined) state[12] = finite(properties.fovY, "fovY");
if (properties.aspect !== undefined) state[13] = finite(properties.aspect, "aspect");
if (properties.near !== undefined) state[14] = finite(properties.near, "near");
if (properties.far !== undefined) state[15] = finite(properties.far, "far");
this.#write(state);
return this;
}
lookAt(position, target, { up = this.up } = {}) {
return this.update({ position, target, up });
}
#write(state) {
const offset = state.slice(0, 3).map((value, axis) => state[4 + axis] - value);
const up = state.slice(8, 11);
const cross = [
offset[1] * up[2] - offset[2] * up[1],
offset[2] * up[0] - offset[0] * up[2],
offset[0] * up[1] - offset[1] * up[0],
];
if (Math.hypot(...offset) < 0.1 || Math.hypot(...up) === 0 || Math.hypot(...cross) === 0)
throw new RangeError("camera position, target, and up do not define a view");
if (!(state[12] > 0 && state[12] < Math.PI) || state[13] <= 0 || state[14] <= 0 || state[15] <= state[14])
throw new RangeError("camera projection is invalid");
state[3] = state[7] = 1;
state[11] = 0;
this.#array.write(0, state);
}
}
const FLOAT_WORD = new ArrayBuffer(4);
const FLOAT_VIEW = new Float32Array(FLOAT_WORD);
const WORD_VIEW = new Uint32Array(FLOAT_WORD);
function wordToFloat(word) { WORD_VIEW[0] = word; return FLOAT_VIEW[0]; }
function floatToWord(value) { FLOAT_VIEW[0] = value; return WORD_VIEW[0]; }
/** Creates scene-scoped material objects over packed material SOA rows. */
export class MaterialHandles {
#array;
constructor(core) {
this.#array = requireArray(core, "material.state", { domain: "fixed", scalar: "u32", lanes: 28 });
}
fromImportedScene(result) {
if (!result || !Array.isArray(result.materials)) throw new TypeError("invalid imported scene");
return result.materials.map(material => this.get(material?.key));
}
get(key) {
if (!Number.isInteger(key) || key < 0 || key > 0xffffffff || key >= this.#array.length)
throw new RangeError("material key is outside the shared material rows");
return new MaterialHandle(TOKEN, this.#array, key);
}
}
export class MaterialHandle {
#array; #key;
constructor(token, array, key) {
if (token !== TOKEN) throw new TypeError("MaterialHandle cannot be constructed directly");
this.#array = array;
this.#key = key;
}
get key() { return this.#key; }
get baseColor() { return this.#floats(0, 4); }
set baseColor(value) { this.update({ baseColor: value }); }
get emissive() { return this.#floats(4, 3); }
set emissive(value) { this.update({ emissive: value }); }
get metallic() { return this.#float(8); }
set metallic(value) { this.update({ metallic: value }); }
get roughness() { return this.#float(9); }
set roughness(value) { this.update({ roughness: value }); }
get normalScale() { return this.#float(10); }
set normalScale(value) { this.update({ normalScale: value }); }
get occlusionStrength() { return this.#float(11); }
set occlusionStrength(value) { this.update({ occlusionStrength: value }); }
get alphaCutoff() { return this.#float(13); }
set alphaCutoff(value) { this.update({ alphaCutoff: value }); }
get ior() { return this.#float(14); }
set ior(value) { this.update({ ior: value }); }
update(properties = {}) {
if (!properties || typeof properties !== "object") throw new TypeError("material properties must be an object");
const known = new Set(["baseColor", "emissive", "metallic", "roughness", "normalScale", "occlusionStrength", "alphaCutoff", "ior"]);
for (const key of Object.keys(properties)) if (!known.has(key)) throw new TypeError(`unknown material property '${key}'`);
const words = this.#array.read(this.#key);
const setFloat = (lane, value, name) => { words[lane] = floatToWord(finite(value, name)); };
if (properties.baseColor !== undefined)
vector(properties.baseColor, 4, "baseColor").forEach((value, lane) => setFloat(lane, value, "baseColor"));
if (properties.emissive !== undefined)
vector(properties.emissive, 3, "emissive").forEach((value, lane) => setFloat(4 + lane, value, "emissive"));
for (const [name, lane] of [["metallic", 8], ["roughness", 9], ["normalScale", 10], ["occlusionStrength", 11], ["alphaCutoff", 13]]) {
if (properties[name] !== undefined) setFloat(lane, properties[name], name);
}
if (properties.metallic !== undefined && !(properties.metallic >= 0 && properties.metallic <= 1)) throw new RangeError("metallic must be in [0, 1]");
if (properties.roughness !== undefined && !(properties.roughness >= 0 && properties.roughness <= 1)) throw new RangeError("roughness must be in [0, 1]");
if (properties.occlusionStrength !== undefined && !(properties.occlusionStrength >= 0 && properties.occlusionStrength <= 1)) throw new RangeError("occlusionStrength must be in [0, 1]");
if (properties.alphaCutoff !== undefined && !(properties.alphaCutoff >= 0 && properties.alphaCutoff <= 1)) throw new RangeError("alphaCutoff must be in [0, 1]");
if (properties.ior !== undefined) {
const ior = finite(properties.ior, "ior");
if (ior !== 0 && ior < 1) throw new RangeError("ior must be 0 or at least 1");
setFloat(14, ior, "ior");
setFloat(15, ior === 0 ? 1 : ((ior - 1) / (ior + 1)) ** 2, "ior");
}
this.#array.write(this.#key, words);
return this;
}
#float(lane) { return wordToFloat(this.#array.read(this.#key)[lane]); }
#floats(start, length) { return this.#array.read(this.#key).slice(start, start + length).map(wordToFloat); }
}
@@ -1,4 +1,4 @@
/** Shared render-data snapshot protocol used by core picking workers. */
/** Shared render-data snapshot protocol consumed by the mesh-handle BVH worker. */
export const SNAPSHOT = Object.freeze({
MAGIC: 0x504e5359, BLOB_MAGIC: 0x32534452, VERSION: 1, BYTES: 256,
SLOTS: 3, SLOT_BYTES: 64, SCHEMA: 2, INIT: 0, OPEN: 1, FAILED: 2,
+3 -1
View File
@@ -51,13 +51,15 @@ function normalizePipelines(raw = {}) {
vertexEntry: pipeline.vertexEntry ?? "vs_main",
fragmentEntry: pipeline.fragmentEntry ?? "fs_main",
doubleSided: pipeline.doubleSided ?? false,
material: pipeline.material ?? false,
};
if (
!identifier(result.name) ||
!identifier(result.vertexEntry) ||
!identifier(result.fragmentEntry) ||
typeof result.shader !== "string" ||
typeof result.doubleSided !== "boolean"
typeof result.doubleSided !== "boolean" ||
typeof result.material !== "boolean"
)
fail("AST_PIPELINE", "invalid render pipeline declaration");
return result;
+2 -1
View File
@@ -1,9 +1,10 @@
# Yawn examples
- `index.html` is the browser landing page for the complete example and recipes.
- `render-graph-studio/` is the complete browser integration with FXNode, JSO,
external pipelines, shared glTF import, mesh handles, and picking.
- `cookbook/` contains small copyable recipes, each focused on one public API.
Start the full browser example with `npm run dev`. The cookbook modules are plain
Start the examples index with `npm run examples`. The cookbook modules are plain
ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live
renderer is required.
@@ -11,9 +11,3 @@ export async function compileAndSwitch(core, graph) {
throw error;
}
}
/** Return to clear-only mode before releasing an active compiled loadout. */
export async function dropActiveGraph(core, compiled) {
await core.switchToImmediate();
await core.dropCompiledGraph(compiled.compiledId);
}
+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. */
export function pickNearest(core, origin, direction) {
return new MeshHandles(core).pickRay(origin, direction, {
export function pickNearest(meshHandles, origin, direction) {
return meshHandles.pickRay(origin, direction, {
maxDistance: 10_000,
maxHits: 1,
});
-1
View File
@@ -6,7 +6,6 @@ export async function connectFromWorker(port, options = {}) {
worker: port,
memory: options.memory,
ringPtr: options.ringPtr,
pickingWorkerFactory: options.pickingWorkerFactory,
free: options.free,
});
await core.ready;
+3 -12
View File
@@ -1,19 +1,10 @@
import { culling } from "../render-graph-studio/render-graph/presets.js";
import { importGltf } from "./09-gltf-import-worker.js";
import { compileAndSwitch, dropActiveGraph } from "./08-compile-and-switch.js";
import { compileAndSwitch } from "./08-compile-and-switch.js";
/** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */
export async function loadCompleteScene(core, gltfUrl) {
const meshes = await importGltf(core, gltfUrl);
const compiled = await compileAndSwitch(core, culling);
try {
const meshes = await importGltf(core, gltfUrl);
return {
compiled,
meshes,
dispose: () => dropActiveGraph(core, compiled),
};
} catch (error) {
await dropActiveGraph(core, compiled).catch(() => {});
throw error;
}
return { compiled, meshes };
}
+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 |
| `06-custom-render-pipeline.js` | Supplying a custom scene render shader |
| `07-compute-pipeline.js` | Supplying a binding-free compute pass |
| `08-compile-and-switch.js` | Compiling, activating, and safely cleaning up a graph |
| `08-compile-and-switch.js` | Compiling and transactionally activating a graph |
| `09-gltf-import-worker.js` | Fetching a glTF URL into shared memory from a worker |
| `10-mesh-instances.js` | Creating and mutating conventional instance handles |
| `11-custom-soa-column.js` | Allocating an instance-sized shared SOA column |
@@ -20,6 +20,8 @@ Import the function you need and pass the same `YawnCore` instance to every addo
| `13-bvh-picking.js` | Ray picking through the mesh-handles addon |
| `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` |
| `15-complete-scene.js` | Combining the graph, glTF, and mesh addons |
| `16-camera-render-data.js` | Treating camera input as direct render-data SAB writes |
| `17-conventional-handles.js` | Conventional camera and material properties over SAB rows |
Recipes 17 isolate graph authoring concepts, so their ASTs are intentionally
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 "./14-worker-to-worker.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);
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry");
const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]});
const json={asset:{version:"2.0",generator:"yawn-phase8"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[
const json={asset:{version:"2.0",generator:"yawn-demo"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[
{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},
{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},
{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},
@@ -57,7 +57,7 @@ const galleryPngBase64=Object.freeze({
});
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<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(){
// A modest shared sphere keeps the embedded GLB compact while making roughness
// 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 meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
const json={asset:{version:"2.0",generator:"yawn-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
+2 -7
View File
@@ -71,11 +71,6 @@
text-transform: uppercase;
letter-spacing: 0.08em;
}
#profile-menu { min-width: 210px; color: #9da9ba; }
#profile-menu summary { cursor: pointer; color: #eef2f8; }
#profile-menu table { width: 100%; font-size: 11px; }
#profile-menu th { text-align: left; font-weight: 500; }
#profile-menu td { text-align: right; font-variant-numeric: tabular-nums; }
select,
button {
font: inherit;
@@ -165,7 +160,7 @@
>Scene loadout<select id="loadout-select">
<option value="cubes">Cubes</option>
<option value="spheres">UV spheres</option>
<option value="materials">Phase 6 PBR gallery</option>
<option value="materials">PBR material gallery</option>
</select></label
><label class="field" for="graph-select"
>Graph preset<select id="graph-select">
@@ -175,7 +170,7 @@
>
</div>
<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 class="editor" aria-label="Render graph editor">
<div class="editor-bar">
+16 -113
View File
@@ -1,5 +1,6 @@
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 { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
import { createGraphAst } from "@yawn/render-graph-ast";
@@ -37,11 +38,7 @@ const state = {
compiled: {},
telemetry: null,
};
export const profileRequested = (search) =>
new URLSearchParams(search).get("profile") === "1";
const profileEnabled = profileRequested(location.search);
function createWorkerTransport(profile) {
function createWorkerTransport() {
const canvas = document.querySelector("#canvas0");
const dpr = devicePixelRatio;
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
@@ -62,16 +59,6 @@ function createWorkerTransport(profile) {
kind,
values: new Float64Array(values),
});
const mouseValues = (event) => [
devicePixelRatio,
event.buttons,
event.movementX,
event.movementY,
event.offsetX,
event.offsetY,
Math.max(1, canvas.clientHeight),
];
addEventListener(
"resize",
() =>
@@ -82,106 +69,19 @@ function createWorkerTransport(profile) {
]),
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();
worker.postMessage({ type: "init", canvas: offscreen, profile }, [offscreen]);
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
return {
worker,
pickingWorkerFactory: createPickingWorker,
free() {
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) {
state.telemetry = telemetry;
document.documentElement.dataset.phase8State = JSON.stringify({
document.documentElement.dataset.yawnState = JSON.stringify({
activeLoadout: state.loadout,
activeGraph: state.graph,
renderDataRevision: telemetry.revision,
@@ -195,7 +95,6 @@ function publish(telemetry) {
draws: telemetry.draws,
instances: telemetry.instances,
indices: telemetry.indices,
framingRadius: telemetry.framingRadius,
gpuError: telemetry.gpuError,
});
}
@@ -252,9 +151,9 @@ async function transaction(label, operation, rollback) {
try {
await rollback?.();
} 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}`);
}
return false;
@@ -328,6 +227,7 @@ async function cleanup() {
await editor?.destroy();
} finally {
gltfImporter?.dispose();
meshHandles?.dispose();
renderer?.dispose();
}
}
@@ -337,12 +237,15 @@ const pagehide = () => {
async function start() {
addEventListener("pagehide", pagehide, { once: true });
delete document.documentElement.dataset.phase8Ready;
renderer = new YawnCore(createWorkerTransport(profileEnabled));
delete document.documentElement.dataset.yawnReady;
const transport = createWorkerTransport();
renderer = new YawnCore(transport);
meshHandles = new MeshHandles(renderer);
gltfImporter = new GltfImporter(renderer);
installProfileMenu();
await renderer.ready;
listeners.push(
installCameraRenderDataControls(renderer, document.querySelector("#canvas0")),
);
const nextEditor = await createRenderGraphEditor(
document.querySelector("#graph-editor"),
);
@@ -465,11 +368,11 @@ async function start() {
},
);
if (!initialized) throw new Error("Initial demo transaction failed");
document.documentElement.dataset.phase8Ready = "true";
document.documentElement.dataset.yawnReady = "true";
}
const startupError = (error) => {
if (cleaned) return;
console.error("Phase 8 startup failed", error);
console.error("Render graph startup failed", error);
status(`Startup failed · ${error?.code ?? error}`);
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/*"
],
"scripts": {
"dev": "run-s rsw:build dev:watch",
"dev:watch": "run-p rsw:watch dev:vite",
"dev:vite": "vite dev",
"examples": "run-s rsw:build examples:watch",
"examples:watch": "run-p rsw:watch examples:vite",
"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: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-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-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",
"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 renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
"bundle-dev": "vite build --mode development",
"bundle-release": "vite build",
"build": "run-s clean wasm-dev bundle-dev",
"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",
"start": "vite preview",
"clean": "rimraf --glob dist **/pkg",
+1 -4
View File
@@ -2,8 +2,5 @@
"name": "@yawn/core",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.js",
"./snapshot": "./src/snapshot.js"
}
"exports": "./src/index.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 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 {
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;
#readyResolve; #readyReject; #arrays = new Map();
#transportReady = false;
#telemetry; #profile; #stopped = false;
#telemetry; #stopped = false; #renderDataSnapshot;
#graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
constructor(bridge) {
super();
@@ -31,20 +28,11 @@ export class YawnCore extends EventTarget {
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
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 telemetry() { return this.#telemetry; }
get profile() { return this.#profile; }
get renderDataSnapshot() { return this.#renderDataSnapshot; }
array(name) {
const array = this.#arrays.get(name);
@@ -99,9 +87,6 @@ export class YawnCore extends EventTarget {
} else if (message?.type === "telemetry") {
this.#telemetry = 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") {
console.error("renderer worker fatal", JSON.stringify(message));
this.#fail(message.code || "WORKER_FATAL");
@@ -117,11 +102,18 @@ export class YawnCore extends EventTarget {
} else if (message?.type === "snapshot-init") {
try {
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
this.#snapshotReader = new SnapshotReader(this.#bridge.memory, message.controlPtr);
this.#bvh?.postMessage({type:"init",memory:this.#bridge.memory,controlPtr:message.controlPtr,controlVersion:1,schemaVersion:2});
} catch { this.#disablePicking("PICK_PROTOCOL_MISMATCH"); }
this.#renderDataSnapshot = Object.freeze({
memory: this.#bridge.memory,
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") {
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);
}
#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() {
if (this.#stopped) return;
this.#stopped = true;
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 */ }
this.#bridge = null;
}
@@ -171,7 +143,6 @@ export class YawnCore extends EventTarget {
this.#payloadPending.clear();
for (const pending of this.#graphQueue) pending.reject(error);
this.#graphQueue.length = 0;
this.#disablePicking(code === "DISPOSED" ? "DISPOSED" : "PICK_WORKER_ERROR");
this.#stop();
}
@@ -211,14 +182,13 @@ export class YawnCore extends EventTarget {
return promise;
}
commitGlbUpload(array, byteLength, { framing = "exterior" } = {}) {
commitRenderDataUpload(array, byteLength) {
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)
throw new TypeError("array must be a packed fixed uint32 shared array");
if (!Number.isInteger(byteLength) || byteLength < 1 || byteLength > array.length * array.lanes * 4)
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) } = {}) {
@@ -306,11 +276,7 @@ export class YawnCore extends EventTarget {
switchCompiledGraph(compiledId) {
validateCompiledId(compiledId);
if (compiledId[0] === 0 && compiledId[1] === 0) throw new TypeError("compiledId must be nonzero");
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [1, ...compiledId]));
}
switchToImmediate() {
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, [0, 0, 0]));
return this.#graphCall(() => this.#enqueue(OP.SWITCH_GRAPH, compiledId));
}
dispose() { this.#fail("DISPOSED"); }
-22
View File
@@ -1,5 +1,3 @@
cargo-features = ["profile-rustflags"]
[package]
name = "renderer"
description = "WGPU renderer core library"
@@ -14,38 +12,18 @@ 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]
wasm-bindgen = { workspace = true }
wasm-bindgen-futures = { workspace = true }
console_error_panic_hook = { workspace = true }
console_log = { workspace = true }
log = { workspace = true }
wasm-logger = { workspace = true }
web-sys = { workspace = true }
js-sys = { workspace = true }
bytemuck = { workspace = true }
cgmath = { workspace = true }
raw-window-handle = { workspace = true }
wgpu = { workspace = true }
thiserror = { workspace = true }
ultraviolet = { workspace = true }
futures = { workspace = true }
gltf = { workspace = true }
image = { workspace = true }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+6 -25
View File
@@ -7,11 +7,11 @@ use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use crate::command_ring::CommandRing;
use crate::message::{MouseMessage, ResizeMessage, WheelMessage, WindowEvent};
use crate::platform::web::worker;
use crate::renderer::ResizeMessage;
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.
@@ -20,30 +20,11 @@ pub fn worker_window_event(kind: u32, values: js_sys::Float64Array) {
let values = values.to_vec();
let value = |index: usize| values.get(index).copied().unwrap_or_default();
let event = match kind {
0 => WindowEvent::Resize(ResizeMessage {
0 => ResizeMessage {
width: value(0),
height: value(1),
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,
};
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.
pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bool) -> u32 {
pub fn worker_entrypoint() -> u32 {
let (sender, events) = mpsc::channel();
// The render worker owns this allocation for its entire lifetime. Publishing a
// 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();
WORKER_EVENTS.with(|worker_events| *worker_events.borrow_mut() = Some(sender));
spawn_local(async move {
worker::run_render_loop::<T>(events, ring, profile).await;
worker::run_render_loop(events, ring).await;
});
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 camera;
pub mod command_ring;
pub mod gltf;
pub mod message;
pub mod platform;
pub mod render_data;
pub mod render_graph;
@@ -10,6 +7,22 @@ pub mod renderer;
pub mod shared_snapshot;
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")]
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, {
clear_payloads,
discard_payload,
@@ -6,7 +6,7 @@ import initWasm, {
worker_main,
worker_memory,
worker_window_event,
} from "/level-editor/pkg/level_editor.js";
} from "/renderer/pkg/renderer.js";
function listenerReady() {
if (state !== "waiting-listener") return;
@@ -30,7 +30,7 @@ addEventListener("message", async (event) => {
}
if (state !== "uninitialized") return;
state = "initializing";
const { canvas, profile } = message;
const { canvas } = message;
// The renderer worker exclusively owns the one WASM instance. Other threads
// receive only its shared memory and mutate the published SAB layouts.
@@ -43,7 +43,7 @@ addEventListener("message", async (event) => {
state = "waiting-listener";
pending.push({ type: "canvas", canvas });
try {
const ringPtr = worker_main(profile);
const ringPtr = worker_main();
postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr });
setTimeout(listenerReady, 0);
} catch (error) {
+3 -9
View File
@@ -1,5 +1,5 @@
use crate::command_ring::CommandRing;
use crate::message::WindowEvent;
use crate::renderer::ResizeMessage;
use log::info;
use std::sync::mpsc::Receiver;
use std::{cell::RefCell, rc::Rc};
@@ -7,18 +7,12 @@ use wasm_bindgen::{prelude::*, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::MessageEvent;
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
profile: bool,
) {
pub async fn run_render_loop(events_chan: Receiver<ResizeMessage>, ring: &'static CommandRing) {
use crate::renderer::Renderer;
let canvas = wait_for_canvas_transfer().await;
let renderer = Rc::new(RefCell::new(
Renderer::<T>::new(canvas, events_chan, profile).await,
));
let renderer = Rc::new(RefCell::new(Renderer::new(canvas, events_chan).await));
renderer.borrow_mut().command_ring = Some(ring);
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 range_allocator;
pub(crate) mod upload;
pub use handle::{InstanceHandle, MeshHandle};
@@ -32,7 +34,7 @@ pub const IDENTITY_NORMAL_MATRIX: NormalMatrix =
pub struct MaterialKey(u32);
impl MaterialKey {
/// The glTF/default material.
/// The default material.
pub const DEFAULT: Self = Self(0);
pub const fn new(value: u32) -> Self {
@@ -428,6 +430,9 @@ impl RenderData {
/// Creates an empty transactional successor whose handles cannot alias this data.
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 mut stage = Self::new(RenderDataConfig {
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) {
out.push_str(&serde_json::to_string(value).expect("strings always serialize"));
}
#[cfg(test)]
fn push_json(out: &mut String, value: &Value) {
match value {
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.
#[cfg(test)]
pub fn serialize(graph: &Graph) -> String {
let mut out = format!("(yawn-graph {AST_VERSION}\n (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| {
contract.is_raster_draw() || contract.fullscreen_policy.is_some() || name == "frame_out"
if super::contract(name).is_some_and(|contract| {
!contract.is_raster_draw()
&& contract.fullscreen_policy.is_none()
&& name != "frame_out"
}) {
return Err(GraphError::new(
"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());
+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 invalid =
|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)?,
}
}
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 =
serde_json::from_value(node.parameters.clone()).map_err(invalid)?;
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,
}
}
key if contract(key)
key if contract_for(key, pipelines)
.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(|| {
error(
"GRAPH_PARAMETERS_INVALID",
@@ -909,7 +913,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.iter()
.enumerate()
.map(|(i, n)| {
contract(&n.executor.key).ok_or_else(|| {
contract_for(&n.executor.key, &graph.pipelines).ok_or_else(|| {
error(
"GRAPH_UNKNOWN_EXECUTOR",
"unknown executor",
@@ -931,7 +935,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
.nodes
.iter()
.enumerate()
.map(|(i, n)| decode(n, i))
.map(|(i, n)| decode(n, i, &graph.pipelines))
.collect::<Result<_, _>>()?;
if graph
.nodes
+24 -11
View File
@@ -202,17 +202,6 @@ pub static CONTRACTS: &[Contract] = &[
c!("mesh", 2, Source, NONE_I, MESH_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!("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!(
"and",
2,
@@ -443,3 +432,27 @@ pub static CONTRACTS: &[Contract] = &[
pub fn contract(key: &str) -> Option<&'static Contract> {
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 schema;
pub use ast::{parse as parse_ast, serialize as serialize_ast};
pub use compiler::{compile, parse_and_compile};
pub use contracts::*;
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)
}
fn execution_supported(key: &str) -> bool {
contract(key).is_some_and(|contract| {
fn execution_supported(graph: &CompiledGraph, key: &str) -> bool {
contract_for(key, &graph.pipelines).is_some_and(|contract| {
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 { .. }))
.count()
== 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
|| !exact_output
|| !matches!(&source.origin,
@@ -787,7 +787,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
}
}
for (i, execution) in graph.executions.iter().enumerate() {
if !execution_supported(&execution.executor.key) {
if !execution_supported(graph, &execution.executor.key) {
return Err(error(
"GRAPH_EXECUTION_UNSUPPORTED",
"unsupported execution",
@@ -926,7 +926,8 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
Ok(())
}
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 {
return Err(invalid(
"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 input_contract = contract(&execution.executor.key)
let input_contract = contract_for(&execution.executor.key, &graph.pipelines)
.expect("supported executor has contract")
.inputs
.iter()
@@ -1125,7 +1126,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
.count()
== 1
})
&& contract(&owner_executions[0].executor.key)
&& contract_for(&owner_executions[0].executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw)
&& owner_executions[0].executor.version == 2
&& graph
@@ -1135,7 +1136,7 @@ fn validate_canonical_plan(graph: &CompiledGraph) -> Result<(), GraphError> {
.filter(|input| input.resource == source)
.count()
== 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))
.is_some_and(|input| {
input.name == *socket
@@ -1365,7 +1366,7 @@ fn validate_instance_traversal(graph: &CompiledGraph) -> Result<(), GraphError>
.iter()
.enumerate()
.filter_map(|(i, e)| {
contract(&e.executor.key)
contract_for(&e.executor.key, &graph.pipelines)
.is_some_and(Contract::is_raster_draw)
.then_some(i as u32)
})
@@ -1693,8 +1694,8 @@ pub fn prepare_runtime_plan(
for (i, execution) in graph.executions.iter().enumerate() {
let path = format!("executions[{i}]");
match execution.executor.key.as_str() {
key if contract(key).is_some_and(Contract::is_raster_draw) => {}
_ if contract(&execution.executor.key)
key if contract_for(key, &graph.pipelines).is_some_and(Contract::is_raster_draw) => {}
_ if contract_for(&execution.executor.key, &graph.pipelines)
.is_some_and(|contract| contract.fullscreen_policy.is_some()) => {}
"frame_out" => {
if frame_out_index.replace(i).is_some() {
@@ -1728,7 +1729,9 @@ pub fn prepare_runtime_plan(
validate_instance_traversal(graph)?;
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;
}
let NormalizedParameters::Raster {
+2
View File
@@ -43,6 +43,8 @@ pub struct RenderPipelineDeclaration {
pub fragment_entry: String,
#[serde(default)]
pub double_sided: bool,
#[serde(default)]
pub material: bool,
}
/// 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 })
}
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 {
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"),
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]}),
@@ -54,7 +63,7 @@ pub(crate) fn full_cull_graph() -> Value {
node("visible", "not", 1, json!({"operandDefault":false}), json!({"operand":input("cull","isFrustumCulled")})),
node("class", "and", 2, json!({}),
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!({"mesh":input("mesh","mesh"),"predicate":input("class","value"),"color":input("color","texture"),"depth":input("depth","texture")})),
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() {
assert_eq!(contract("mesh").unwrap().version, 2);
assert!(contract("pipeline").is_none());
for key in [
"ground_plane",
"gltf_standard",
"gltf_standard_double_sided",
] {
let contract = contract(key).unwrap();
assert!(contract("material").is_none());
let pipelines: PipelineDeclarations =
serde_json::from_value(render_pipeline_declarations()).unwrap();
for key in ["unlit", "material", "material_double_sided"] {
let contract = contract_for(key, &pipelines).unwrap();
assert_eq!(contract.version, 2);
assert!(contract.is_raster_draw());
}
@@ -90,7 +98,7 @@ fn catalog_exposes_final_mesh_pipeline_and_generic_expression_contracts() {
("localAabb", SemanticType::LocalAabb)
]
);
let predicate = contract("gltf_standard")
let predicate = contract_for("material", &pipelines)
.unwrap()
.inputs
.iter()
@@ -376,7 +384,7 @@ fn pipeline_predicate_defaults_true_and_expression_edges_are_validated() {
#[test]
fn raster_executors_reject_removed_pipeline_parameter() {
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();
assert_eq!(error.code, "GRAPH_PARAMETERS_INVALID");
assert_eq!(error.details["path"], "nodes[8].parameters");
@@ -426,7 +434,7 @@ fn sibling_raster_writers_form_one_ordered_physical_pass() {
9,
node(
"sibling",
"ground_plane",
"unlit",
2,
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")}),
@@ -476,12 +484,13 @@ fn expression_provenance_rejects_cross_mesh_values() {
}
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("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!({"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!({"mesh":input("mesh","mesh"),"color":input("first","color"),"depth":input("first","depth")})),
node("frame", "frame_out", 3,
@@ -493,12 +502,12 @@ fn implicit_pipeline_graph() -> Value {
fn three_raster_graph() -> Value {
let mut graph = full_cull_graph();
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(
9,
node(
"standard",
"gltf_standard",
"material",
2,
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")}),
@@ -508,7 +517,7 @@ fn three_raster_graph() -> Value {
10,
node(
"double",
"gltf_standard_double_sided",
"material_double_sided",
2,
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")}),
@@ -857,7 +866,9 @@ fn runtime_rejects_noncanonical_physical_passes() {
#[test]
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.inputs[0].default_policy, InputDefaultPolicy::None);
assert_eq!(
@@ -1115,7 +1126,7 @@ fn fullscreen_output_is_a_valid_raster_attachment_root() {
11,
node(
"later",
"ground_plane",
"unlit",
2,
json!({"depthCompare":"less_equal","depthWriteEnabled":true,"clearDepth":1.0,"clearColor":[0,0,0,1],"predicateDefault":true}),
json!({
+1 -1
View File
@@ -1,3 +1,3 @@
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::{
gpu_scene::GpuSceneCache, material::MaterialResources, ActiveCompiledGraph, PipelineLibrary,
PreparedExecution,
frame_data::FrameData, gpu_scene::GpuSceneCache, material::MaterialResources,
ActiveCompiledGraph, PipelineLibrary, PreparedExecution,
};
use super::super::scene::Scene;
pub(crate) fn encode_compiled<T: Scene>(
pub(crate) fn encode_compiled(
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::TextureView,
active: &ActiveCompiledGraph,
scene: &T,
frame_data: &FrameData,
gpu: &GpuSceneCache,
pipelines: &PipelineLibrary,
materials: &MaterialResources,
planes: Option<&[[f32; 4]; 6]>,
mut profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) -> Result<(), &'static str> {
use crate::render_graph::{NormalizedColorLoad, NormalizedDepthLoad, StoreOp};
for compute in &active.compute {
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some(&compute.name),
timestamp_writes: profile
.as_deref_mut()
.and_then(|profile| profile.compute_writes(&compute.name)),
timestamp_writes: None,
});
pass.set_pipeline(&compute.pipeline);
pass.dispatch_workgroups(
@@ -150,7 +145,7 @@ pub(crate) fn encode_compiled<T: Scene>(
color_attachments: &colors,
depth_stencil_attachment: depth,
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 {
match active
@@ -178,7 +173,7 @@ pub(crate) fn encode_compiled<T: Scene>(
.instance_traversal
.as_ref()
.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, &[]);
}
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(())
}
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 bytemuck::{Pod, Zeroable};
use image::DynamicImage;
use wgpu::util::DeviceExt;
use crate::{
gltf::{AlphaMode, ImageSource, ImportedScene, Material, SamplerMetadata, TextureReference},
render_data::MaterialKey,
use crate::render_data::{
upload::{AddressMode, FilterMode, Material, MaterialState, RenderDataUpload, SamplerMetadata},
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)]
pub enum MaterialError {
#[error("external image sources are unsupported")]
ExternalImage,
#[error("unsupported image MIME type: {0}")]
Mime(String),
#[error("image decode failed: {0}")]
@@ -97,8 +20,13 @@ pub enum MaterialError {
InvalidRgba(&'static str),
}
struct MaterialBinding {
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
pub(super) struct PreparedMaterials {
groups: HashMap<MaterialKey, wgpu::BindGroup>,
groups: HashMap<MaterialKey, MaterialBinding>,
textures: Vec<wgpu::Texture>,
views: Vec<[wgpu::TextureView; 2]>,
samplers: Vec<wgpu::Sampler>,
@@ -106,8 +34,8 @@ pub(super) struct PreparedMaterials {
pub struct MaterialResources {
pub layout: wgpu::BindGroupLayout,
groups: HashMap<MaterialKey, wgpu::BindGroup>,
fallback: wgpu::BindGroup,
groups: HashMap<MaterialKey, MaterialBinding>,
fallback: MaterialBinding,
fallback_views: Vec<wgpu::TextureView>,
fallback_sampler: wgpu::Sampler,
textures: Vec<wgpu::Texture>,
@@ -208,12 +136,18 @@ fn slot_uses_srgb(slot: usize) -> bool {
matches!(slot, 0 | 4)
}
fn address(value: &str) -> wgpu::AddressMode {
fn address(value: AddressMode) -> wgpu::AddressMode {
match value {
"ClampToEdge" => wgpu::AddressMode::ClampToEdge,
"MirroredRepeat" => wgpu::AddressMode::MirrorRepeat,
"Repeat" => wgpu::AddressMode::Repeat,
_ => unreachable!("gltf crate returned unknown wrap"),
AddressMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
AddressMode::MirrorRepeat => wgpu::AddressMode::MirrorRepeat,
AddressMode::Repeat => wgpu::AddressMode::Repeat,
}
}
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,
),
|m| {
let mag = match m.mag_filter.as_deref() {
Some("Nearest") => wgpu::FilterMode::Nearest,
Some("Linear") | None => wgpu::FilterMode::Linear,
_ => unreachable!(),
};
let (min, mip) = match m.min_filter.as_deref() {
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))
(
filter(m.mag_filter),
filter(m.min_filter),
filter(m.mipmap_filter),
address(m.address_u),
address(m.address_v),
)
},
);
wgpu::SamplerDescriptor {
@@ -289,7 +207,7 @@ impl MaterialResources {
));
}
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("glTF material group 2"),
label: Some("render-data material group 2"),
entries: &entries,
});
let colors = [[255, 255, 255, 255], [128, 128, 255, 255], [0, 0, 0, 255]];
@@ -333,11 +251,11 @@ impl MaterialResources {
material: &Material,
views: [&wgpu::TextureView; 5],
samplers: [&wgpu::Sampler; 5],
) -> wgpu::BindGroup {
) -> MaterialBinding {
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("material uniform"),
contents: bytemuck::bytes_of(&GpuMaterial::from(material)),
usage: wgpu::BufferUsages::UNIFORM,
contents: bytemuck::bytes_of(&MaterialState::from(material)),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
@@ -355,30 +273,28 @@ impl MaterialResources {
resource: wgpu::BindingResource::Sampler(sampler),
});
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("material bind group"),
layout,
entries: &entries,
})
});
MaterialBinding { group, uniform }
}
pub(super) fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
scene: &ImportedScene,
scene: &RenderDataUpload,
) -> Result<PreparedMaterials, MaterialError> {
let max_dimension = device.limits().max_texture_dimension_2d;
let mut textures = Vec::with_capacity(scene.images.len());
let mut views = Vec::with_capacity(scene.images.len());
for image in &scene.images {
if !matches!(image.source, ImageSource::BufferView(_)) {
return Err(MaterialError::ExternalImage);
}
let format = match image.mime_type.as_deref() {
Some("image/png") => image::ImageFormat::Png,
Some("image/jpeg") => image::ImageFormat::Jpeg,
other => return Err(MaterialError::Mime(other.unwrap_or("missing").into())),
let format = match image.mime_type.as_str() {
"image/png" => image::ImageFormat::Png,
"image/jpeg" => image::ImageFormat::Jpeg,
other => return Err(MaterialError::Mime(other.into())),
};
let decoded = image::load_from_memory_with_format(&image.encoded_data, format)?;
let (width, height, rgba) = normalize_rgba(decoded);
@@ -386,7 +302,7 @@ impl MaterialResources {
let (texture, image_views) = upload_rgba(
device,
queue,
"glTF embedded image",
"render-data image",
width,
height,
bytes_per_row,
@@ -464,7 +380,24 @@ impl MaterialResources {
}
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]
fn material_uniform_is_112_bytes() {
assert_eq!(std::mem::size_of::<GpuMaterial>(), 112);
assert_eq!(std::mem::size_of::<MaterialState>(), 112);
}
#[test]
fn texcoord_one_disables_slot() {
let mut m = Material::default();
m.base_color_texture = Some(TextureReference {
m.base_color_texture = Some(crate::render_data::upload::TextureReference {
texture: 0,
tex_coord: 1,
});
assert_eq!(GpuMaterial::from(&m).flags[0] & BASE, 0);
assert_eq!(MaterialState::from(&m).flags[0] & 1, 0);
}
#[test]
fn material_packing_includes_ior_f0_flags_and_uv_sets() {
let mut m = Material::default();
m.ior = 2.0;
m.double_sided = true;
m.normal_texture = Some(TextureReference {
m.normal_texture = Some(crate::render_data::upload::TextureReference {
texture: 4,
tex_coord: 0,
});
let gpu = GpuMaterial::from(&m);
let gpu = MaterialState::from(&m);
assert_eq!(gpu.alpha_optics[2], 2.0);
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.uv_sets[2], 0);
}
@@ -541,18 +474,18 @@ mod tests {
fn explicit_ior_sentinel_packs_unit_f0() {
let mut material = Material::default();
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[3], 1.0);
}
#[test]
fn sampler_translation_is_exact() {
let m = SamplerMetadata {
index: 0,
mag_filter: Some("Nearest".into()),
min_filter: Some("LinearMipmapNearest".into()),
wrap_s: "ClampToEdge".into(),
wrap_t: "MirroredRepeat".into(),
mag_filter: FilterMode::Nearest,
min_filter: FilterMode::Linear,
mipmap_filter: FilterMode::Nearest,
address_u: AddressMode::ClampToEdge,
address_v: AddressMode::MirrorRepeat,
};
let d = sampler_descriptor(Some(&m));
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
}
/// 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) {
let base = self
.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();
layouts.push(layout.clone());
let key = PipelineLayoutKey(self.next_layout);
@@ -184,12 +184,13 @@ impl PipelineLibrary {
self.material_layout = Some(key);
}
fn compatibility_spec(
fn authored_spec(
&self,
name: &str,
layouts: &[wgpu::VertexBufferLayout],
shader: &str,
format: wgpu::TextureFormat,
material: bool,
double_sided: bool,
) -> RenderPipelineSpec {
let stage = |entry: &str| OwnedProgrammableStage {
shader_source: shader.to_owned(),
@@ -198,7 +199,7 @@ impl PipelineLibrary {
zero_initialize_workgroup_memory: true,
};
RenderPipelineSpec {
layout: if name.starts_with("gltf_") {
layout: if material {
self.material_layout.or(self.default_layout)
} else {
self.default_layout
@@ -214,7 +215,7 @@ impl PipelineLibrary {
.collect(),
fragment: Some(stage("fs_main")),
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()
},
depth_stencil: Some(wgpu::DepthStencilState {
@@ -330,7 +331,7 @@ impl PipelineLibrary {
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(
&mut self,
device: &wgpu::Device,
@@ -338,14 +339,18 @@ impl PipelineLibrary {
layouts: &[wgpu::VertexBufferLayout],
format: wgpu::TextureFormat,
) -> PipelineKey {
let mut spec =
self.compatibility_spec(&declaration.name, layouts, &declaration.shader, format);
let mut spec = self.authored_spec(
layouts,
&declaration.shader,
format,
declaration.material,
declaration.double_sided,
);
spec.vertex.entry_point = declaration.vertex_entry.clone();
spec.fragment
.as_mut()
.expect("scene pipelines have fragment stages")
.expect("render-data pipelines have fragment stages")
.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))
}
@@ -463,11 +468,12 @@ mod tests {
use super::*;
fn spec() -> RenderPipelineSpec {
PipelineLibrary::new().compatibility_spec(
"x",
PipelineLibrary::new().authored_spec(
&[],
"shader",
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 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 VERSION: u32 = 1;
@@ -344,6 +348,8 @@ fn valid_name(value: &str) -> bool {
pub struct SharedSoaRegistry {
arrays: BTreeMap<String, SharedArray>,
next_id: u32,
layout_changed: bool,
material_keys: Vec<MaterialKey>,
published_instance_generations: BTreeMap<u32, u32>,
published_mesh_generations: BTreeMap<u32, u32>,
}
@@ -353,6 +359,8 @@ impl SharedSoaRegistry {
let mut registry = Self {
arrays: BTreeMap::new(),
next_id: 1,
layout_changed: false,
material_keys: Vec::new(),
published_instance_generations: BTreeMap::new(),
published_mesh_generations: BTreeMap::new(),
};
@@ -389,9 +397,28 @@ impl SharedSoaRegistry {
stride: Some(16),
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.publish_materials(&[Material::default()])?;
registry.material_keys.clear();
registry.layout_changed = false;
Ok(registry)
}
@@ -447,7 +474,7 @@ impl SharedSoaRegistry {
return Err(SharedSoaError::LayoutConflict);
}
if request.domain == ArrayDomain::Fixed {
existing.resize(capacity)?;
self.layout_changed |= existing.resize(capacity)?;
}
return existing.descriptor();
}
@@ -460,6 +487,7 @@ impl SharedSoaRegistry {
let array = SharedArray::new(id, request, stride / 4, capacity)?;
let descriptor = array.descriptor()?;
self.arrays.insert(name, array);
self.layout_changed = true;
Ok(descriptor)
}
@@ -514,12 +542,132 @@ impl SharedSoaRegistry {
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.
pub fn sync_capacities(
&mut self,
capacities: RenderDataCapacities,
) -> Result<bool, SharedSoaError> {
let mut changed = false;
let mut changed = std::mem::take(&mut self.layout_changed);
for array in self.arrays.values_mut() {
if array.request.domain == ArrayDomain::Fixed {
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]
fn custom_layouts_are_aligned_idempotent_and_conflict_checked() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
@@ -761,7 +986,7 @@ mod tests {
fn fixed_arrays_grow_and_publish_stable_byte_uploads() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let request = |length| ArrayRequest {
name: "upload.gltf".into(),
name: "upload.renderData".into(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::U32,
lanes: 4,
@@ -773,18 +998,18 @@ mod tests {
assert_eq!(first.id, second.id);
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();
array
.data_word(0, 0)
.store(u32::from_le_bytes(*b"glTF"), Ordering::Relaxed);
.store(u32::from_le_bytes(*b"YRDP"), Ordering::Relaxed);
array
.data_word(0, 1)
.store(u32::from_le_bytes([2, 0, 0, 0]), Ordering::Relaxed);
array.unlock(sequence);
assert_eq!(
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
[[crates]]
name = "level-editor"
name = "renderer"
root = "."
out-dir = "pkg"
target = "web"
+9 -13
View File
@@ -12,13 +12,15 @@ import {
createVelocityColumn,
customRenderPipelineExample,
defaultPipelineExample,
dropActiveGraph,
fluentGraphExample,
fxNodeExportExample,
importGltf,
installCameraRenderDataControls,
jsoGraphExample,
loadCompleteScene,
pickNearest,
conventionalSceneHandles,
restyleScene,
setVelocity,
simpleSceneShader,
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 core = {
compileGraph(source) {
@@ -74,10 +76,6 @@ test("cookbook graph lifecycle recipe serializes, switches, and drops", async ()
calls.push(["switch", id]);
return Promise.resolve();
},
switchToImmediate() {
calls.push(["immediate"]);
return Promise.resolve();
},
dropCompiledGraph(id) {
calls.push(["drop", id]);
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 compiled = await compileAndSwitch(core, graph);
await dropActiveGraph(core, compiled);
assert.match(calls[0][1], /^\(yawn-graph 1/);
assert.deepEqual(calls.slice(1), [
["switch", [3, 4]],
["immediate"],
["drop", [3, 4]],
]);
assert.deepEqual(calls.slice(1), [["switch", [3, 4]]]);
});
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, -1],
);
assert.deepEqual(picked.hits[0].instance.handle, [9, 3]);
assert.deepEqual(picked.hits[0].instance, [9, 3]);
class Port extends EventTarget {
postMessage() {}
@@ -183,5 +176,8 @@ test("cookbook picking and worker-to-worker recipes use public facades", async (
core.dispose();
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");
});
+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("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("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("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 { 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 { createCubeGeometry, encodeGeometryGlb } from "../examples/render-graph-studio/demo-loadouts.js";
class WorkerMock extends EventTarget {
messages = [];
@@ -18,7 +20,7 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
const memory = new SharedArrayBuffer(4096);
const descriptor = {
id: 9,
name: "upload.gltf",
name: "upload.renderData",
domain: "fixed",
scalar: "u32",
lanes: 4,
@@ -36,8 +38,8 @@ test("glTF addon stages fetched bytes in shared SOA and commits only metadata",
const calls = [];
const core = {
async allocateArray(layout) { calls.push(["allocate", layout]); return array; },
async commitGlbUpload(value, byteLength, options) {
calls.push(["commit", value, byteLength, options]);
async commitRenderDataUpload(value, byteLength) {
calls.push(["commit", value, byteLength]);
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 });
await tick();
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].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 });
assert.deepEqual(await loading, { meshes: [] });
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();
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 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 { MeshHandles } from "../addons/mesh-handles/src/index.js";
import { YawnCore } from "../packages/yawn-core/src/index.js";
const align16 = value => (value + 15) & ~15;
@@ -108,25 +109,27 @@ class WorkerMock extends EventTarget {
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 ring = 8192;
const ringHeader = new Int32Array(scene.memory.buffer, ring, 16);
ringHeader.set([0x4e574159, 2, 1024, 40]);
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 handles = new MeshHandles(client, { pickingWorkerFactory: () => bvhWorker });
rendererWorker.reply({ type: "soa-init", arrays: [] });
await client.ready;
rendererWorker.reply({ type: "snapshot-init", controlPtr: 0, controlVersion: 1, schemaVersion: 2 });
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");
bvhWorker.reply({ type: "pick", request: request.request, epoch: 1, stale: false, hits: [{ slot: 10, generation: 3, distance: 2 }] });
const result = await picking;
assert.equal(result.epoch, 1);
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();
assert.equal(bvhWorker.terminated, true);
});
+80 -20
View File
@@ -1,10 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { YawnCore, RendererError } from "@yawn/core";
import { MeshHandles } from "@yawn/mesh-handles";
import * as coreApi from "@yawn/core";
import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
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 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,
});
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,
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 bridge = { memory, ringPtr: 0, worker, freed: false, free() { this.freed = true; } };
const core = new YawnCore(bridge);
worker.reply({ type: "soa-init", arrays: [transform, type, generation, meshGeneration, upload] });
return { memory, ring, worker, bridge, core, handles: new MeshHandles(core), transform, type, generation, 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, camera, material };
}
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({
type: "reply",
request: 1,
@@ -93,6 +113,7 @@ async function imported(fixture) {
defaultInstance: [8, 5],
defaultType: TYPE,
}],
materials: [{ key: 1 }],
},
});
const [mesh] = fixture.handles.fromImportedScene(await loading);
@@ -105,10 +126,10 @@ async function imported(fixture) {
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 pending = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8, { framing: "interior" });
assert.deepEqual([...new Int32Array(fixture.memory.buffer, 64, 6)], [2, 1, 1, 5, 8, 1]);
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, 0]);
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { 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]);
});
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 () => {
const fixture = setup();
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);
});
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 () => {
const fixture = setup();
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 second = fixture.core.dropCompiledGraph([2, 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 });
await first;
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
: undefined;
const serverPort = Number(process.env.PORT) || 8080;
const wasmSource = path.resolve(__dirname, "level-editor/pkg");
const exampleRoot = path.resolve(__dirname, "examples/render-graph-studio");
const wasmDestination = path.resolve(exampleRoot, "level-editor/pkg");
const wasmSource = path.resolve(__dirname, "renderer/pkg");
const exampleRoot = path.resolve(__dirname, "examples");
const wasmDestination = path.resolve(exampleRoot, "renderer/pkg");
const buildOutput = path.resolve(__dirname, "dist");
// Vite resolves entry imports before plugin build hooks. Mirror synchronously while
// loading the config so a clean build always sees the complete generated package.
@@ -29,6 +30,21 @@ function freshWasmPackage() {
return {
name: "fresh-wasm-package",
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) {
server.watcher.add(wasmSource);
const update = file => {
@@ -46,14 +62,18 @@ export default defineConfig({
build: {
rollupOptions: {
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'.
outDir: "../../dist",
outDir: "../dist",
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,
// 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.
@@ -80,10 +100,6 @@ export default defineConfig({
__dirname,
"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/mesh-handles": path.resolve(
__dirname,
@@ -97,7 +113,6 @@ export default defineConfig({
__dirname,
"addons/default-pipelines/src/index.js",
),
pkg: path.resolve(__dirname, "level-editor/pkg"),
},
},
plugins: [