Rebuild core around render graph AST and shared memory

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-18 11:58:31 +00:00
co-authored by heaust
parent a23ef37b4d
commit 0e44917f9e
101 changed files with 4409 additions and 2610 deletions
+1
View File
@@ -101,6 +101,7 @@ toolchain="$(sed -n 's/^channel = "\([^"]*\)"/\1/p' rust-toolchain.toml)"
echo "Installing Rust toolchain ${toolchain}..."
rustup toolchain install "$toolchain" --profile minimal \
--component rust-src \
--component rustfmt \
--target wasm32-unknown-unknown
install_wasm_pack
+126 -60
View File
@@ -1,69 +1,135 @@
# yawn
# Yawn
yet another webgl ngine
Yawn is a Rust/WGPU renderer whose application boundary is worker messages plus
shared WebAssembly memory. Backward compatibility is intentionally deferred until
1.0.
### Building
## Architecture
Run
```text
FXNode ───────────────┐
├─> canonical DAG AST ─> S-expression ─> Yawn render worker
JavaScript objects ──┘ │
├─> graph compiler
├─> transient allocator
└─> prepared GPU loadout
Any browser thread ── infrequent commands ──────────────────> worker
Any browser thread ── atomic SOA writes ────────────────────> shared WASM memory
glTF import worker ── fetch URL ──> fixed shared SOA upload ─┘
```
The canonical AST is the only public render-graph wire format. Nodes are named
definitions and `(ref "node" "socket")` forms are edges, so an output can fan out
without expanding into a tree. Core parses the S-expression, validates the DAG,
culls dead work, calculates resource lifetimes, aliases compatible non-overlapping
transients, coalesces render passes, and allocates the resulting textures and GPU
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.
## Packages
- `packages/yawn-core` (`@yawn/core`) — the worker command transport, serialized
graph lifecycle, and shared SOA views; it returns `[slot, generation]` 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.
The integration example in `examples/render-graph-studio` consumes every package
through its public API; no example source or shader lives in core.
The focused recipes in `examples/cookbook` show each addon independently, including
AST/JSO/FXNode authoring, external render and compute programs, graph activation,
shared glTF import, mesh instances, custom SOA columns, SAB animation, picking, and
worker-to-worker use.
Example graph authoring:
```js
import { RenderGraph, ref } from "@yawn/render-graph-js";
import { defaultPipelines } from "@yawn/default-pipelines";
const graph = new RenderGraph("main", 1)
.renderPipeline(defaultPipelines.render[1])
.renderPipeline(defaultPipelines.render[2])
.renderPipeline(defaultPipelines.render[3])
.computePipeline({
name: "prepare",
shader: "@compute @workgroup_size(1) fn main() {}",
entry: "main",
dispatch: [1, 1, 1],
})
.node("mesh", "mesh", { version: 2 })
.node("draw", "gltf_standard", {
version: 2,
inputs: { mesh: [ref("mesh", "mesh")] },
});
// Add the required attachments and frame output, then let the addon own the wire encoding:
await graph.load(core);
```
## Shared render data
`@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.
Allocate application columns infrequently through the worker:
```js
const velocity = await core.allocateArray({
name: "instance.velocity",
domain: "instance", // also "mesh" or "fixed"
scalar: "f32",
lanes: 4,
});
velocity.write(instanceSlot, [1, 0, 0, 0]);
```
Import a GLB without transferring its bytes through renderer messages:
```js
import { GltfImporter } from "@yawn/gltf-import";
import { MeshHandles } from "@yawn/mesh-handles";
const importer = new GltfImporter(core);
const handles = new MeshHandles(core);
const meshes = handles.fromImportedScene(await importer.load(gltfUrl));
meshes[0].defaultInstance.setTransform(nextTransform); // direct shared-SOA write
```
The renderer grows mesh/instance-domain columns with render-data capacity and
publishes replacement descriptors through the core's `yawn-soa-layout` event.
Typed-array views refresh when shared WASM memory grows. Messages are reserved for
allocation and lifecycle operations. Existing instance values and bulk asset uploads
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`.
Cross-origin isolation is required (`COOP: same-origin`, `COEP: require-corp`). The
Vite development and preview servers already set both headers.
## Development
```sh
npm run dev
npm run test:js
cargo check --workspace
```
- Open http://localhost:8080
- Write rust and see it in the browser
Production build:
## accepted plans
- most of the logic in workers and wasm
- rust / wgpu based
- as declarative as possible
- no backwards compatibility until v1.0
- as much geometric algebra as reasonable
## planned milestone
- [done] connect events from main thread to worker
- [done] share canvas b/w worker
- [done] render triangle
## Goal: All the good algorithms
What algorithms are we planning to have,
- gpu picking?
- HZB occlusion + frustum/portal culling?
- deferred + forward lighting?
- hair/fur support?
- splines?
- good auto lod (how?)
- auto billboarding?
- selective raytracing? (maybe to bake static lighting on init?)
- Global Illumination? (how?)
- postprocesses?
- physics?
- simulations? (fluid, cloth, rigid body, wind)
- g-splats?
- volumetrics? (openvdb?)
- edge detection?
- SDFs?
- animation/bones?
- spatial audio?
- particles?
- instancing?
- procedural gen? (providing noise textures, perlin, voronoi)
- mesh edits/CSG?
- glass/refraction?
- nanites?
- caching?
- streaming mesh data? (for progressive loading/nanites/volumetrics/sectors/occlusion-optimised loading)
- server components? (websockets for mesh streaming)
- collision sounds?
- HDR/LDR rendering?
- tonemapping?
- lighting bsdf? materials?
- trails?
- vfx?
- vr/ar?
- dynamic textures?
- shadow casting?
```sh
npm run build-release
```
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@yawn/default-pipelines",
"version": "0.1.0",
"description": "Optional graph-authored scene, frame, and compute pipelines for Yawn",
"type": "module",
"exports": "./src/index.js"
}
+86
View File
@@ -0,0 +1,86 @@
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 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>;
@group(2) @binding(0) var<uniform> material: MaterialData;
@group(2) @binding(1) var base_tex: texture_2d<f32>;
@group(2) @binding(2) var mr_tex: texture_2d<f32>;
@group(2) @binding(3) var normal_tex: texture_2d<f32>;
@group(2) @binding(4) var occlusion_tex: texture_2d<f32>;
@group(2) @binding(5) var emissive_tex: texture_2d<f32>;
@group(2) @binding(6) var base_sampler: sampler;
@group(2) @binding(7) var mr_sampler: sampler;
@group(2) @binding(8) var normal_sampler: sampler;
@group(2) @binding(9) var occlusion_sampler: sampler;
@group(2) @binding(10) var emissive_sampler: sampler;
struct VertexInput { @location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) model_col0: vec4<f32>, @location(4) model_col1: vec4<f32>, @location(5) model_col2: vec4<f32>, @location(6) model_col3: vec4<f32>, @location(7) normal_col0: vec4<f32>, @location(8) normal_col1: vec4<f32>, @location(9) normal_col2: vec4<f32>, @location(10) tangent: vec4<f32> }
struct VertexOutput { @builtin(position) clip_position: vec4<f32>, @location(0) world_pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) tangent: vec3<f32>, @location(3) bitangent: vec3<f32>, @location(4) uv: vec2<f32>, @location(5) @interpolate(flat) determinant_sign: f32 }
fn safe_normalize(v: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); }
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
let linear = mat3x3<f32>(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
let world = model * vec4<f32>(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3<f32>(0,1,0)); let raw_t = linear * in.tangent.xyz;
var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3<f32>(0,1,0), vec3<f32>(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3<f32>(1,0,0));
out.clip_position = view_proj * world; out.world_pos = world.xyz; out.normal = n; out.tangent = t; out.bitangent = safe_normalize(cross(n,t), vec3<f32>(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out;
}
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
fn sample_closure(uv: vec2<f32>) -> Closure {
let bits = material.flags.x; var c: Closure;
c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
let mr = select(vec4<f32>(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2<f32>(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1));
c.normal_map = select(vec3<f32>(0.5,0.5,1), textureSample(normal_tex, normal_sampler, uv).xyz, (bits & 4u) != 0u);
let occ = select(1.0, textureSample(occlusion_tex, occlusion_sampler, uv).r, (bits & 8u) != 0u); c.ao = mix(1.0, occ, material.surface_factors.w);
c.emissive = material.emissive_factor.rgb * select(vec3<f32>(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c;
}
fn schlick(f0: vec3<f32>, v_h: f32) -> vec3<f32> { return f0 + (vec3<f32>(1)-f0) * pow(1.0-clamp(v_h,0,1),5.0); }
fn ggx_d(n_h_input: f32, a: f32) -> f32 { let n_h=clamp(n_h_input,0.0,1.0); let a2=a*a; let nh2=n_h*n_h; let q=(1.0-nh2)+a2*nh2; return a2/(3.14159265*q*q); }
fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); }
@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0;
let n=safe_normalize(mat3x3<f32>(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3<f32>(map.xy*material.surface_factors.z,map.z),vec3<f32>(0,0,1)),in.normal)*orientation;
let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3<f32>(0.35,1,0.45),vec3<f32>(0,1,0)); let h=safe_normalize(v+l,n);
let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y;
let f0=mix(vec3<f32>(material.alpha_optics.w),c.base.rgb,c.mr.x); let direct_f=schlick(f0,vh); let env_f=schlick(f0,nv); let spec=direct_f*ggx_d(nh,a)*smith_v(nv,nl,a); let diffuse=(vec3<f32>(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265;
let sun=(diffuse+spec)*nl*vec3<f32>(3.0,2.85,2.65); let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3<f32>(0.055,0.045,0.035),vec3<f32>(0.24,0.36,0.58),up);
let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3<f32>(0.04,0.035,0.03),vec3<f32>(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y);
let color=sun+(vec3<f32>(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky*c.ao+env_spec*c.ao+c.emissive; return vec4<f32>(color,1.0);
}`;
export const groundShader = /* wgsl */ `
@group(0) @binding(0) var<uniform> application: array<vec4<f32>, 3>;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
struct Input { @location(0) position: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) c0: vec4<f32>, @location(4) c1: vec4<f32>, @location(5) c2: vec4<f32>, @location(6) c3: vec4<f32> }
struct Output { @builtin(position) position: vec4<f32>, @location(0) normal: vec3<f32> }
@vertex fn vs_main(input: Input) -> Output { var output: Output; output.position=view_proj*mat4x4<f32>(input.c0,input.c1,input.c2,input.c3)*vec4(input.position,1); output.normal=input.normal; return output; }
@fragment fn fs_main(input: Output) -> @location(0) vec4<f32> { return vec4(vec3(0.12)+max(input.normal.y,0.0)*vec3(0.16),1); }
`;
export const frameShader = /* wgsl */ `
@group(0) @binding(0) var source_texture: texture_2d<f32>;
@group(0) @binding(1) var second_texture: texture_2d<f32>;
@group(0) @binding(2) var linear_clamp: sampler;
struct Parameters { values: array<vec4<f32>, 8> }
@group(0) @binding(3) var<uniform> parameters: Parameters;
struct VertexOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
@vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { let positions=array(vec2(-1.0,-1.0),vec2(3.0,-1.0),vec2(-1.0,3.0)); let p=positions[index]; var out:VertexOut; out.position=vec4(p,0,1); out.uv=p*vec2(0.5,-0.5)+vec2(0.5); return out; }
fn aces(x:vec3<f32>)->vec3<f32>{return clamp((x*(2.51*x+vec3(0.03)))/(x*(2.43*x+vec3(0.59))+vec3(0.14)),vec3(0),vec3(1));}
fn linear_to_srgb(x:vec3<f32>)->vec3<f32>{let safe=clamp(x,vec3(0),vec3(1));return select(1.055*pow(safe,vec3(1.0/2.4))-vec3(0.055),safe*12.92,safe<=vec3(0.0031308));}
@fragment fn fs_frame_out(in:VertexOut)->@location(0) vec4<f32>{let sampled=textureSampleLevel(source_texture,linear_clamp,in.uv,0);var rgb=max(sampled.rgb*exp2(parameters.values[0].z),vec3(0));if parameters.values[0].x>0.5{if parameters.values[0].y>1.5{rgb=aces(rgb);}else if parameters.values[0].y>0.5{rgb=rgb/(vec3(1)+rgb);}}if parameters.values[0].w>0.5{rgb=linear_to_srgb(rgb);}return vec4(clamp(rgb,vec3(0),vec3(1)),clamp(sampled.a,0,1));}
`;
export const noopComputeShader = /* wgsl */ `@compute @workgroup_size(1) fn main() {}`;
/** Optional declarations copied into each graph that wants these implementations. */
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: "frame_out", shader: frameShader, vertexEntry: "vs_main", fragmentEntry: "fs_frame_out" }),
]),
compute: Object.freeze([
Object.freeze({ name: "initialize_scene", shader: noopComputeShader, entry: "main", dispatch: [1, 1, 1] }),
]),
});
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@yawn/gltf-import",
"version": "0.1.0",
"description": "glTF fetch worker that uploads directly into Yawn shared SOA memory",
"type": "module",
"exports": "./src/index.js"
}
+101
View File
@@ -0,0 +1,101 @@
export class GltfImportError extends Error {
constructor(code) {
super(code);
this.name = "GltfImportError";
this.code = code;
}
}
/** Fetches glTF in a dedicated worker and commits only shared-memory upload metadata. */
export class GltfImporter {
#core;
#worker;
#next = 1;
#pending = new Map();
#tail = Promise.resolve();
#disposed = false;
constructor(core, { workerFactory } = {}) {
if (!core?.allocateArray || !core?.commitGlbUpload) throw new TypeError("core must implement the Yawn shared upload protocol");
this.#core = core;
this.#worker = workerFactory
? workerFactory()
: new Worker(new URL("./worker.js", import.meta.url), {
type: "module",
name: "yawn-gltf-import",
});
this.#worker.addEventListener("message", event => this.#message(event.data));
this.#worker.addEventListener("error", () => this.#fail("GLTF_WORKER_ERROR"));
this.#worker.addEventListener("messageerror", () => this.#fail("GLTF_WORKER_ERROR"));
this.#worker.start?.();
}
load(url, options = {}) {
if (this.#disposed) return Promise.reject(new GltfImportError("DISPOSED"));
const source = url instanceof URL ? url.href : url;
if (typeof source !== "string" || !source) return Promise.reject(new TypeError("url must be a URL or nonempty string"));
const operation = this.#tail.then(() => this.#start(source, options));
this.#tail = operation.catch(() => {});
return operation;
}
#start(url, options) {
let request = this.#next++ >>> 0;
if (!request) request = this.#next++ >>> 0;
return new Promise((resolve, reject) => {
this.#pending.set(request, { resolve, reject, options, array: null });
this.#worker.postMessage({ type: "load", request, url });
});
}
async #message(message) {
const pending = this.#pending.get(message?.request);
if (!pending) return;
try {
if (message.type === "allocate") {
const length = Math.ceil(message.byteLength / 16);
pending.array = await this.#core.allocateArray({
name: "upload.gltf",
domain: "fixed",
scalar: "u32",
lanes: 4,
stride: 16,
length,
});
this.#worker.postMessage({
type: "storage",
request: message.request,
...pending.array.share(),
});
} else if (message.type === "ready") {
const result = await this.#core.commitGlbUpload(
pending.array,
message.byteLength,
pending.options,
);
this.#pending.delete(message.request);
pending.resolve(result);
} else if (message.type === "error") {
this.#pending.delete(message.request);
pending.reject(new GltfImportError(message.code || "GLTF_IMPORT_FAILED"));
}
} catch (error) {
this.#pending.delete(message.request);
pending.reject(error);
}
}
#fail(code) {
if (this.#disposed) return;
this.#disposed = true;
const error = new GltfImportError(code);
for (const pending of this.#pending.values()) pending.reject(error);
this.#pending.clear();
this.#worker.terminate?.();
}
dispose() {
if (this.#disposed) return;
this.#fail("DISPOSED");
}
}
+43
View File
@@ -0,0 +1,43 @@
const MAGIC = 0x414f5359;
/** Publishes one byte payload into a packed fixed SOA allocation. */
export function writeSharedUpload(buffer, descriptor, bytes) {
if (!(buffer instanceof SharedArrayBuffer) || !(bytes instanceof Uint8Array))
throw new TypeError("shared upload requires SharedArrayBuffer storage and Uint8Array bytes");
if (
!descriptor ||
descriptor.domain !== "fixed" ||
descriptor.scalar !== "u32" ||
descriptor.stride !== descriptor.lanes * 4 ||
descriptor.controlPtr % 64 !== 0 ||
descriptor.dataOffset !== 64 ||
bytes.byteLength < 1 ||
bytes.byteLength > descriptor.length * descriptor.lanes * 4
) throw new TypeError("invalid packed fixed SOA upload");
const control = new Int32Array(buffer, descriptor.controlPtr, 16);
if (
(Atomics.load(control, 0) >>> 0) !== MAGIC ||
(Atomics.load(control, 2) >>> 0) !== descriptor.id
) throw new Error("SOA_PROTOCOL_MISMATCH");
let sequence;
for (let attempt = 0; attempt < 1024; attempt++) {
const candidate = Atomics.load(control, 9) >>> 0;
if (!(candidate & 1) && (Atomics.compareExchange(control, 9, candidate | 0, (candidate + 1) | 0) >>> 0) === candidate) {
sequence = candidate;
break;
}
}
if (sequence === undefined) throw new Error("SOA_BUSY");
try {
new Uint8Array(
buffer,
descriptor.controlPtr + descriptor.dataOffset,
bytes.byteLength,
).set(bytes);
} finally {
Atomics.store(control, 9, (sequence + 2) | 0);
Atomics.notify(control, 9);
}
}
+28
View File
@@ -0,0 +1,28 @@
import { writeSharedUpload } from "./shared-upload.js";
const downloads = new Map();
addEventListener("message", async ({ data: message }) => {
const request = message?.request;
try {
if (message?.type === "load") {
const response = await fetch(message.url);
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 });
return;
}
if (message?.type === "storage") {
const bytes = downloads.get(request);
if (!bytes) throw new Error("GLTF_REQUEST_UNKNOWN");
writeSharedUpload(message.buffer, message.descriptor, bytes);
downloads.delete(request);
postMessage({ type: "ready", request, byteLength: bytes.byteLength });
}
} catch (error) {
downloads.delete(request);
postMessage({ type: "error", request, code: error?.message || "GLTF_IMPORT_FAILED" });
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@yawn/mesh-handles",
"version": "0.1.0",
"description": "Conventional mesh handles over the Yawn worker and shared-SOA protocol",
"type": "module",
"exports": "./src/index.js",
"dependencies": {
"@yawn/core": "0.1.0"
}
}
@@ -1,3 +1,4 @@
/** Worker-local acceleration structure used by the optional mesh-handle addon. */
export class DerivedBvh {
constructor() { this.count = 0; this.identity = new Uint32Array(); this.meshIdentity = new Uint32Array(); this.bounds = new Float32Array(); this.nodeBounds = new Float32Array(); this.left = this.right = new Int32Array(); this.leafStart = this.leafCount = new Uint32Array(); this.leaves = new Uint32Array(); this.root = -1; this.rebuilds = 0; this.refits = 0; }
update(snapshot) {
@@ -1,5 +1,5 @@
import {SnapshotReader} from "./render-data-snapshot.js";
import {DerivedBvh} from "./bvh-core.js";
import { SnapshotReader } from "@yawn/core/snapshot";
import { DerivedBvh } from "./bvh-core.js";
let reader, bvh = new DerivedBvh(), epoch = 0, updating = false, requestedEpoch = 0;
function ensureEpoch(expected) {
+67
View File
@@ -0,0 +1,67 @@
const TOKEN = Symbol("yawn mesh handle addon");
/** Creates the optional snapshot/BVH worker used by core's picking protocol. */
export const createPickingWorker = () => new Worker(
new URL("./bvh-worker.js", import.meta.url),
{ type: "module", name: "yawn-spatial-query" },
);
/** Conventional mesh/instance objects layered entirely over the Yawn core protocol. */
export class MeshHandles {
#core;
constructor(core) {
this.#core = core;
}
fromImportedScene(result) {
if (!result || !Array.isArray(result.meshes)) throw new TypeError("invalid imported scene");
return result.meshes.map((mesh) => new Mesh(TOKEN, this.#core, mesh));
}
async pickRay(origin, direction, options) {
const result = await this.#core.pickRay(origin, direction, options);
return {
...result,
hits: result.hits.map((hit) => ({
...hit,
instance: new Instance(TOKEN, this.#core, hit.instance),
})),
};
}
}
export class Mesh {
#core; #handle; #defaultInstance; #defaultType;
constructor(token, core, descriptor) {
if (token !== TOKEN) throw new TypeError("Mesh cannot be constructed directly");
this.#core = core;
this.#handle = Object.freeze([...descriptor.handle]);
this.#defaultInstance = new Instance(TOKEN, core, descriptor.defaultInstance);
this.#defaultType = Object.freeze([...descriptor.defaultType]);
}
get handle() { return this.#handle; }
get defaultInstance() { return this.#defaultInstance; }
async createInstance(transform, { type = this.#defaultType } = {}) {
return new Instance(TOKEN, this.#core, await this.#core.createInstance(this.#handle, transform, { type }));
}
}
export class Instance {
#core; #handle; #dead = false;
constructor(token, core, handle) {
if (token !== TOKEN) throw new TypeError("Instance cannot be constructed directly");
this.#core = core;
this.#handle = Object.freeze([...handle]);
}
get handle() { return this.#handle; }
#live() { if (this.#dead) throw new Error("STALE_HANDLE"); }
setType(words) { this.#live(); this.#core.setInstanceType(this.#handle, words); }
setTransform(transform) { this.#live(); this.#core.setInstanceTransform(this.#handle, transform); }
async destroy() { this.#live(); await this.#core.destroyInstance(this.#handle); this.#dead = true; }
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@yawn/render-graph-ast",
"version": "0.1.0",
"description": "Canonical Yawn render-graph AST and S-expression codec",
"type": "module",
"exports": "./src/index.js"
}
+189
View File
@@ -0,0 +1,189 @@
/** Canonical DAG AST shared by every Yawn render-graph frontend. */
const AST_KIND = "yawn-render-graph";
const AST_VERSION = 1;
const IDENTIFIER = /^[A-Za-z][A-Za-z0-9_.-]*$/;
export class GraphAstError extends TypeError {
constructor(code, message = code) {
super(message);
this.name = "GraphAstError";
this.code = code;
}
}
const fail = (code, message) => {
throw new GraphAstError(code, message);
};
const object = (value) =>
value !== null && typeof value === "object" && !Array.isArray(value);
const identifier = (value) =>
typeof value === "string" &&
IDENTIFIER.test(value) &&
new TextEncoder().encode(value).length <= 64;
const finiteData = (value) =>
value === null ||
typeof value === "string" ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value)) ||
(Array.isArray(value) && value.every(finiteData)) ||
(object(value) && Object.values(value).every(finiteData));
const clone = (value) => structuredClone(value);
const freeze = (value) => {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
Object.freeze(value);
Object.values(value).forEach(freeze);
}
return value;
};
const u32 = (value, name) => {
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff)
fail("AST_U32", `${name} must be a uint32`);
return value;
};
function normalizePipelines(raw = {}) {
if (!object(raw)) fail("AST_PIPELINES", "pipelines must be an object");
const render = (raw.render ?? []).map((pipeline) => {
if (!object(pipeline)) fail("AST_PIPELINE", "render pipeline must be an object");
const result = {
name: pipeline.name,
shader: pipeline.shader,
vertexEntry: pipeline.vertexEntry ?? "vs_main",
fragmentEntry: pipeline.fragmentEntry ?? "fs_main",
doubleSided: pipeline.doubleSided ?? false,
};
if (
!identifier(result.name) ||
!identifier(result.vertexEntry) ||
!identifier(result.fragmentEntry) ||
typeof result.shader !== "string" ||
typeof result.doubleSided !== "boolean"
)
fail("AST_PIPELINE", "invalid render pipeline declaration");
return result;
});
const compute = (raw.compute ?? []).map((pipeline) => {
if (!object(pipeline)) fail("AST_PIPELINE", "compute pipeline must be an object");
const result = {
name: pipeline.name,
shader: pipeline.shader,
entry: pipeline.entry ?? "main",
dispatch: Array.from(pipeline.dispatch ?? []),
};
if (
!identifier(result.name) ||
!identifier(result.entry) ||
typeof result.shader !== "string" ||
result.dispatch.length !== 3 ||
result.dispatch.some((value) => u32(value, "dispatch") === 0)
)
fail("AST_PIPELINE", "invalid compute pipeline declaration");
return result;
});
const names = new Set();
for (const pipeline of [...render, ...compute]) {
if (names.has(pipeline.name))
fail("AST_PIPELINE_DUPLICATE", `duplicate pipeline '${pipeline.name}'`);
names.add(pipeline.name);
}
return { render, compute };
}
function normalizeNode(raw) {
if (!object(raw) || !identifier(raw.id) || !object(raw.executor))
fail("AST_NODE", "invalid node");
if (raw.state !== "enabled" && raw.state !== "muted")
fail("AST_NODE", "node state must be enabled or muted");
if (!identifier(raw.executor.key)) fail("AST_NODE", "invalid executor key");
const parameters = clone(raw.parameters ?? {});
if (!finiteData(parameters)) fail("AST_DATA", "parameters must be finite data");
if (!object(raw.inputs ?? {})) fail("AST_NODE", "inputs must be an object");
const inputs = {};
for (const name of Object.keys(raw.inputs ?? {}).sort()) {
if (!identifier(name) || !Array.isArray(raw.inputs[name]))
fail("AST_INPUT", "invalid node input");
inputs[name] = raw.inputs[name].map((reference) => {
if (!object(reference) || !identifier(reference.node) || !identifier(reference.socket))
fail("AST_REFERENCE", "invalid DAG reference");
return { node: reference.node, socket: reference.socket };
});
}
return {
id: raw.id,
state: raw.state,
executor: { key: raw.executor.key, version: u32(raw.executor.version, "executor version") },
parameters,
inputs,
};
}
/** Creates the canonical in-memory graph AST shared by all authoring frontends. */
export function createGraphAst({ kind, version, id, revision, pipelines = {}, nodes }) {
if (kind !== undefined && kind !== AST_KIND) fail("AST_KIND", "invalid AST kind");
if (version !== undefined && version !== AST_VERSION) fail("AST_VERSION", "unsupported AST version");
if (!identifier(id)) fail("AST_ID", "invalid graph id");
u32(revision, "revision");
if (revision === 0) fail("AST_REVISION", "revision must be nonzero");
if (!Array.isArray(nodes)) fail("AST_NODES", "nodes must be an array");
const normalized = nodes.map(normalizeNode);
const ids = new Set();
for (const node of normalized) {
if (ids.has(node.id)) fail("AST_NODE_DUPLICATE", `duplicate node '${node.id}'`);
ids.add(node.id);
}
return freeze({
kind: AST_KIND,
version: AST_VERSION,
id,
revision,
pipelines: normalizePipelines(pipelines),
nodes: normalized,
});
}
export const reference = (node, socket) => {
if (!identifier(node) || !identifier(socket)) fail("AST_REFERENCE", "invalid DAG reference");
return Object.freeze({ node, socket });
};
function data(value) {
if (value === null) return "null";
if (typeof value === "boolean") return String(value);
if (typeof value === "number") {
if (!Number.isFinite(value)) fail("AST_DATA", "numbers must be finite");
return JSON.stringify(value);
}
if (typeof value === "string") return JSON.stringify(value);
if (Array.isArray(value)) return `(array${value.map((item) => ` ${data(item)}`).join("")})`;
if (object(value))
return `(object${Object.keys(value)
.sort()
.map((key) => ` (field ${JSON.stringify(key)} ${data(value[key])})`)
.join("")})`;
fail("AST_DATA", "unsupported AST data value");
}
/** Serializes a graph AST to the only graph wire format accepted by Yawn core. */
export function serializeGraphAst(raw) {
const graph = createGraphAst(raw);
const nodes = graph.nodes
.map((node) => {
const inputs = Object.entries(node.inputs)
.map(
([name, references]) =>
`\n (input ${JSON.stringify(name)}${references
.map(
(reference) =>
` (ref ${JSON.stringify(reference.node)} ${JSON.stringify(reference.socket)})`,
)
.join("")})`,
)
.join("");
return `\n (node ${JSON.stringify(node.id)} ${node.state}\n (executor ${JSON.stringify(node.executor.key)} ${node.executor.version})\n (params ${data(node.parameters)})\n (inputs${inputs})\n )`;
})
.join("");
return `(yawn-graph ${AST_VERSION}\n (id ${JSON.stringify(graph.id)})\n (revision ${graph.revision})\n (pipelines ${data(graph.pipelines)})\n (nodes${nodes}))\n`;
}
export const GRAPH_AST_KIND = AST_KIND;
export const GRAPH_AST_VERSION = AST_VERSION;
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@yawn/render-graph-fxnode",
"version": "0.1.0",
"description": "FXNode frontend for Yawn render graphs",
"type": "module",
"exports": {
".": "./src/index.js",
"./catalog": "./src/catalog.js"
},
"dependencies": {
"@yawn/render-graph-ast": "0.1.0"
}
}
@@ -1,3 +1,4 @@
// Converts Yawn's FXNode authoring document into the canonical render-graph AST.
import {
CATALOG_VERSION,
descriptors,
@@ -5,6 +6,7 @@ import {
nodeDefinitions,
socketTypes,
} from "./catalog.js";
import { createGraphAst } from "@yawn/render-graph-ast";
class AuthoringGraphError extends Error {
constructor(code, details = {}) {
@@ -138,7 +140,7 @@ function validSemanticValue(value, type) {
return false;
}
export function adaptFxNodeSnapshot(raw, revision = 1) {
export function adaptFxNodeSnapshot(raw, revision = 1, { pipelines = {} } = {}) {
try {
const rootKeys = [
"graphId",
@@ -580,17 +582,16 @@ export function adaptFxNodeSnapshot(raw, revision = 1) {
}
paths[`${base}.inputs`] = nodeSource;
}
const ir = {
schemaVersion: 3,
graphId: GRAPH_ID,
const ir = createGraphAst({
id: GRAPH_ID,
revision,
pipelines,
nodes: ordered.map((item) => item.value),
};
});
const graphSource = { kind: "graph", graphId: GRAPH_ID };
for (const field of ["schemaVersion", "graphId", "revision", "nodes"])
for (const field of ["version", "id", "revision", "pipelines", "nodes"])
paths[field] = graphSource;
deepFreeze(paths);
deepFreeze(ir);
sourceMaps.set(ir, paths);
return ir;
} catch (error) {
@@ -1,3 +1,4 @@
// Yawn's FXNode contract catalog lives with this addon, not core or the example app.
export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 13;
const exact = (type) => ({ kind: "exact", types: [type] });
+6
View File
@@ -0,0 +1,6 @@
// FXNode is an optional authoring frontend. Its exporter is intentionally the only
// FXNode-aware code that feeds the canonical AST package.
export {
adaptFxNodeSnapshot,
mapAuthoringDiagnostic,
} from "./adapter.js";
+10
View File
@@ -0,0 +1,10 @@
{
"name": "@yawn/render-graph-js",
"version": "0.1.0",
"description": "JSO and builder frontend for Yawn render graphs",
"type": "module",
"exports": "./src/index.js",
"dependencies": {
"@yawn/render-graph-ast": "0.1.0"
}
}
+67
View File
@@ -0,0 +1,67 @@
import {
createGraphAst,
reference,
serializeGraphAst,
} from "@yawn/render-graph-ast";
/** Compiles a plain JavaScript object description into the canonical graph AST. */
export const graphFromObject = (description) => createGraphAst(description);
/** Small mutable authoring facade; `ast()` returns an immutable canonical AST. */
export class RenderGraph {
#id;
#revision;
#nodes = [];
#render = [];
#compute = [];
constructor(id, revision = 1) {
this.#id = id;
this.#revision = revision;
}
renderPipeline(declaration) {
this.#render.push(structuredClone(declaration));
return this;
}
computePipeline(declaration) {
this.#compute.push(structuredClone(declaration));
return this;
}
node(id, executor, { version = 1, parameters = {}, inputs = {}, state = "enabled" } = {}) {
this.#nodes.push({
id,
state,
executor: { key: executor, version },
parameters: structuredClone(parameters),
inputs: structuredClone(inputs),
});
return this;
}
ast() {
return createGraphAst({
id: this.#id,
revision: this.#revision,
pipelines: { render: this.#render, compute: this.#compute },
nodes: this.#nodes,
});
}
serialize() {
return serializeGraphAst(this.ast());
}
load(core) {
return core.compileGraph(this.serialize());
}
}
/** Canonicalizes a JSO/AST and sends its S-expression wire form to Yawn core. */
export function loadGraph(core, description) {
return core.compileGraph(serializeGraphAst(graphFromObject(description)));
}
export { reference as ref, serializeGraphAst };
+9
View File
@@ -0,0 +1,9 @@
# Yawn examples
- `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
ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live
renderer is required.
+27
View File
@@ -0,0 +1,27 @@
import {
createGraphAst,
reference,
serializeGraphAst,
} from "@yawn/render-graph-ast";
const expression = (id, inputs = {}) => ({
id,
state: "enabled",
executor: { key: "and", version: 2 },
parameters: {},
inputs,
});
/** Build one DAG whose shared output fans out to two consumers. */
export function canonicalAstExample() {
const ast = createGraphAst({
id: "shared_dag",
revision: 1,
nodes: [
expression("source"),
expression("left", { inputs: [reference("source", "value")] }),
expression("right", { inputs: [reference("source", "value")] }),
],
});
return { ast, source: serializeGraphAst(ast) };
}
+18
View File
@@ -0,0 +1,18 @@
import { graphFromObject } from "@yawn/render-graph-js";
/** Author a graph with an ordinary JavaScript object and receive canonical AST. */
export function jsoGraphExample() {
return graphFromObject({
id: "jso_graph",
revision: 1,
nodes: [
{
id: "mesh",
state: "enabled",
executor: { key: "mesh", version: 2 },
parameters: {},
inputs: {},
},
],
});
}
+12
View File
@@ -0,0 +1,12 @@
import { RenderGraph, ref } from "@yawn/render-graph-js";
/** Build the same sort of DAG with the small mutable authoring facade. */
export function fluentGraphExample() {
return new RenderGraph("fluent_graph", 1)
.node("source", "and", { version: 2 })
.node("consumer", "not", {
version: 1,
inputs: { operand: [ref("source", "value")] },
})
.ast();
}
+19
View File
@@ -0,0 +1,19 @@
import { defaultPipelines } from "@yawn/default-pipelines";
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
import { CATALOG_VERSION, GRAPH_ID } from "@yawn/render-graph-fxnode/catalog";
/** Export a minimal FXNode authoring document through the shared AST boundary. */
export function fxNodeExportExample() {
return adaptFxNodeSnapshot(
{
graphId: GRAPH_ID,
catalogVersion: CATALOG_VERSION,
nodes: [],
links: [],
metadata: {},
version: 1,
},
1,
{ pipelines: defaultPipelines },
);
}
+12
View File
@@ -0,0 +1,12 @@
import { defaultPipelines } from "@yawn/default-pipelines";
import { RenderGraph } from "@yawn/render-graph-js";
/** Copy the optional package's external programs into a graph AST. */
export function defaultPipelineExample() {
const graph = new RenderGraph("default_programs", 1);
for (const pipeline of defaultPipelines.render)
graph.renderPipeline(pipeline);
for (const pipeline of defaultPipelines.compute)
graph.computePipeline(pipeline);
return graph.ast();
}
@@ -0,0 +1,31 @@
import { RenderGraph } from "@yawn/render-graph-js";
export const simpleSceneShader = /* wgsl */ `
@group(1) @binding(0) var<uniform> view_projection: mat4x4<f32>;
struct Input {
@location(0) position: vec3<f32>,
@location(3) model_0: vec4<f32>,
@location(4) model_1: vec4<f32>,
@location(5) model_2: vec4<f32>,
@location(6) model_3: vec4<f32>,
}
@vertex fn vertex_main(input: Input) -> @builtin(position) vec4<f32> {
let model = mat4x4<f32>(input.model_0, input.model_1, input.model_2, input.model_3);
return view_projection * model * vec4(input.position, 1.0);
}
@fragment fn fragment_main() -> @location(0) vec4<f32> {
return vec4(0.2, 0.7, 1.0, 1.0);
}`;
/** Supply scene WGSL and entry points as graph data, never as core source. */
export function customRenderPipelineExample() {
return new RenderGraph("custom_render_program", 1)
.renderPipeline({
name: "ground_plane",
shader: simpleSceneShader,
vertexEntry: "vertex_main",
fragmentEntry: "fragment_main",
doubleSided: false,
})
.ast();
}
+18
View File
@@ -0,0 +1,18 @@
import { RenderGraph } from "@yawn/render-graph-js";
export const initializeShader = /* wgsl */ `
@compute @workgroup_size(8, 1, 1)
fn initialize() {}
`;
/** Declare a binding-free compute pass that runs before graph render passes. */
export function computePipelineExample() {
return new RenderGraph("compute_program", 1)
.computePipeline({
name: "initialize",
shader: initializeShader,
entry: "initialize",
dispatch: [4, 1, 1],
})
.ast();
}
@@ -0,0 +1,19 @@
import { loadGraph } from "@yawn/render-graph-js";
/** Compile a complete AST/JSO and make its prepared loadout active. */
export async function compileAndSwitch(core, graph) {
const compiled = await loadGraph(core, graph);
try {
await core.switchCompiledGraph(compiled.compiledId);
return compiled;
} catch (error) {
await core.dropCompiledGraph(compiled.compiledId).catch(() => {});
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);
}
@@ -0,0 +1,13 @@
import { GltfImporter } from "@yawn/gltf-import";
import { MeshHandles } from "@yawn/mesh-handles";
/** Fetch a glTF URL in the import worker and wrap the resulting protocol handles. */
export async function importGltf(core, url, options) {
const importer = new GltfImporter(core);
try {
const imported = await importer.load(url, options);
return new MeshHandles(core).fromImportedScene(imported);
} finally {
importer.dispose();
}
}
+14
View File
@@ -0,0 +1,14 @@
export const identityTransform = Object.freeze([
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
]);
/** Create a conventional instance object from an imported mesh handle. */
export function createInstance(mesh, transform = identityTransform) {
return mesh.createInstance(transform);
}
/** Frequent mutations stay on the shared-memory path exposed by the handle addon. */
export function updateInstance(instance, transform, typeWords) {
instance.setTransform(transform);
instance.setType(typeWords);
}
+14
View File
@@ -0,0 +1,14 @@
/** Allocate one SIMD-aligned velocity row for every live instance slot. */
export function createVelocityColumn(core) {
return core.allocateArray({
name: "instance.velocity",
domain: "instance",
scalar: "f32",
lanes: 4,
});
}
/** Mutate an existing row directly; no renderer command message is emitted. */
export function setVelocity(column, instance, xyz) {
column.write(instance.handle[0], [xyz[0], xyz[1], xyz[2], 0]);
}
@@ -0,0 +1,9 @@
/** Write a new model matrix through core's generation-guarded shared SOA column. */
export function animateInstance(core, instance, transform) {
core.setInstanceTransform(instance.handle, transform);
}
/** Write the opaque 512-bit instance classification used by graph predicates. */
export function classifyInstance(core, instance, words) {
core.setInstanceType(instance.handle, words);
}
+9
View File
@@ -0,0 +1,9 @@
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, {
maxDistance: 10_000,
maxHits: 1,
});
}
+14
View File
@@ -0,0 +1,14 @@
import { YawnCore } from "@yawn/core";
/** Connect from any worker using a MessagePort with the Worker-like transport API. */
export async function connectFromWorker(port, options = {}) {
const core = new YawnCore({
worker: port,
memory: options.memory,
ringPtr: options.ringPtr,
pickingWorkerFactory: options.pickingWorkerFactory,
free: options.free,
});
await core.ready;
return core;
}
+19
View File
@@ -0,0 +1,19 @@
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";
/** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */
export async function loadCompleteScene(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;
}
}
+26
View File
@@ -0,0 +1,26 @@
# Yawn addon cookbook
These recipes deliberately avoid another application framework or renderer wrapper.
Import the function you need and pass the same `YawnCore` instance to every addon.
| Recipe | Demonstrates |
| --- | --- |
| `01-canonical-ast.js` | A shared DAG output and canonical S-expression serialization |
| `02-jso-graph.js` | Plain JavaScript object authoring |
| `03-fluent-builder.js` | Fluent render-graph authoring |
| `04-fxnode-export.js` | Exporting an FXNode snapshot to the canonical AST |
| `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 |
| `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 |
| `12-direct-sab-animation.js` | Updating transforms through generation-guarded SAB writes |
| `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 |
Recipes 17 isolate graph authoring concepts, so their ASTs are intentionally
fragments rather than complete renderable loadouts. Recipe 15 uses the complete
scene graph from `render-graph-studio` when an end-to-end example is needed.
+15
View File
@@ -0,0 +1,15 @@
export * from "./01-canonical-ast.js";
export * from "./02-jso-graph.js";
export * from "./03-fluent-builder.js";
export * from "./04-fxnode-export.js";
export * from "./05-default-pipelines.js";
export * from "./06-custom-render-pipeline.js";
export * from "./07-compute-pipeline.js";
export * from "./08-compile-and-switch.js";
export * from "./09-gltf-import-worker.js";
export * from "./10-mesh-instances.js";
export * from "./11-custom-soa-column.js";
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";
@@ -1,3 +1,4 @@
// Procedural example assets keep the package demo self-contained.
const JSON_CHUNK = 0x4e4f534a;
const BIN_CHUNK = 0x004e4942;
const encoder = new TextEncoder();
@@ -83,13 +84,10 @@ export function createMaterialGalleryGlb(){
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);
view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true);view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20);const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset);return out;
}
export function isGitLfsPointer(bytes){const text=new TextDecoder().decode(new Uint8Array(bytes,0,Math.min(bytes.byteLength,256)));return text.startsWith("version https://git-lfs.github.com/spec/v1\n");}
export class LoadoutError extends Error{constructor(code,message){super(message);this.name="LoadoutError";this.code=code;}}
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"Phase 6 deterministic PBR gallery"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
const assetUrls=Object.freeze({manor:new URL("./themanor.glb",import.meta.url),sponza:new URL("./sponza.glb",import.meta.url)});
export async function loadDemoLoadout(id,{signal,fetchImpl=fetch}={}){
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());if(id==="materials")return createMaterialGalleryGlb();
const url=assetUrls[id];if(!url)throw new LoadoutError("LOADOUT_UNKNOWN",`Unknown loadout: ${id}`);
let response;try{response=await fetchImpl(url,{signal});}catch(error){if(error?.name==="AbortError")throw error;throw new LoadoutError("LOADOUT_FETCH_FAILED",`Could not fetch ${id}: ${error?.message||"network error"}`);}
if(!response.ok)throw new LoadoutError("LOADOUT_HTTP",`Could not fetch ${id}: HTTP ${response.status}`);const buffer=await response.arrayBuffer();if(isGitLfsPointer(buffer))throw new LoadoutError("LOADOUT_LFS_POINTER",`${id} is a Git LFS pointer; hydrate repository assets first`);return buffer;
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"PBR material gallery"}});
export async function loadDemoLoadout(id){
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());
if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());
if(id==="materials")return createMaterialGalleryGlb();
throw new RangeError(`Unknown loadout: ${id}`);
}
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Yawn Render Graph Demo</title>
<title>Yawn Package Integration Example</title>
<style>
* {
box-sizing: border-box;
@@ -166,25 +166,11 @@
<option value="cubes">Cubes</option>
<option value="spheres">UV spheres</option>
<option value="materials">Phase 6 PBR gallery</option>
<option value="manor">The Manor</option>
<option value="sponza">Sponza</option>
</select></label
><label class="field" for="graph-select"
>Graph preset<select id="graph-select">
<option value="authored">Authored</option>
<option value="midnight">Midnight</option>
<option value="ember">Ember</option>
<option value="hdr">HDR Fullscreen</option>
<option value="msaa">4× MSAA</option>
<option value="culling">GPU frustum culling</option>
<option value="tone">ACES display</option>
<option value="contain">SDR contain</option>
<option value="reinhard">Reinhard cover</option>
<option value="linear">Linear clip</option>
<option value="grading">Grading</option>
<option value="edges">Edges</option>
<option value="bloom">Bloom</option>
<option value="combined">Combined</option>
<option value="jso">JSO addon</option>
</select></label
>
</div>
@@ -1,12 +1,18 @@
import wbg_init, { main } from "./level-editor/pkg/level_editor.js";
import { RendererClient, RendererError } from "./renderer-client.js";
import { YawnCore, RendererError } from "@yawn/core";
import { MeshHandles, createPickingWorker } from "@yawn/mesh-handles";
import { loadDemoLoadout } from "./demo-loadouts.js";
import { adaptFxNodeSnapshot } from "./render-graph/adapter.js";
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
import { createGraphAst } from "@yawn/render-graph-ast";
import { loadGraph } from "@yawn/render-graph-js";
import { GltfImporter } from "@yawn/gltf-import";
import { defaultPipelines } from "@yawn/default-pipelines";
import { AuthoringController } from "./render-graph/authoring-controller.js";
import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js";
import { renderGraphPresets } from "./render-graph/presets.js";
let renderer,
meshHandles,
gltfImporter,
editor,
controller,
assetAbort,
@@ -34,6 +40,119 @@ const state = {
export const profileRequested = (search) =>
new URLSearchParams(search).get("profile") === "1";
const profileEnabled = profileRequested(location.search);
function createWorkerTransport(profile) {
const canvas = document.querySelector("#canvas0");
const dpr = devicePixelRatio;
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
canvas.height = Math.round(Math.max(1, canvas.clientHeight) * dpr);
const worker = new Worker(
new URL(
"../../renderer/src/platform/web/worker/mainWorker.js",
import.meta.url,
),
{ type: "module", name: "yawn-renderer" },
);
const abort = new AbortController();
const options = { signal: abort.signal };
const post = (kind, values) =>
worker.postMessage({
type: "window-event",
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",
() =>
post(0, [
Math.max(1, canvas.clientWidth),
Math.max(1, canvas.clientHeight),
devicePixelRatio,
]),
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]);
return {
worker,
pickingWorkerFactory: createPickingWorker,
free() {
abort.abort();
},
};
}
function installProfileMenu() {
if (!profileEnabled) return;
const menu = document.createElement("details");
@@ -41,7 +160,7 @@ function installProfileMenu() {
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(window, "renderer-profile", (event) => {
on(renderer, "renderer-profile", (event) => {
const p = event.detail;
document.querySelector("#profile-status").textContent = p.available
? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped`
@@ -87,7 +206,7 @@ function waitTelemetry(predicate, timeout = 30000) {
let timer;
const done = () => {
clearTimeout(timer);
removeEventListener("renderer-frame", frame);
renderer.removeEventListener("renderer-frame", frame);
};
const frame = (e) => {
if (predicate(e.detail)) {
@@ -103,7 +222,7 @@ function waitTelemetry(predicate, timeout = 30000) {
done();
reject(new RendererError("DISPOSED"));
};
addEventListener("renderer-frame", frame);
renderer.addEventListener("renderer-frame", frame);
timer.unref?.();
});
}
@@ -154,9 +273,12 @@ async function selectLoadout(next, select) {
assetAbort = new AbortController();
const ok = await transaction(`Loading ${next}`, async () => {
const glb = await loadDemoLoadout(next, { signal: assetAbort.signal });
await renderer.replaceSceneGlb(glb, {
framing: next === "sponza" ? "interior" : "exterior",
});
const url = URL.createObjectURL(new Blob([glb], { type: "model/gltf-binary" }));
try {
meshHandles.fromImportedScene(await gltfImporter.load(url));
} finally {
URL.revokeObjectURL(url);
}
state.loadout = next;
return waitTelemetry(
(x) =>
@@ -205,6 +327,7 @@ async function cleanup() {
await controller?.destroy();
await editor?.destroy();
} finally {
gltfImporter?.dispose();
renderer?.dispose();
}
}
@@ -215,9 +338,9 @@ const pagehide = () => {
async function start() {
addEventListener("pagehide", pagehide, { once: true });
delete document.documentElement.dataset.phase8Ready;
await wbg_init();
if (cleaned) return;
renderer = new RendererClient(main(profileEnabled));
renderer = new YawnCore(createWorkerTransport(profileEnabled));
meshHandles = new MeshHandles(renderer);
gltfImporter = new GltfImporter(renderer);
installProfileMenu();
await renderer.ready;
const nextEditor = await createRenderGraphEditor(
@@ -230,7 +353,8 @@ async function start() {
editor = nextEditor;
controller = new AuthoringController({
renderer,
adapt: adaptFxNodeSnapshot,
adapt: (snapshot, revision) =>
adaptFxNodeSnapshot(snapshot, revision, { pipelines: defaultPipelines }),
});
const apply = document.querySelector("#apply-graph"),
graphStatus = document.querySelector("#graph-status"),
@@ -255,14 +379,15 @@ async function start() {
const authored = await controller.apply();
state.compiled.authored = { ...authored, graphId: "authored_gpu_culling" };
for (const [name, preset] of Object.entries(renderGraphPresets)) {
const compiled = await renderer.compileGraph(preset);
// The explicit AST construction shows the common boundary shared by JSO and FXNode.
const compiled = await loadGraph(renderer, createGraphAst(preset));
state.compiled[name] = {
...compiled,
graphId: preset.graphId,
graphId: preset.id,
revision: preset.revision,
};
}
on(window, "renderer-frame", (event) => {
on(renderer, "renderer-frame", (event) => {
const expected = state.compiled[state.graph],
telemetry = event.detail;
if (
@@ -321,7 +446,13 @@ async function start() {
"Preparing procedural cubes…",
async () => {
const targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));
const glb = await loadDemoLoadout("cubes");
const url = URL.createObjectURL(new Blob([glb], { type: "model/gltf-binary" }));
try {
meshHandles.fromImportedScene(await gltfImporter.load(url));
} finally {
URL.revokeObjectURL(url);
}
await renderer.switchCompiledGraph(authored.compiledId);
return waitTelemetry(
(x) =>
@@ -1,4 +1,6 @@
import { NODE_TITLE_OVERRIDES, semanticCatalog } from "./catalog.js";
import { NODE_TITLE_OVERRIDES, semanticCatalog } from "@yawn/render-graph-fxnode/catalog";
// Example-only DOM menu for the FXNode frontend.
const GROUPS = Object.freeze([
["source", "Source"],
@@ -1,4 +1,7 @@
import { mapAuthoringDiagnostic } from "./adapter.js";
import { mapAuthoringDiagnostic } from "@yawn/render-graph-fxnode";
import { loadGraph } from "@yawn/render-graph-js";
// Example lifecycle glue; package frontends remain independent of this controller.
export class AuthoringController {
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
@@ -64,7 +67,7 @@ export class AuthoringController {
let candidate, ir;
try {
ir = this.#adapt(record.snapshot, this.#nextRevision++);
candidate = await this.#renderer.compileGraph(ir);
candidate = await loadGraph(this.#renderer, ir);
this.#owned.set(this.#key(candidate.compiledId), candidate);
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
@@ -1,3 +1,4 @@
// Browser input host used only by the interactive package example.
const viewport = (canvas, ownerWindow) => ({
width: Math.max(1, canvas.clientWidth),
height: Math.max(1, canvas.clientHeight),
@@ -1,10 +1,12 @@
import { createFxNode } from "@fxnode/index.ts";
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js";
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "@yawn/render-graph-fxnode/catalog";
import { prepareBrowserHost } from "./browser-host.js";
import { createAddNodeMenu } from "./add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
import { culling } from "./presets.js";
// Seeds a user-facing FXNode document before exporting it through the addon.
async function seed(root) {
await root.setState({
graphId: GRAPH_ID,
@@ -16,6 +18,17 @@ async function seed(root) {
for (const [index, item] of culling.nodes.entries())
await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key,
position: { x: 40 + (index % 6) * 280, y: 120 + Math.floor(index / 6) * 260 } });
const authoredNodes = new Map(culling.nodes.map((node) => [node.id, node]));
const socketKey = (nodeId, semantic, direction) => {
const type = authoredNodes.get(nodeId)?.executor.key;
const sockets = fxNodeComposition.nodes[type]?.sockets ?? {};
const matches = Object.entries(sockets).filter(
([key, socket]) =>
socket.direction === direction &&
(key === semantic || socket.title === semantic),
);
return matches.length === 1 ? matches[0][0] : semantic;
};
const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).flatMap(([socket, sources]) =>
sources.map((from, index) => [from.node, from.socket, item.id, socket, index])));
for (const [a, as, b, bs, index] of links) {
@@ -25,9 +38,9 @@ async function seed(root) {
link: {
id,
fromNodeId: a,
fromSocketId: `${a}:${as}`,
fromSocketId: `${a}:${socketKey(a, as, "output")}`,
toNodeId: b,
toSocketId: `${b}:${bs}`,
toSocketId: `${b}:${socketKey(b, bs, "input")}`,
muted: false,
extensions: {},
},
@@ -1,4 +1,4 @@
/** Allocates a bounded fxnode-safe ID, reserving candidates for this session. */
/** Allocates an example-local bounded FXNode ID, reserving candidates for this session. */
export function createNodeIdAllocator(randomUUID = () => crypto.randomUUID()) {
const reserved = new Set();
return (existingIds) => {
@@ -0,0 +1,62 @@
import { defaultPipelines } from "@yawn/default-pipelines";
import { descriptors } from "@yawn/render-graph-fxnode/catalog";
import { graphFromObject } from "@yawn/render-graph-js";
// A complete graph authored like a package consumer would author it.
const input = (node, socket) => [{ node, socket }];
const node = (id, key, parameters = {}, inputs = {}) => ({
id,
state: "enabled",
executor: { key, version: descriptors[key].version },
parameters,
inputs,
});
const texture = (format) => ({
texture: {
dimension: "d2",
format,
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
},
residency: "transient",
});
const nodes = [
node("hdr", "texture", texture("rgba16_float")),
node("scene_depth", "texture", texture("depth32_float")),
node("mesh", "mesh"),
node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }),
node("type_bits", "separate_u32_bits", { valueDefault: 0 }, { value: input("type_words", "word0") }),
node("ground_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit1")] }),
node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }),
node("standard_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit2"), ...input("not_double", "value")] }),
node("double_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit3")] }),
node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }),
node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }),
];
for (const id of ["ground_class", "standard_class", "double_class"])
nodes.find((item) => item.id === id).inputs.inputs.push(...input("not_culled", "value"));
nodes.push(
node("ground", "ground_plane", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("ground_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
node("pbr", "gltf_standard", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("standard_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
node("pbr_double", "gltf_standard_double_sided", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("double_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
node("frame_out", "frame_out", { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, { color: input("pbr_double", "color") }),
);
/** The example's JSO graph; the graph addon canonicalizes it to AST and S-expressions. */
export const culling = graphFromObject({
id: "example_jso_scene",
revision: 1,
pipelines: defaultPipelines,
nodes,
});
export const renderGraphPresets = Object.freeze({ jso: culling });
+10 -129
View File
@@ -1,22 +1,12 @@
#![cfg(target_arch = "wasm32")]
use ultraviolet::Mat4;
use wasm_bindgen::prelude::*;
use renderer::app_setup::WebAppRuntime;
use renderer::camera::Camera;
use renderer::render_data::{InstanceType, MeshCreateInfo, RenderData};
use renderer::render_data::RenderData;
use renderer::renderer as gpu_renderer;
use renderer::renderer::gpu_scene::vertex_layouts;
use renderer::renderer::scene::FrameMetadata;
/// Simple vertex format.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Vertex {
pos: [f32; 3],
}
struct EditorScene {
uniform_buffers: [wgpu::Buffer; 2],
bind_groups: [wgpu::BindGroup; 2],
@@ -28,7 +18,7 @@ impl renderer::renderer::scene::Scene for EditorScene {
fn setup(
renderer_context: &gpu_renderer::RendererContext,
resources: &mut gpu_renderer::PipelineLibrary,
render_data: &mut RenderData,
_render_data: &mut RenderData,
) -> Self {
let dimension = ultraviolet::Vec2::new(
renderer_context.surface_config.width as f32,
@@ -50,21 +40,12 @@ impl renderer::renderer::scene::Scene for EditorScene {
resources.set_bind_group_layouts(&bind_group_layouts);
let mut scene = EditorScene {
EditorScene {
uniform_buffers: [uniform_resource.buffer, camera_resource.buffer],
bind_groups: [uniform_resource.bind_group, camera_resource.bind_group],
frame_metadata,
cam: camera,
};
scene.create_default_scene(
&renderer_context.device,
resources,
render_data,
renderer_context.surface_config.format,
);
scene
}
}
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
@@ -108,116 +89,16 @@ impl renderer::renderer::scene::Scene for EditorScene {
}
}
impl EditorScene {
/// Ground plane vertex data.
const VERTICES: &[Vertex] = &[
// First triangle of quad
Vertex {
pos: [-5.0, 0.0, -5.0],
},
Vertex {
pos: [5.0, 0.0, -5.0],
},
Vertex {
pos: [-5.0, 0.0, 5.0],
},
// Second triangle of quad
Vertex {
pos: [5.0, 0.0, -5.0],
},
Vertex {
pos: [5.0, 0.0, 5.0],
},
Vertex {
pos: [-5.0, 0.0, 5.0],
},
];
// Wind the ground plane so the upward-facing side is front-facing (CCW from
// above) to avoid being culled by the default back-face culling.
const INDICES: &[u32] = &[0, 2, 1, 3, 5, 4];
fn create_default_scene(
&mut self,
device: &wgpu::Device,
resources: &mut gpu_renderer::PipelineLibrary,
render_data: &mut RenderData,
surface_format: wgpu::TextureFormat,
) {
let positions: Vec<[f32; 3]> = Self::VERTICES.iter().map(|v| v.pos).collect();
// Ground plane normals point upward (Y+)
let normals: Vec<[f32; 3]> = vec![[0.0, 1.0, 0.0]; positions.len()];
let tangents: Vec<[f32; 4]> = vec![[1.0, 0.0, 0.0, 1.0]; positions.len()];
let uvs: &[[f32; 2]] = &[
[0.0, 0.0],
[1.0, 0.0],
[0.0, 1.0],
[1.0, 0.0],
[1.0, 1.0],
[0.0, 1.0],
];
let vertex_layout = vertex_layouts();
let pipeline_index = resources.get_or_create_pipeline(
device,
"ground_plane",
&vertex_layout,
include_str!("./program.wgsl"),
surface_format,
);
let scale_factor = 100.0;
let scale_matrix = Mat4::from_scale(scale_factor);
let transform: [[f32; 4]; 4] = scale_matrix.into();
render_data
.create_mesh(MeshCreateInfo {
positions: &positions,
normals: &normals,
tangents: &tangents,
uvs,
indices: Self::INDICES,
pipeline: pipeline_index,
material: renderer::render_data::MaterialKey::DEFAULT,
default_instance_type: InstanceType {
words: [1 | 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
},
default_transform: transform,
})
.expect("ground plane geometry is valid");
}
}
/// Entrypoint for the level editor
/// Start the level editor inside its owning render worker.
#[wasm_bindgen]
pub fn main(profile: bool) -> Result<RendererBridge, JsValue> {
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());
let runtime = WebAppRuntime::new::<EditorScene>("main-worker", "#canvas0", profile)?;
Ok(RendererBridge { runtime })
renderer::app_setup::worker_entrypoint::<EditorScene>(profile)
}
/// Opaque owner of the worker, event listeners, and pinned command ring.
/// Return this worker's shared WebAssembly memory to messaging clients.
#[wasm_bindgen]
pub struct RendererBridge {
runtime: renderer::app_setup::WebAppRuntime,
pub fn worker_memory() -> JsValue {
wasm_bindgen::memory()
}
#[wasm_bindgen]
impl RendererBridge {
#[wasm_bindgen(getter)]
pub fn worker(&self) -> web_sys::Worker {
web_sys::Worker::clone(&*self.runtime.worker())
}
#[wasm_bindgen(getter, js_name = ringPtr)]
pub fn ring_ptr(&self) -> u32 {
self.runtime.ring_ptr()
}
#[wasm_bindgen(getter)]
pub fn memory(&self) -> JsValue {
wasm_bindgen::memory()
}
}
renderer::export_worker_entrypoint!();
-72
View File
@@ -1,72 +0,0 @@
struct UniformData {
mouse_move: vec2<f32>,
mouse_click: vec2<f32>,
resolution: vec2<f32>,
time: f32,
_padding0: f32,
camera_position: vec4<f32>,
}
@group(0) @binding(0) var<uniform> uni: UniformData;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
struct VertexInput {
@location(0) pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) model_col0: vec4<f32>,
@location(4) model_col1: vec4<f32>,
@location(5) model_col2: vec4<f32>,
@location(6) model_col3: vec4<f32>,
@location(7) normal_col0: vec4<f32>,
@location(8) normal_col1: vec4<f32>,
@location(9) normal_col2: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let model = mat4x4<f32>(
in.model_col0,
in.model_col1,
in.model_col2,
in.model_col3,
);
let world_position = model * vec4<f32>(in.pos, 1.0);
out.clip_position = view_proj * world_position;
out.world_pos = world_position.xyz;
let normal_matrix = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
out.normal = normalize(normal_matrix * in.normal);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let light_direction = normalize(vec3<f32>(0.35, 1.0, 0.45));
let light_color = vec3<f32>(1.0, 0.95, 0.85);
let base_color = vec3<f32>(0.2, 0.2, 0.2);
let normal = normalize(in.normal);
let view_dir = normalize(uni.camera_position.xyz - in.world_pos);
let diffuse_strength = max(dot(normal, light_direction), 0.0);
let ambient = 0.15;
var specular = 0.0;
if diffuse_strength > 0.0 {
let halfway_dir = normalize(light_direction + view_dir);
specular = pow(max(dot(normal, halfway_dir), 0.0), 32.0);
}
let lighting = min(base_color * (ambient + diffuse_strength) + light_color * specular, vec3<f32>(1.0));
let x = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_move) < 25.0);
let y = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_click) < 25.0);
return vec4<f32>(lighting + x - y, 1.0);
}
+63 -436
View File
@@ -7,6 +7,10 @@
"": {
"name": "basic",
"version": "0.1.0",
"workspaces": [
"packages/*",
"addons/*"
],
"devDependencies": {
"@wasm-tool/wasm-pack-plugin": "^1.7.0",
"npm-run-all": "^4.1.5",
@@ -17,276 +21,37 @@
"vite-plugin-wasm": "^3.3.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz",
"integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
"addons/default-pipelines": {
"name": "@yawn/default-pipelines",
"version": "0.1.0"
},
"addons/gltf-import": {
"name": "@yawn/gltf-import",
"version": "0.1.0"
},
"addons/mesh-handles": {
"name": "@yawn/mesh-handles",
"version": "0.1.0",
"dependencies": {
"@yawn/core": "0.1.0"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz",
"integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
"addons/render-graph-ast": {
"name": "@yawn/render-graph-ast",
"version": "0.1.0"
},
"addons/render-graph-fxnode": {
"name": "@yawn/render-graph-fxnode",
"version": "0.1.0",
"dependencies": {
"@yawn/render-graph-ast": "0.1.0"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz",
"integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz",
"integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz",
"integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz",
"integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz",
"integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz",
"integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz",
"integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz",
"integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz",
"integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz",
"integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz",
"integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz",
"integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz",
"integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz",
"integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
"addons/render-graph-js": {
"name": "@yawn/render-graph-js",
"version": "0.1.0",
"dependencies": {
"@yawn/render-graph-ast": "0.1.0"
}
},
"node_modules/@esbuild/linux-x64": {
@@ -306,159 +71,6 @@
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz",
"integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz",
"integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz",
"integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz",
"integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz",
"integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz",
"integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz",
"integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz",
"integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.25.11",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz",
"integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -602,6 +214,34 @@
"which": "^2.0.2"
}
},
"node_modules/@yawn/core": {
"resolved": "packages/yawn-core",
"link": true
},
"node_modules/@yawn/default-pipelines": {
"resolved": "addons/default-pipelines",
"link": true
},
"node_modules/@yawn/gltf-import": {
"resolved": "addons/gltf-import",
"link": true
},
"node_modules/@yawn/mesh-handles": {
"resolved": "addons/mesh-handles",
"link": true
},
"node_modules/@yawn/render-graph-ast": {
"resolved": "addons/render-graph-ast",
"link": true
},
"node_modules/@yawn/render-graph-fxnode": {
"resolved": "addons/render-graph-fxnode",
"link": true
},
"node_modules/@yawn/render-graph-js": {
"resolved": "addons/render-graph-js",
"link": true
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
@@ -1350,21 +990,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -1571,7 +1196,6 @@
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -1724,7 +1348,6 @@
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"dev": true,
"license": "ISC",
"dependencies": {
@@ -3909,6 +3532,10 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true,
"license": "ISC"
},
"packages/yawn-core": {
"name": "@yawn/core",
"version": "0.1.0"
}
}
}
+5
View File
@@ -3,6 +3,11 @@
"author": "ecoricemon",
"name": "basic",
"version": "0.1.0",
"private": true,
"workspaces": [
"packages/*",
"addons/*"
],
"scripts": {
"dev": "run-s rsw:build dev:watch",
"dev:watch": "run-p rsw:watch dev:vite",
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@yawn/core",
"version": "0.1.0",
"type": "module",
"exports": {
".": "./src/index.js",
"./snapshot": "./src/snapshot.js"
}
}
@@ -1,47 +1,68 @@
import { SnapshotReader } from "./render-data-snapshot.js";
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, INSTANCE_TRANSFORM: 5, DESTROY_INSTANCE: 6, COMPILE_GRAPH: 7, DROP_GRAPH: 8, SWITCH_GRAPH: 9, SET_INSTANCE_TYPE: 10 };
const HANDLE_TOKEN = Symbol("renderer handle");
const OP = { IMPORT_GLB: 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; }
}
export class RendererClient {
export class YawnCore extends EventTarget {
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
#readyResolve; #readyReject; #arrays = new Map();
#transportReady = false;
#telemetry; #profile; #stopped = false;
#graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
constructor(bridge) {
super();
this.#bridge = bridge;
this.#worker = bridge.worker;
this.#refreshViews();
if (Atomics.load(this.#header, 0) !== 0x4e574159 || Atomics.load(this.#header, 1) !== 2 || Atomics.load(this.#header, 2) !== CAPACITY || Atomics.load(this.#header, 3) !== SLOT_WORDS) {
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
try { bridge?.free?.(); } catch { /* best effort */ }
throw new RendererError("PROTOCOL_MISMATCH");
this.#ready = new Promise((resolve, reject) => {
this.#readyResolve = resolve;
this.#readyReject = reject;
});
if (bridge.memory && Number.isInteger(bridge.ringPtr)) {
this.#installTransport(bridge.memory, bridge.ringPtr);
}
this.#worker.addEventListener("message", e => this.#message(e.data));
this.#worker.addEventListener("error", () => this.#fail("WORKER_ERROR"));
this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_MESSAGE_ERROR"));
this.#worker.start?.();
try {
const factory = bridge.workerFactory || (() => new Worker(new URL("./bvh-worker.js", import.meta.url), { type: "module" }));
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"));
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; }
this.#ready = Promise.resolve(this);
}
get ready() { return this.#ready; }
get telemetry() { return this.#telemetry; }
get profile() { return this.#profile; }
array(name) {
const array = this.#arrays.get(name);
if (!array) throw new RendererError("SOA_ARRAY_UNKNOWN", { message: `Unknown shared array '${name}'` });
return array;
}
async allocateArray(layout) {
await this.#ready;
let source;
try { source = JSON.stringify(layout); }
catch (error) { throw new RendererError("SOA_LAYOUT_INVALID", { message: error?.message }); }
const descriptor = await this.#withPayload(new TextEncoder().encode(source).buffer, OP.ALLOCATE_SOA);
return this.#installArray(descriptor);
}
#refreshViews() {
if (!this.#bridge.memory || !Number.isInteger(this.#bridge.ringPtr)) return;
const buffer = this.#bridge.memory.buffer;
if (buffer === this.#buffer) return;
this.#buffer = buffer;
@@ -49,8 +70,25 @@ export class RendererClient {
this.#slots = new Int32Array(buffer, this.#bridge.ringPtr + 64, CAPACITY * SLOT_WORDS);
}
#installTransport(memory, ringPtr) {
this.#bridge.memory = memory;
this.#bridge.ringPtr = ringPtr;
this.#refreshViews();
if (Atomics.load(this.#header, 0) !== 0x4e574159 || Atomics.load(this.#header, 1) !== 2 || Atomics.load(this.#header, 2) !== CAPACITY || Atomics.load(this.#header, 3) !== SLOT_WORDS) {
const actual = Array.from(this.#header.subarray(0, 4), value => value >>> 0);
try { this.#worker?.terminate?.(); } catch { /* best effort */ }
try { this.#bridge?.free?.(); } catch { /* best effort */ }
throw new RendererError("PROTOCOL_MISMATCH", { message: `Invalid command ring at ${ringPtr}: ${actual.join(",")}` });
}
this.#transportReady = true;
}
#message(message) {
if (message?.type === "reply") {
if (message?.type === "bootstrap") {
if (this.#transportReady) { this.#fail("PROTOCOL_MISMATCH"); return; }
try { this.#installTransport(message.memory, message.ringPtr); }
catch (error) { this.#readyReject?.(error); this.#fail(error.code || "PROTOCOL_MISMATCH"); }
} else if (message?.type === "reply") {
const pending = this.#pending.get(message.request);
if (!pending) return;
this.#pending.delete(message.request);
@@ -60,13 +98,22 @@ export class RendererClient {
if (pending) { this.#payloadPending.delete(message.id); pending.resolve(); }
} else if (message?.type === "telemetry") {
this.#telemetry = message;
dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
this.dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
} else if (message?.type === "profile-snapshot") {
this.#profile = message;
dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
this.dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
} else if (message?.type === "fatal") {
console.error("renderer worker fatal", message.code, message.message);
console.error("renderer worker fatal", JSON.stringify(message));
this.#fail(message.code || "WORKER_FATAL");
} else if (message?.type === "soa-init" || message?.type === "soa-layout") {
try {
for (const descriptor of message.arrays ?? []) this.#installArray(descriptor);
if (message.type === "soa-init") this.#readyResolve?.(this);
this.dispatchEvent(new CustomEvent("yawn-soa-layout", { detail: this.#arrays }));
} catch (error) {
this.#readyReject?.(error);
this.#fail("SOA_PROTOCOL_MISMATCH");
}
} else if (message?.type === "snapshot-init") {
try {
if (message.controlVersion !== 1 || message.schemaVersion !== 2) throw new Error("version");
@@ -78,6 +125,13 @@ export class RendererClient {
}
}
#installArray(descriptor) {
const existing = this.#arrays.get(descriptor?.name);
if (existing) existing.update(descriptor);
else this.#arrays.set(descriptor?.name, new SharedSoaArray(this.#bridge.memory, descriptor));
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;}
@@ -85,7 +139,7 @@ export class RendererClient {
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:this.#instance([hit.slot>>>0,hit.generation>>>0]),distance:hit.distance}));p.resolve({epoch:latest,hits});
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"));}}
@@ -110,6 +164,7 @@ export class RendererClient {
if (this.#disposed) { this.#stop(); return; }
this.#disposed = true;
const error = new RendererError(code);
this.#readyReject?.(error);
for (const pending of this.#pending.values()) pending.reject(error);
this.#pending.clear();
for (const pending of this.#payloadPending.values()) pending.reject(error);
@@ -156,34 +211,42 @@ export class RendererClient {
return promise;
}
#mesh(handle, defaultInstanceHandle, defaultType = Array(16).fill(0)) {
return new Mesh(HANDLE_TOKEN,
this.#instance(defaultInstanceHandle),
async (transform, {type = defaultType} = {}) => {
type = typeWords(type);
const result = await this.#enqueue(OP.CREATE_INSTANCE, [...handle, ...floatWords(transform), ...type]);
return this.#instance(result);
});
}
#instance(handle) {
return new Instance(HANDLE_TOKEN,
type => this.#enqueue(OP.SET_INSTANCE_TYPE, [...handle, ...typeWords(type)]),
transform => this.#enqueue(OP.INSTANCE_TRANSFORM, [...handle, ...floatWords(transform)]),
() => this.#enqueue(OP.DESTROY_INSTANCE, [...handle]));
}
async replaceSceneGlb(source, { framing = "exterior" } = {}) {
commitGlbUpload(array, byteLength, { framing = "exterior" } = {}) {
if (this.#disposed) throw new RendererError("DISPOSED");
if (framing !== "exterior" && framing !== "interior") throw new TypeError("framing must be exterior or interior");
let buffer;
if (typeof source === "string" || source instanceof URL) buffer = await (await fetch(source)).arrayBuffer();
else if (typeof File !== "undefined" && source instanceof File) buffer = await source.arrayBuffer();
else if (source instanceof ArrayBuffer) buffer = source;
else throw new TypeError("GLB source must be URL, File, or ArrayBuffer");
if (this.#disposed) throw new RendererError("DISPOSED");
const result = await this.#withPayload(buffer, OP.IMPORT_GLB, [framing === "interior" ? 1 : 0]);
return result.meshes.map(item => this.#mesh(item.handle, item.defaultInstance, item.defaultType));
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]);
}
async createInstance(mesh, transform, { type = Array(16).fill(0) } = {}) {
validateHandle(mesh, "mesh");
const result = await this.#enqueue(OP.CREATE_INSTANCE, [...mesh, ...floatWords(transform), ...typeWords(type)]);
validateHandle(result, "instance");
return result;
}
setInstanceTransform(instance, transform) {
this.#validateLiveInstance(instance);
this.array("instance.transform").write(instance[0], floatValues(transform), instance[1]);
}
setInstanceType(instance, type) {
this.#validateLiveInstance(instance);
this.array("instance.type").write(instance[0], typeWords(type), instance[1]);
}
destroyInstance(instance) {
this.#validateLiveInstance(instance);
return this.#enqueue(OP.DESTROY_INSTANCE, instance);
}
#validateLiveInstance(instance) {
validateHandle(instance, "instance");
const generation = this.array("instance.generation").read(instance[0])[0];
if (generation !== instance[1]) throw new RendererError("STALE_HANDLE");
}
@@ -228,10 +291,9 @@ export class RendererClient {
async #compileGraph(graph) {
if (this.#disposed) throw new RendererError("DISPOSED");
let json;
try { json = JSON.stringify(graph); } catch (error) { throw new RendererError("GRAPH_JSON_INVALID", { message: error?.message || "GRAPH_JSON_INVALID" }); }
if (json === undefined) throw new RendererError("GRAPH_JSON_INVALID");
const buffer = new TextEncoder().encode(json).buffer;
if (typeof graph !== "string") throw new TypeError("graph must be a serialized render-graph AST");
const source = graph;
const buffer = new TextEncoder().encode(source).buffer;
if (buffer.byteLength > 1024 * 1024) throw new RendererError("GRAPH_PAYLOAD_TOO_LARGE");
return this.#withPayload(buffer, OP.COMPILE_GRAPH);
}
@@ -258,34 +320,135 @@ function validateCompiledId(compiledId) {
if (!Array.isArray(compiledId) || compiledId.length !== 2 || compiledId.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError("compiledId must contain exactly two uint32 values");
}
function validateHandle(handle, name) {
if (!Array.isArray(handle) || handle.length !== 2 || handle.some(word => !Number.isInteger(word) || word < 0 || word > 0xffffffff)) throw new TypeError(`${name} handle must contain exactly two uint32 values`);
}
function typeWords(words) { if (!words || words.length !== 16 || [...words].some(x => !Number.isInteger(x) || x < 0 || x > 0xffffffff)) throw new TypeError("type must contain exactly 16 uint32 values"); return Array.from(words, x => x >>> 0); }
function floatWords(matrix) {
if (!matrix || matrix.length !== 16) throw new TypeError("transform must contain 16 numbers");
return [...new Int32Array(new Float32Array(matrix).buffer)];
return [...new Int32Array(new Float32Array(floatValues(matrix)).buffer)];
}
class Mesh {
#defaultInstance; #createInstance;
constructor(token, defaultInstance, createInstance) {
if (token !== HANDLE_TOKEN) throw new TypeError("Mesh cannot be constructed directly");
this.#defaultInstance = defaultInstance;
this.#createInstance = createInstance;
}
get defaultInstance() { return this.#defaultInstance; }
createInstance(transform, options = {}) { return this.#createInstance(transform, options); }
function floatValues(values) {
if (!values || values.length !== 16 || [...values].some(value => typeof value !== "number" || !Number.isFinite(value))) throw new TypeError("transform must contain 16 finite numbers");
return Array.from(values);
}
class Instance {
#setType; #setTransform; #destroy; #dead = false;
constructor(token, setType, setTransform, destroy) {
if (token !== HANDLE_TOKEN) throw new TypeError("Instance cannot be constructed directly");
this.#setType = setType;
this.#setTransform = setTransform;
this.#destroy = destroy;
const SOA_MAGIC = 0x414f5359;
const SCALAR_TAG = { u32: 1, i32: 2, f32: 3 };
export class SharedSoaArray {
#memory; #descriptor; #buffer; #control; #words;
constructor(memory, descriptor) {
this.#memory = memory;
this.update(descriptor);
}
get name() { return this.#descriptor.name; }
get id() { return this.#descriptor.id; }
get domain() { return this.#descriptor.domain; }
get scalar() { return this.#descriptor.scalar; }
get lanes() { return this.#descriptor.lanes; }
get stride() { return this.#descriptor.stride; }
get length() { this.#refresh(); return Atomics.load(this.#control, 6) >>> 0; }
get capacity() { return this.#descriptor.capacity; }
/** Returns the shared backing store and current wire descriptor for another worker. */
share() {
this.#refresh();
return { buffer: this.#memory.buffer, descriptor: { ...this.#descriptor } };
}
update(descriptor) {
if (!descriptor || typeof descriptor.name !== "string" || !SCALAR_TAG[descriptor.scalar] || typeof descriptor.writable !== "boolean" || (descriptor.generationGuard !== undefined && descriptor.generationGuard !== "instance" && descriptor.generationGuard !== "mesh"))
throw new RendererError("SOA_PROTOCOL_MISMATCH");
if (this.#descriptor && (descriptor.id !== this.#descriptor.id || descriptor.layoutEpoch < this.#descriptor.layoutEpoch))
throw new RendererError("SOA_PROTOCOL_MISMATCH");
this.#descriptor = Object.freeze({ ...descriptor });
this.#buffer = null;
this.#refresh();
}
#refresh() {
const buffer = this.#memory.buffer;
if (this.#buffer === buffer && this.#control?.byteOffset === this.#descriptor.controlPtr) return;
const descriptor = this.#descriptor;
if (!(buffer instanceof SharedArrayBuffer) || descriptor.controlPtr % 64 || descriptor.dataOffset !== 64 || descriptor.stride % 16)
throw new RendererError("SOA_PROTOCOL_MISMATCH");
this.#buffer = buffer;
this.#control = new Int32Array(buffer, descriptor.controlPtr, 16);
this.#words = new Int32Array(buffer, descriptor.controlPtr + descriptor.dataOffset, descriptor.byteLength / 4);
if ((Atomics.load(this.#control, 0) >>> 0) !== SOA_MAGIC || (Atomics.load(this.#control, 1) >>> 0) !== 1 || (Atomics.load(this.#control, 2) >>> 0) !== descriptor.id || (Atomics.load(this.#control, 3) >>> 0) !== SCALAR_TAG[descriptor.scalar])
throw new RendererError("SOA_PROTOCOL_MISMATCH");
}
#encode(value) {
if (this.scalar === "u32") {
if (!Number.isInteger(value) || value < 0 || value > 0xffffffff) throw new TypeError("value must be a uint32");
return value | 0;
}
if (this.scalar === "i32") {
if (!Number.isInteger(value) || value < -0x80000000 || value > 0x7fffffff) throw new TypeError("value must be an int32");
return value | 0;
}
if (typeof value !== "number" || !Number.isFinite(value)) throw new TypeError("value must be a finite float32");
return new Int32Array(new Float32Array([value]).buffer)[0];
}
#decode(word) {
if (this.scalar === "u32") return word >>> 0;
if (this.scalar === "i32") return word | 0;
return new Float32Array(new Int32Array([word]).buffer)[0];
}
#lock() {
this.#refresh();
for (let attempt = 0; attempt < 1024; attempt++) {
const sequence = Atomics.load(this.#control, 9) >>> 0;
if (!(sequence & 1) && (Atomics.compareExchange(this.#control, 9, sequence | 0, (sequence + 1) | 0) >>> 0) === sequence)
return sequence;
}
throw new RendererError("SOA_BUSY");
}
#unlock(sequence) {
Atomics.store(this.#control, 9, (sequence + 2) | 0);
Atomics.notify(this.#control, 9);
}
read(slot) {
this.#refresh();
if (!Number.isInteger(slot) || slot < 0 || slot >= this.length) throw new RangeError("slot is outside the shared array");
const base = slot * (this.stride / 4);
for (let attempt = 0; attempt < 1024; attempt++) {
const before = Atomics.load(this.#control, 9) >>> 0;
if (before & 1) continue;
const values = Array.from({ length: this.lanes }, (_, lane) => this.#decode(Atomics.load(this.#words, base + lane)));
const after = Atomics.load(this.#control, 9) >>> 0;
if (before === after && !(after & 1)) return values;
}
throw new RendererError("SOA_BUSY");
}
write(slot, values, generation) {
if (!this.#descriptor.writable) throw new RendererError("SOA_READ_ONLY");
if (!values || values.length !== this.lanes) throw new TypeError(`values must contain ${this.lanes} lanes`);
if (this.#descriptor.generationGuard !== undefined && (!Number.isInteger(generation) || generation < 1 || generation > 0xffffffff))
throw new TypeError("generation must be a nonzero uint32");
const encoded = Array.from(values, value => this.#encode(value));
const sequence = this.#lock();
try {
if (!Number.isInteger(slot) || slot < 0 || slot >= (Atomics.load(this.#control, 6) >>> 0)) throw new RangeError("slot is outside the shared array");
const base = slot * (this.stride / 4);
encoded.forEach((word, lane) => Atomics.store(this.#words, base + lane, word));
if (this.#descriptor.generationGuard !== undefined) {
Atomics.store(this.#words, base + this.lanes, generation | 0);
Atomics.add(this.#words, base + this.lanes + 1, 1);
}
} finally {
this.#unlock(sequence);
}
}
#live() { if (this.#dead) throw new RendererError("STALE_HANDLE"); }
setType(words) { this.#live(); return this.#setType(words); }
setTransform(transform) { this.#live(); return this.#setTransform(transform); }
async destroy() { this.#live(); await this.#destroy(); this.#dead = true; }
}
@@ -1,3 +1,4 @@
/** Shared render-data snapshot protocol used by core picking workers. */
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,
+55 -180
View File
@@ -1,193 +1,68 @@
#![cfg(target_arch = "wasm32")]
use std::sync::mpsc::{self, Sender};
use wasm_bindgen::closure::Closure;
use std::cell::RefCell;
use std::sync::mpsc;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::spawn_local;
use crate::command_ring::CommandRing;
use crate::message::WindowEvent;
use crate::platform::web;
use crate::platform::web::worker::MainWorker;
use wasm_bindgen_futures::spawn_local;
use web_sys::AddEventListenerOptions;
use crate::message::{MouseMessage, ResizeMessage, WheelMessage, WindowEvent};
use crate::platform::web::worker;
pub struct EventListeners {
_resize_listener: Closure<dyn FnMut()>,
_pointer_listener: Closure<dyn FnMut(web_sys::PointerEvent)>,
_click_listener: Closure<dyn FnMut(web_sys::MouseEvent)>,
_wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)>,
_contextmenu_listener: Closure<dyn FnMut(web_sys::MouseEvent)>,
thread_local! {
static WORKER_EVENTS: RefCell<Option<mpsc::Sender<WindowEvent>>> = const { RefCell::new(None) };
}
/// Setup default window event listeners that forward events to the worker thread
pub fn setup_event_listeners(
worker_chan: &Sender<WindowEvent>,
canvas: &web_sys::HtmlCanvasElement,
) -> Result<EventListeners, JsValue> {
let window = web_sys::window().unwrap();
let resize_worker_chan = worker_chan.clone();
let resize_canvas = canvas.clone();
let resize_listener: Closure<dyn FnMut()> = Closure::new(move || {
use crate::message::ResizeMessage;
let window = web_sys::window().unwrap();
let width = f64::from(resize_canvas.client_width().max(1));
let height = f64::from(resize_canvas.client_height().max(1));
let _ = resize_worker_chan.send(WindowEvent::Resize(ResizeMessage {
width,
height,
scale_factor: window.device_pixel_ratio(),
}));
});
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
let pointer_worker_chan = worker_chan.clone();
let pointer_canvas = canvas.clone();
let pointer_listener: Closure<dyn FnMut(web_sys::PointerEvent)> =
Closure::new(move |event: web_sys::PointerEvent| {
use crate::message::{camera_drag, MouseMessage};
if event.pointer_type() != "mouse" {
return;
/// Deliver a low-frequency browser event to the worker-owned renderer channel.
#[wasm_bindgen]
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 {
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)
}
match event.type_().as_str() {
"pointerdown" if matches!(event.button(), 1 | 2) => {
event.prevent_default();
let _ = pointer_canvas.set_pointer_capture(event.pointer_id());
}
"pointermove"
if pointer_canvas.has_pointer_capture(event.pointer_id())
&& camera_drag(event.buttons()).is_some() =>
{
event.prevent_default();
let message = MouseMessage::from_pointer_evt(
&event,
f64::from(pointer_canvas.client_height().max(1)),
);
let _ = pointer_worker_chan.send(WindowEvent::PointerMove(message));
}
"pointerup" | "pointercancel"
if pointer_canvas.has_pointer_capture(event.pointer_id()) =>
{
let _ = pointer_canvas.release_pointer_capture(event.pointer_id());
}
_ => {}
}
});
for event_name in ["pointerdown", "pointermove", "pointerup", "pointercancel"] {
canvas.add_event_listener_with_callback(
event_name,
pointer_listener.as_ref().unchecked_ref(),
)?;
}
let click_worker_chan = worker_chan.clone();
let click_canvas = canvas.clone();
let click_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| {
use crate::message::MouseMessage;
if event.button() != 0 {
return;
}
let message =
MouseMessage::from_evt(&event, f64::from(click_canvas.client_height().max(1)));
let _ = click_worker_chan.send(WindowEvent::PointerClick(message));
});
canvas.add_event_listener_with_callback("click", click_listener.as_ref().unchecked_ref())?;
let wheel_worker_chan = worker_chan.clone();
let wheel_canvas = canvas.clone();
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
Closure::new(move |event: web_sys::WheelEvent| {
use crate::message::WheelMessage;
event.prevent_default();
if let Some(message) =
WheelMessage::from_evt(&event, f64::from(wheel_canvas.client_height().max(1)))
{
let _ = wheel_worker_chan.send(WindowEvent::PointerWheel(message));
}
});
let wheel_options = {
let options = AddEventListenerOptions::new();
options.set_passive(false);
options
}
3 => WindowEvent::PointerWheel(WheelMessage {
delta_y_pixels: value(0) as f32,
}),
_ => return,
};
canvas.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
wheel_listener.as_ref().unchecked_ref(),
&wheel_options,
)?;
let contextmenu_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| event.prevent_default());
canvas.add_event_listener_with_callback(
"contextmenu",
contextmenu_listener.as_ref().unchecked_ref(),
)?;
Ok(EventListeners {
_resize_listener: resize_listener,
_pointer_listener: pointer_listener,
_click_listener: click_listener,
_wheel_listener: wheel_listener,
_contextmenu_listener: contextmenu_listener,
})
WORKER_EVENTS.with(|sender| {
if let Some(sender) = sender.borrow().as_ref() {
let _ = sender.send(event);
}
});
}
/// Runtime resources required to keep a WASM application running.
pub struct WebAppRuntime {
worker: MainWorker,
_event_listeners: EventListeners,
ring: Box<CommandRing>,
}
impl WebAppRuntime {
/// Initialize the web worker, canvas ownership, and event listeners.
pub fn new<T: crate::renderer::scene::Scene + 'static>(
worker_name: &str,
canvas_selector: &str,
profile: bool,
) -> Result<Self, JsValue> {
let (sender, receiver) = mpsc::channel::<WindowEvent>();
let canvas = web::get_canvas_element(canvas_selector);
let window = web_sys::window().unwrap();
let dpr = window.device_pixel_ratio();
canvas.set_width((canvas.client_width() as f64 * dpr).round() as u32);
canvas.set_height((canvas.client_height() as f64 * dpr).round() as u32);
let ring = CommandRing::new();
let ring_ptr = ring.ptr();
let worker = MainWorker::spawn(worker_name, 1, ring_ptr, move || {
spawn_local(async move {
let ring = unsafe { &*(ring_ptr as *const CommandRing) };
MainWorker::run_render_loop::<T>(receiver, ring, profile).await;
});
})?;
worker.transfer_ownership(&canvas);
let event_listeners = setup_event_listeners(&sender, &canvas)?;
Ok(Self {
worker,
_event_listeners: event_listeners,
ring,
})
}
/// Access the spawned worker reference.
pub fn worker(&self) -> &MainWorker {
&self.worker
}
pub fn ring_ptr(&self) -> u32 {
self.ring.ptr()
}
/// Start the typed renderer and return its SAB command-ring pointer.
pub fn worker_entrypoint<T: crate::renderer::scene::Scene + 'static>(profile: bool) -> 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.
let ring: &'static CommandRing = Box::leak(CommandRing::new());
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;
});
ring_ptr
}
-3
View File
@@ -1,3 +0,0 @@
fn main() {
println!("hello world!");
}
-35
View File
@@ -1,35 +0,0 @@
struct UniformData {
mouse_move: vec2<f32>,
mouse_click: vec2<f32>,
resolution: vec2<f32>,
time: f32,
}
@group(0) @binding(0) var<uniform> uni: UniformData;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
struct VertexInput {
@location(0) pos: vec3<f32>,
@location(1) color: vec3<f32>
}
struct VertexOutput {
@builtin(position) pos: vec4<f32>,
@location(1) color: vec3<f32>
}
@vertex
fn v_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.pos = vec4<f32>(in.pos, 1.0);
let fluc = sin(modf(uni.time).fract * 3.141592) * 0.3 + 0.7;
out.color = in.color * fluc;
return out;
}
@fragment
fn f_main(in: VertexOutput) -> @location(0) vec4<f32> {
let x = select(0.0, 0.3, distance(in.pos.xy, uni.mouse_move) < 25.0);
let y = select(0.0, 0.3, distance(in.pos.xy, uni.mouse_click) < 25.0);
return vec4f(in.color + x - y, 1.0);
}
+1 -3
View File
@@ -5,7 +5,7 @@ use ultraviolet::{Mat4, Vec3};
use crate::render_data::{
InstanceHandle, InstanceType, MaterialKey, MeshCreateInfo, MeshHandle, ModelTransform,
PipelineKey, RenderData, RenderDataError,
RenderData, RenderDataError,
};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -527,7 +527,6 @@ fn decode_gltf_model(mut model: Gltf) -> Result<ImportedScene, ImportError> {
pub fn install_imported(
target: &mut RenderData,
imported: &ImportedScene,
pipelines: [PipelineKey; 2],
) -> Result<InstalledScene, ImportError> {
let mut stage = target.replacement_stage()?;
let mut handles = HashMap::new();
@@ -547,7 +546,6 @@ pub fn install_imported(
tangents: &geometry.tangents,
uvs: &geometry.uvs,
indices: &geometry.indices,
pipeline: pipelines[usize::from(geometry.double_sided)],
material: geometry.material,
default_instance_type: InstanceType {
words: [
-52
View File
@@ -1,52 +0,0 @@
struct UniformData { mouse_move: vec2<f32>, mouse_click: vec2<f32>, 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>;
@group(2) @binding(0) var<uniform> material: MaterialData;
@group(2) @binding(1) var base_tex: texture_2d<f32>;
@group(2) @binding(2) var mr_tex: texture_2d<f32>;
@group(2) @binding(3) var normal_tex: texture_2d<f32>;
@group(2) @binding(4) var occlusion_tex: texture_2d<f32>;
@group(2) @binding(5) var emissive_tex: texture_2d<f32>;
@group(2) @binding(6) var base_sampler: sampler;
@group(2) @binding(7) var mr_sampler: sampler;
@group(2) @binding(8) var normal_sampler: sampler;
@group(2) @binding(9) var occlusion_sampler: sampler;
@group(2) @binding(10) var emissive_sampler: sampler;
struct VertexInput { @location(0) pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) uv: vec2<f32>, @location(3) model_col0: vec4<f32>, @location(4) model_col1: vec4<f32>, @location(5) model_col2: vec4<f32>, @location(6) model_col3: vec4<f32>, @location(7) normal_col0: vec4<f32>, @location(8) normal_col1: vec4<f32>, @location(9) normal_col2: vec4<f32>, @location(10) tangent: vec4<f32> }
struct VertexOutput { @builtin(position) clip_position: vec4<f32>, @location(0) world_pos: vec3<f32>, @location(1) normal: vec3<f32>, @location(2) tangent: vec3<f32>, @location(3) bitangent: vec3<f32>, @location(4) uv: vec2<f32>, @location(5) @interpolate(flat) determinant_sign: f32 }
fn safe_normalize(v: vec3<f32>, fallback: vec3<f32>) -> vec3<f32> { let l2 = dot(v, v); return select(fallback, v * inverseSqrt(l2), l2 > 1e-12 && l2 < 1e30); }
@vertex fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput; let model = mat4x4<f32>(in.model_col0, in.model_col1, in.model_col2, in.model_col3);
let linear = mat3x3<f32>(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3<f32>(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz);
let world = model * vec4<f32>(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3<f32>(0,1,0)); let raw_t = linear * in.tangent.xyz;
var t = raw_t - n * dot(n, raw_t); if dot(t,t) < 1e-8 { t = cross(select(vec3<f32>(0,1,0), vec3<f32>(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3<f32>(1,0,0));
out.clip_position = view_proj * world; out.world_pos = world.xyz; out.normal = n; out.tangent = t; out.bitangent = safe_normalize(cross(n,t), vec3<f32>(0,0,1)) * in.tangent.w * in.normal_col0.w; out.uv = in.uv; out.determinant_sign = in.normal_col0.w; return out;
}
struct Closure { base: vec4<f32>, mr: vec2<f32>, normal_map: vec3<f32>, ao: f32, emissive: vec3<f32> }
fn sample_closure(uv: vec2<f32>) -> Closure {
let bits = material.flags.x; var c: Closure;
c.base = material.base_color_factor * select(vec4<f32>(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u);
let mr = select(vec4<f32>(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2<f32>(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1));
c.normal_map = select(vec3<f32>(0.5,0.5,1), textureSample(normal_tex, normal_sampler, uv).xyz, (bits & 4u) != 0u);
let occ = select(1.0, textureSample(occlusion_tex, occlusion_sampler, uv).r, (bits & 8u) != 0u); c.ao = mix(1.0, occ, material.surface_factors.w);
c.emissive = material.emissive_factor.rgb * select(vec3<f32>(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c;
}
fn schlick(f0: vec3<f32>, v_h: f32) -> vec3<f32> { return f0 + (vec3<f32>(1)-f0) * pow(1.0-clamp(v_h,0,1),5.0); }
fn ggx_d(n_h_input: f32, a: f32) -> f32 { let n_h=clamp(n_h_input,0.0,1.0); let a2=a*a; let nh2=n_h*n_h; let q=(1.0-nh2)+a2*nh2; return a2/(3.14159265*q*q); }
fn smith_v(n_v: f32, n_l: f32, a: f32) -> f32 { let a2=a*a; let gv=n_l*sqrt(max(n_v*n_v*(1.0-a2)+a2,0)); let gl=n_v*sqrt(max(n_l*n_l*(1.0-a2)+a2,0)); return 0.5/max(gv+gl,1e-6); }
@fragment fn fs_main(in: VertexOutput, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let c=sample_closure(in.uv); if material.alpha_optics.x == 1.0 && c.base.a < material.alpha_optics.y { discard; }
let physical_front=front == (in.determinant_sign > 0); let orientation=select(-1.0,1.0,physical_front || material.flags.y == 0u); let map=c.normal_map*2.0-1.0;
let n=safe_normalize(mat3x3<f32>(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3<f32>(map.xy*material.surface_factors.z,map.z),vec3<f32>(0,0,1)),in.normal)*orientation;
let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3<f32>(0.35,1,0.45),vec3<f32>(0,1,0)); let h=safe_normalize(v+l,n);
let nv=max(dot(n,v),0); let nl=max(dot(n,l),0); let nh=dot(n,h); let vh=max(dot(v,h),0); let a=c.mr.y*c.mr.y;
let f0=mix(vec3<f32>(material.alpha_optics.w),c.base.rgb,c.mr.x); let direct_f=schlick(f0,vh); let env_f=schlick(f0,nv); let spec=direct_f*ggx_d(nh,a)*smith_v(nv,nl,a); let diffuse=(vec3<f32>(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265;
let sun=(diffuse+spec)*nl*vec3<f32>(3.0,2.85,2.65);
let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3<f32>(0.055,0.045,0.035),vec3<f32>(0.24,0.36,0.58),up); let env_diff=(vec3<f32>(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky;
let reflection=reflect(-v,n); let horizon=clamp(reflection.y*0.5+0.5,0,1); let env_spec=env_f*mix(vec3<f32>(0.04,0.035,0.03),vec3<f32>(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y);
var color=sun+(env_diff+env_spec)*c.ao+c.emissive;
if material.debug_extras.y == 1u { color=n*0.5+0.5; } else if material.debug_extras.y == 2u { color=vec3<f32>(c.mr.x,c.mr.y,c.ao); } else if material.debug_extras.y == 3u { color=f0; }
return vec4<f32>(color,1.0); // BLEND remains intentionally opaque.
}
+1 -24
View File
@@ -8,6 +8,7 @@ pub mod render_data;
pub mod render_graph;
pub mod renderer;
pub mod shared_snapshot;
pub mod shared_soa;
#[cfg(target_arch = "wasm32")]
thread_local! { static PAYLOADS: std::cell::RefCell<std::collections::HashMap<u32, Vec<u8>>> = Default::default(); }
@@ -43,27 +44,3 @@ pub(crate) fn take_payload(id: u32) -> Option<Vec<u8>> {
pub(crate) fn take_payload(_id: u32) -> Option<Vec<u8>> {
None
}
/// Worker entrypoint helper - executes the closure it is spawned with
/// Applications should export this with #[wasm_bindgen]
pub fn worker_entrypoint_impl(ptr: u32) {
let work = unsafe { Box::from_raw(ptr as *mut Box<dyn FnOnce()>) };
(*work)();
}
/// Macro to export the worker_entrypoint function in application crates
///
/// Usage:
/// ```rust
/// use renderer::export_worker_entrypoint;
/// export_worker_entrypoint!();
/// ```
#[macro_export]
macro_rules! export_worker_entrypoint {
() => {
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn worker_entrypoint(ptr: u32) {
$crate::worker_entrypoint_impl(ptr);
}
};
}
-3
View File
@@ -1,5 +1,2 @@
#[cfg(target_arch = "wasm32")]
pub mod web;
#[cfg(not(target_arch = "wasm32"))]
mod native;
-1
View File
@@ -1 +0,0 @@
-9
View File
@@ -1,10 +1 @@
use wasm_bindgen::JsCast;
pub mod worker;
pub fn get_canvas_element(selectors: &str) -> web_sys::HtmlCanvasElement {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let element = document.query_selector(selectors).unwrap().unwrap();
element.dyn_into::<web_sys::HtmlCanvasElement>().unwrap()
}
+27 -17
View File
@@ -1,8 +1,14 @@
// Generic worker that imports the app's WASM module relative to the generated pkg folder.
// Works for any application because the relative depth from this file to pkg is stable.
import initWasm, { clear_payloads, discard_payload, stage_payload, worker_entrypoint } from "/level-editor/pkg/level_editor.js";
// The level editor's render worker owns its only WASM and WebGPU runtime.
import initWasm, {
clear_payloads,
discard_payload,
stage_payload,
worker_main,
worker_memory,
worker_window_event,
} from "/level-editor/pkg/level_editor.js";
export function listenerReady() {
function listenerReady() {
if (state !== "waiting-listener") return;
state = "replaying";
for (const queued of pending.splice(0)) route(queued);
@@ -24,22 +30,24 @@ addEventListener("message", async (event) => {
}
if (state !== "uninitialized") return;
state = "initializing";
const { wasmModule, workerId, memory, entryPtr } = message;
const { canvas, profile } = message;
console.log(
"worker: initializing with WASM module",
wasmModule,
"id:",
workerId,
);
// Initialize WASM with the shared module and memory forwarded from the main thread.
// The renderer worker exclusively owns the one WASM instance. Other threads
// receive only its shared memory and mutate the published SAB layouts.
try {
api = await initWasm({ module_or_path: wasmModule, memory });
state = "waiting-listener";
worker_entrypoint(entryPtr);
api = await initWasm();
} catch (error) {
fatal("WORKER_INIT_FAILED", String(error));
fatal("WORKER_INIT_FAILED", error?.stack || String(error));
return;
}
state = "waiting-listener";
pending.push({ type: "canvas", canvas });
try {
const ringPtr = worker_main(profile);
postMessage({ type: "bootstrap", memory: worker_memory(), ringPtr });
setTimeout(listenerReady, 0);
} catch (error) {
fatal("WORKER_ENTRY_FAILED", error?.stack || String(error));
}
});
@@ -51,6 +59,8 @@ function route(message) {
postMessage({ type: "payload-ready", id: message.id });
} else if (message?.type === "payload-release") {
discard_payload(message.id);
} else if (message?.type === "window-event") {
worker_window_event(message.kind, message.values);
}
}
+13 -120
View File
@@ -2,130 +2,25 @@ use crate::command_ring::CommandRing;
use crate::message::WindowEvent;
use log::info;
use std::sync::mpsc::Receiver;
use std::{cell::RefCell, fmt::Debug, ops::Deref, rc::Rc};
use std::{cell::RefCell, rc::Rc};
use wasm_bindgen::{prelude::*, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::MessageEvent;
/// Binds JS.
#[wasm_bindgen(module = "/src/platform/web/worker/workerGen.js")]
extern "C" {
/// Spawn new worker in JS side in order to make bundler know about dependency.
#[wasm_bindgen(js_name = "createWorker")]
fn create_worker(kind: &str, name: &str) -> web_sys::Worker;
}
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
profile: bool,
) {
use crate::renderer::Renderer;
/// Binds JS.
/// This makes wasm-bindgen bring `mainWorker.js` to the `pkg` directory.
/// So that bundler can bundle it together.
#[wasm_bindgen(module = "/src/platform/web/worker/mainWorker.js")]
extern "C" {
#[wasm_bindgen(js_name = "listenerReady")]
fn listener_ready();
}
let canvas = wait_for_canvas_transfer().await;
pub struct MainWorker {
handle: web_sys::Worker,
name: String,
_callback: Closure<dyn FnMut(web_sys::Event)>,
}
impl Drop for MainWorker {
/// Terminates web worker *immediately*.
fn drop(&mut self) {
self.handle.terminate();
info!("Worker({}) was terminated", &self.name);
}
}
impl Debug for MainWorker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MainWorker")
.field("handle", &self.handle)
.field("name", &self.name)
.finish()
}
}
impl MainWorker {
/// Spawns main worker from the window context.
pub fn spawn(
name: &str,
id: usize,
ring_ptr: u32,
f: impl FnOnce() + Send + 'static,
) -> Result<Self, JsValue> {
// Creates a new worker.
let handle = create_worker("main", name);
// Double-boxing because `dyn FnOnce` is unsized and so `Box<dyn FnOnce()>` has
// an undefined layout (although I think in practice its a pointer and a length?).
let ptr = Box::into_raw(Box::new(Box::new(f) as Box<dyn FnOnce()>));
// Sets default callback.
let callback = Closure::new(|_ev| {});
handle.set_onmessage(Some(callback.as_ref().unchecked_ref()));
let msg = js_sys::Object::new();
for (key, value) in [
("type", JsValue::from("init")),
("wasmModule", wasm_bindgen::module()),
("workerId", id.into()),
("memory", wasm_bindgen::memory()),
("entryPtr", (ptr as u32).into()),
("ringPtr", ring_ptr.into()),
] {
js_sys::Reflect::set(&msg, &key.into(), &value)?;
}
info!("posting message");
handle.post_message(&msg)?;
Ok(Self {
handle,
name: name.to_owned(),
_callback: callback,
})
}
pub fn transfer_ownership(&self, canvas: &web_sys::HtmlCanvasElement) {
let offscreen_canvas = canvas.transfer_control_to_offscreen().unwrap();
let transfer_list = js_sys::Array::new();
transfer_list.push(&offscreen_canvas);
let msg = js_sys::Object::new();
js_sys::Reflect::set(&msg, &"type".into(), &"canvas".into()).unwrap();
js_sys::Reflect::set(&msg, &"canvas".into(), &offscreen_canvas).unwrap();
info!("posting canvas (is_undefined: {})", canvas.is_undefined());
self.handle
.post_message_with_transfer(&msg, &transfer_list)
.unwrap();
}
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
ring: &'static CommandRing,
profile: bool,
) {
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,
));
renderer.borrow_mut().command_ring = Some(ring);
Renderer::run_render_loop(renderer);
}
}
impl Deref for MainWorker {
type Target = web_sys::Worker;
#[inline]
fn deref(&self) -> &Self::Target {
&self.handle
}
let renderer = Rc::new(RefCell::new(
Renderer::<T>::new(canvas, events_chan, profile).await,
));
renderer.borrow_mut().command_ring = Some(ring);
Renderer::run_render_loop(renderer);
}
pub async fn wait_for_canvas_transfer() -> web_sys::OffscreenCanvas {
@@ -149,8 +44,6 @@ pub async fn wait_for_canvas_transfer() -> web_sys::OffscreenCanvas {
.add_event_listener_with_callback("renderer-canvas", handler.as_ref().unchecked_ref())
.unwrap();
handler.forget();
listener_ready();
});
let canvas: web_sys::OffscreenCanvas = JsFuture::from(promise)
@@ -1,13 +0,0 @@
export function createWorker(kind, name) {
switch (kind) {
case 'main':
const main = new Worker(new URL('./mainWorker.js', import.meta.url), {
type: 'module',
/* @vite-ignore */ name, // vite doesn't allow non static value here.
});
return main;
default:
console.log("unsurpported type of worker: ", kind);
return undefined;
}
}
-24
View File
@@ -26,20 +26,6 @@ pub const IDENTITY_MODEL_TRANSFORM: ModelTransform = [
pub const IDENTITY_NORMAL_MATRIX: NormalMatrix =
[[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]];
#[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PipelineKey(u32);
impl PipelineKey {
pub const fn new(value: u32) -> Self {
Self(value)
}
pub const fn get(self) -> u32 {
self.0
}
}
/// Stable CPU-side identity for a device-independent material.
#[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
@@ -91,7 +77,6 @@ pub struct MeshCreateInfo<'a> {
pub tangents: &'a [[f32; 4]],
pub uvs: &'a [[f32; 2]],
pub indices: &'a [u32],
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub default_instance_type: InstanceType,
pub default_transform: ModelTransform,
@@ -107,7 +92,6 @@ pub struct CreatedMesh {
pub struct MeshView {
pub handle: MeshHandle,
pub geometry: GeometryRange,
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub default_instance_type: InstanceType,
pub local_aabb: Aabb,
@@ -277,7 +261,6 @@ struct MeshSoa {
vertex_counts: Vec<u32>,
index_starts: Vec<u32>,
index_counts: Vec<u32>,
pipeline_keys: Vec<PipelineKey>,
material_keys: Vec<MaterialKey>,
default_instance_types: Vec<InstanceType>,
aabb_mins: Vec<[f32; 3]>,
@@ -561,7 +544,6 @@ impl RenderData {
index_start: index_range.start,
index_count,
},
info.pipeline,
info.material,
info.default_instance_type,
bounds,
@@ -839,7 +821,6 @@ impl MeshSoa {
vertex_counts: Vec::new(),
index_starts: Vec::new(),
index_counts: Vec::new(),
pipeline_keys: Vec::new(),
material_keys: Vec::new(),
default_instance_types: Vec::new(),
aabb_mins: Vec::new(),
@@ -859,7 +840,6 @@ impl MeshSoa {
reserve_vec(&mut self.vertex_counts, target, "meshes")?;
reserve_vec(&mut self.index_starts, target, "meshes")?;
reserve_vec(&mut self.index_counts, target, "meshes")?;
reserve_vec(&mut self.pipeline_keys, target, "meshes")?;
reserve_vec(&mut self.material_keys, target, "meshes")?;
reserve_vec(&mut self.default_instance_types, target, "meshes")?;
reserve_vec(&mut self.aabb_mins, target, "meshes")?;
@@ -874,7 +854,6 @@ impl MeshSoa {
&mut self,
prepared: PreparedSlot,
geometry: GeometryRange,
pipeline: PipelineKey,
material: MaterialKey,
default_instance_type: InstanceType,
bounds: Aabb,
@@ -885,7 +864,6 @@ impl MeshSoa {
resize_column(&mut self.vertex_counts, len, 0);
resize_column(&mut self.index_starts, len, 0);
resize_column(&mut self.index_counts, len, 0);
resize_column(&mut self.pipeline_keys, len, PipelineKey::new(0));
resize_column(&mut self.material_keys, len, MaterialKey::DEFAULT);
resize_column(&mut self.default_instance_types, len, InstanceType::ZERO);
resize_column(&mut self.aabb_mins, len, [0.0; 3]);
@@ -897,7 +875,6 @@ impl MeshSoa {
self.vertex_counts[index] = geometry.vertex_count;
self.index_starts[index] = geometry.index_start;
self.index_counts[index] = geometry.index_count;
self.pipeline_keys[index] = pipeline;
self.material_keys[index] = material;
self.default_instance_types[index] = default_instance_type;
self.aabb_mins[index] = bounds.min;
@@ -917,7 +894,6 @@ impl MeshSoa {
index_start: self.index_starts[index],
index_count: self.index_counts[index],
},
pipeline: self.pipeline_keys[index],
material: self.material_keys[index],
default_instance_type: self.default_instance_types[index],
local_aabb: Aabb {
-1
View File
@@ -14,7 +14,6 @@ fn info() -> MeshCreateInfo<'static> {
tangents: &TANGENTS,
uvs: &UVS,
indices: &INDICES,
pipeline: PipelineKey::new(7),
material: MaterialKey::new(11),
default_instance_type: InstanceType {
words: [3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+535
View File
@@ -0,0 +1,535 @@
//! Canonical S-expression wire AST.
//!
//! Nodes are definitions and `(ref "node" "socket")` forms are references, so one
//! output may feed any number of consumers without expanding the source expression.
use std::collections::{BTreeMap, HashSet};
use serde_json::Value;
use super::{
ComputePipelineDeclaration, ExecutorRef, Graph, GraphError, Node, NodeOutputRef, NodeState,
PipelineDeclarations, RenderPipelineDeclaration, MAX_AST_BYTES,
};
const AST_VERSION: u32 = 1;
#[derive(Clone, Debug, PartialEq)]
enum SExpr {
List(Vec<SExpr>),
Atom(String),
String(String),
}
struct Parser<'a> {
source: &'a str,
offset: usize,
}
impl Parser<'_> {
fn skip_trivia(&mut self) {
loop {
while self
.source
.as_bytes()
.get(self.offset)
.is_some_and(u8::is_ascii_whitespace)
{
self.offset += 1;
}
if self.source.as_bytes().get(self.offset) != Some(&b';') {
return;
}
while self
.source
.as_bytes()
.get(self.offset)
.is_some_and(|byte| *byte != b'\n')
{
self.offset += 1;
}
}
}
fn expression(&mut self) -> Result<SExpr, GraphError> {
self.skip_trivia();
match self.source.as_bytes().get(self.offset).copied() {
Some(b'(') => self.list(),
Some(b'"') => self.string(),
Some(b')') | None => Err(invalid("expected expression", self.offset)),
Some(_) => self.atom(),
}
}
fn list(&mut self) -> Result<SExpr, GraphError> {
self.offset += 1;
let mut values = Vec::new();
loop {
self.skip_trivia();
match self.source.as_bytes().get(self.offset).copied() {
Some(b')') => {
self.offset += 1;
return Ok(SExpr::List(values));
}
None => return Err(invalid("unterminated list", self.offset)),
_ => values.push(self.expression()?),
}
}
}
fn string(&mut self) -> Result<SExpr, GraphError> {
let start = self.offset;
self.offset += 1;
let mut escaped = false;
while let Some(byte) = self.source.as_bytes().get(self.offset).copied() {
self.offset += 1;
if escaped {
escaped = false;
} else if byte == b'\\' {
escaped = true;
} else if byte == b'"' {
let encoded = &self.source[start..self.offset];
let value = serde_json::from_str(encoded)
.map_err(|_| invalid("invalid string literal", start))?;
return Ok(SExpr::String(value));
}
}
Err(invalid("unterminated string", start))
}
fn atom(&mut self) -> Result<SExpr, GraphError> {
let start = self.offset;
while self.source.as_bytes().get(self.offset).is_some_and(|byte| {
!byte.is_ascii_whitespace() && !matches!(*byte, b'(' | b')' | b'"' | b';')
}) {
self.offset += 1;
}
if start == self.offset {
Err(invalid("invalid token", start))
} else {
Ok(SExpr::Atom(self.source[start..self.offset].to_owned()))
}
}
}
fn invalid(message: impl Into<String>, offset: usize) -> GraphError {
let message = message.into();
GraphError {
code: "GRAPH_AST_INVALID",
message: message.clone(),
details: serde_json::json!({"message":message,"offset":offset}),
}
}
fn list(value: &SExpr) -> Result<&[SExpr], GraphError> {
match value {
SExpr::List(values) => Ok(values),
_ => Err(invalid("expected list", 0)),
}
}
fn atom(value: &SExpr) -> Result<&str, GraphError> {
match value {
SExpr::Atom(value) => Ok(value),
_ => Err(invalid("expected symbol", 0)),
}
}
fn string(value: &SExpr) -> Result<String, GraphError> {
match value {
SExpr::String(value) => Ok(value.clone()),
_ => Err(invalid("expected string", 0)),
}
}
fn u32_value(value: &SExpr) -> Result<u32, GraphError> {
atom(value)?.parse().map_err(|_| invalid("expected u32", 0))
}
fn named_fields<'a>(values: &'a [SExpr]) -> Result<BTreeMap<&'a str, &'a [SExpr]>, GraphError> {
let mut fields = BTreeMap::new();
for value in values {
let field = list(value)?;
let Some(name) = field.first() else {
return Err(invalid("empty field", 0));
};
let name = atom(name)?;
if fields.insert(name, &field[1..]).is_some() {
return Err(invalid(format!("duplicate field '{name}'"), 0));
}
}
Ok(fields)
}
fn exact_field<'a>(
fields: &BTreeMap<&str, &'a [SExpr]>,
name: &str,
length: usize,
) -> Result<&'a [SExpr], GraphError> {
let values = fields
.get(name)
.copied()
.ok_or_else(|| invalid(format!("missing field '{name}'"), 0))?;
if values.len() != length {
return Err(invalid(format!("field '{name}' has invalid arity"), 0));
}
Ok(values)
}
fn json_value(value: &SExpr) -> Result<Value, GraphError> {
match value {
SExpr::String(value) => Ok(Value::String(value.clone())),
SExpr::Atom(value) if value == "true" => Ok(Value::Bool(true)),
SExpr::Atom(value) if value == "false" => Ok(Value::Bool(false)),
SExpr::Atom(value) if value == "null" => Ok(Value::Null),
SExpr::Atom(value) => serde_json::from_str(value)
.map_err(|_| invalid("value atom must be a finite JSON number", 0)),
SExpr::List(values)
if values.first().and_then(|value| atom(value).ok()) == Some("array") =>
{
values[1..]
.iter()
.map(json_value)
.collect::<Result<Vec<_>, _>>()
.map(Value::Array)
}
SExpr::List(values)
if values.first().and_then(|value| atom(value).ok()) == Some("object") =>
{
let mut object = serde_json::Map::new();
for field in &values[1..] {
let field = list(field)?;
if field.len() != 3 || atom(&field[0])? != "field" {
return Err(invalid("object entries must be (field string value)", 0));
}
let key = string(&field[1])?;
if object.insert(key.clone(), json_value(&field[2])?).is_some() {
return Err(invalid(format!("duplicate object field '{key}'"), 0));
}
}
Ok(Value::Object(object))
}
_ => Err(invalid("invalid data value", 0)),
}
}
fn node(value: &SExpr) -> Result<Node, GraphError> {
let values = list(value)?;
if values.len() < 4 || atom(&values[0])? != "node" {
return Err(invalid("invalid node definition", 0));
}
let id = string(&values[1])?;
let state = match atom(&values[2])? {
"enabled" => NodeState::Enabled,
"muted" => NodeState::Muted,
_ => return Err(invalid("node state must be enabled or muted", 0)),
};
let fields = named_fields(&values[3..])?;
if fields.len() != 3 {
return Err(invalid("node requires executor, params, and inputs", 0));
}
let executor = exact_field(&fields, "executor", 2)?;
let parameters = json_value(&exact_field(&fields, "params", 1)?[0])?;
let input_forms = fields
.get("inputs")
.copied()
.ok_or_else(|| invalid("missing field 'inputs'", 0))?;
let mut inputs = BTreeMap::new();
for input in input_forms {
let input = list(input)?;
if input.len() < 2 || atom(&input[0])? != "input" {
return Err(invalid("invalid input definition", 0));
}
let name = string(&input[1])?;
let mut references = Vec::new();
for reference in &input[2..] {
let reference = list(reference)?;
if reference.len() != 3 || atom(&reference[0])? != "ref" {
return Err(invalid("invalid DAG reference", 0));
}
references.push(NodeOutputRef {
node: string(&reference[1])?,
socket: string(&reference[2])?,
});
}
if inputs.insert(name.clone(), references).is_some() {
return Err(invalid(format!("duplicate input '{name}'"), 0));
}
}
Ok(Node {
id,
state,
executor: ExecutorRef {
key: string(&executor[0])?,
version: u32_value(&executor[1])?,
},
parameters,
inputs,
})
}
/// Parses the only render-graph wire format accepted by Yawn core.
pub fn parse(bytes: &[u8]) -> Result<Graph, GraphError> {
if bytes.len() > MAX_AST_BYTES {
return Err(GraphError::new(
"GRAPH_PAYLOAD_TOO_LARGE",
"graph AST exceeds 1 MiB",
));
}
let source = std::str::from_utf8(bytes)
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph AST is not UTF-8"))?;
let mut parser = Parser { source, offset: 0 };
let root = parser.expression()?;
parser.skip_trivia();
if parser.offset != source.len() {
return Err(invalid("trailing expression", parser.offset));
}
let root = list(&root)?;
if root.len() < 2 || atom(&root[0])? != "yawn-graph" {
return Err(invalid("root must be yawn-graph", 0));
}
if u32_value(&root[1])? != AST_VERSION {
return Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED",
"render graph AST version must be 1",
));
}
let fields = named_fields(&root[2..])?;
if fields.len() != 4 {
return Err(invalid(
"graph requires id, revision, pipelines, and nodes",
0,
));
}
let graph_id = string(&exact_field(&fields, "id", 1)?[0])?;
let revision = u32_value(&exact_field(&fields, "revision", 1)?[0])?;
let pipelines_value = json_value(&exact_field(&fields, "pipelines", 1)?[0])?;
let pipelines: PipelineDeclarations = serde_json::from_value(pipelines_value)
.map_err(|error| invalid(format!("invalid pipeline declarations: {error}"), 0))?;
let nodes = fields
.get("nodes")
.copied()
.ok_or_else(|| invalid("missing field 'nodes'", 0))?
.iter()
.map(node)
.collect::<Result<Vec<_>, _>>()?;
Ok(Graph {
schema_version: 3,
graph_id,
revision,
pipelines,
nodes,
})
}
fn push_string(out: &mut String, value: &str) {
out.push_str(&serde_json::to_string(value).expect("strings always serialize"));
}
fn push_json(out: &mut String, value: &Value) {
match value {
Value::Null => out.push_str("null"),
Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
Value::Number(value) => out.push_str(&value.to_string()),
Value::String(value) => push_string(out, value),
Value::Array(values) => {
out.push_str("(array");
for value in values {
out.push(' ');
push_json(out, value);
}
out.push(')');
}
Value::Object(values) => {
out.push_str("(object");
let mut fields: Vec<_> = values.iter().collect();
fields.sort_by(|left, right| left.0.cmp(right.0));
for (name, value) in fields {
out.push_str(" (field ");
push_string(out, name);
out.push(' ');
push_json(out, value);
out.push(')');
}
out.push(')');
}
}
}
/// Serializes an internal graph for fixtures and cross-language conformance tests.
pub fn serialize(graph: &Graph) -> String {
let mut out = format!("(yawn-graph {AST_VERSION}\n (id ");
push_string(&mut out, &graph.graph_id);
out.push_str(&format!(
")\n (revision {})\n (pipelines ",
graph.revision
));
push_json(
&mut out,
&serde_json::to_value(&graph.pipelines).expect("pipeline declarations serialize"),
);
out.push_str(")\n (nodes");
for node in &graph.nodes {
out.push_str("\n (node ");
push_string(&mut out, &node.id);
out.push(' ');
out.push_str(match node.state {
NodeState::Enabled => "enabled",
NodeState::Muted => "muted",
});
out.push_str("\n (executor ");
push_string(&mut out, &node.executor.key);
out.push_str(&format!(" {})\n (params ", node.executor.version));
push_json(&mut out, &node.parameters);
out.push_str(")\n (inputs");
for (name, references) in &node.inputs {
out.push_str("\n (input ");
push_string(&mut out, name);
for reference in references {
out.push_str(" (ref ");
push_string(&mut out, &reference.node);
out.push(' ');
push_string(&mut out, &reference.socket);
out.push(')');
}
out.push(')');
}
out.push_str(")\n )");
}
out.push_str("))\n");
out
}
pub(crate) fn validate_pipeline_declarations(graph: &Graph) -> Result<(), GraphError> {
let mut names = HashSet::new();
let mut shader_bytes = 0usize;
for RenderPipelineDeclaration {
name,
shader,
vertex_entry,
fragment_entry,
..
} in &graph.pipelines.render
{
if !names.insert(name) {
return Err(GraphError::new(
"GRAPH_DUPLICATE_ID",
format!("duplicate authored pipeline '{name}'"),
));
}
for identifier in [name, vertex_entry, fragment_entry] {
if !super::identifier(identifier) || identifier.len() > 64 {
return Err(GraphError::new(
"GRAPH_INVALID_ID",
"invalid authored render pipeline identifier",
));
}
}
if !super::contract(name).is_some_and(|contract| {
contract.is_raster_draw() || contract.fullscreen_policy.is_some() || name == "frame_out"
}) {
return Err(GraphError::new(
"GRAPH_EXECUTION_UNSUPPORTED",
format!("authored render pipeline '{name}' has no render executor"),
));
}
shader_bytes = shader_bytes.saturating_add(shader.len());
}
for ComputePipelineDeclaration {
name,
shader,
entry,
dispatch,
} in &graph.pipelines.compute
{
if !names.insert(name) {
return Err(GraphError::new(
"GRAPH_DUPLICATE_ID",
format!("duplicate authored pipeline '{name}'"),
));
}
for identifier in [name, entry] {
if !super::identifier(identifier) || identifier.len() > 64 {
return Err(GraphError::new(
"GRAPH_INVALID_ID",
"invalid authored compute pipeline identifier",
));
}
}
if dispatch.contains(&0) {
return Err(GraphError::new(
"GRAPH_PARAMETERS_INVALID",
"compute dispatch dimensions must be nonzero",
));
}
shader_bytes = shader_bytes.saturating_add(shader.len());
}
if shader_bytes > MAX_AST_BYTES / 2 {
return Err(GraphError::new(
"GRAPH_LIMIT_EXCEEDED",
"authored shader source exceeds 512 KiB",
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_round_trip_preserves_shared_dag_references() {
let graph = Graph {
schema_version: 3,
graph_id: "dag".into(),
revision: 7,
pipelines: PipelineDeclarations::default(),
nodes: vec![Node {
id: "consumer".into(),
state: NodeState::Enabled,
executor: ExecutorRef {
key: "and".into(),
version: 2,
},
parameters: serde_json::json!({}),
inputs: BTreeMap::from([(
"inputs".into(),
vec![
NodeOutputRef {
node: "shared".into(),
socket: "value".into(),
},
NodeOutputRef {
node: "shared".into(),
socket: "value".into(),
},
],
)]),
}],
};
let encoded = serialize(&graph);
let decoded = parse(encoded.as_bytes()).unwrap();
assert_eq!(decoded.graph_id, "dag");
assert_eq!(decoded.nodes[0].inputs["inputs"].len(), 2);
assert_eq!(serialize(&decoded), encoded);
}
#[test]
fn rejects_duplicate_fields_and_trailing_expressions() {
let duplicate = b"(yawn-graph 1 (id \"x\") (id \"y\") (revision 1) (pipelines (object (field \"render\" (array)) (field \"compute\" (array)))) (nodes))";
assert_eq!(parse(duplicate).unwrap_err().code, "GRAPH_AST_INVALID");
let trailing = b"(yawn-graph 1 (id \"x\") (revision 1) (pipelines (object (field \"render\" (array)) (field \"compute\" (array)))) (nodes)) true";
assert_eq!(parse(trailing).unwrap_err().code, "GRAPH_AST_INVALID");
}
#[test]
fn rejects_json_and_unknown_top_level_fields() {
assert_eq!(
parse(br#"{"graphId":"old"}"#).unwrap_err().code,
"GRAPH_AST_INVALID"
);
let source = b"(yawn-graph 1 (id \"x\") (revision 1) (pipelines (object (field \"render\" (array)) (field \"compute\" (array)))) (nodes) (legacy true))";
assert_eq!(parse(source).unwrap_err().code, "GRAPH_AST_INVALID");
}
}
+3 -19
View File
@@ -306,25 +306,7 @@ fn validate_name_grammar(s: &str, path: impl Into<String>) -> Result<(), GraphEr
}
pub fn parse_and_compile(bytes: &[u8]) -> Result<CompiledGraph, GraphError> {
if bytes.len() > MAX_JSON_BYTES {
return Err(GraphError::new(
"GRAPH_PAYLOAD_TOO_LARGE",
"graph payload exceeds 1 MiB",
));
}
let text = std::str::from_utf8(bytes)
.map_err(|_| GraphError::new("GRAPH_ENCODING_INVALID", "graph payload is not UTF-8"))?;
let probe: serde_json::Value = serde_json::from_str(text)
.map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?;
if probe.get("schemaVersion").and_then(|v| v.as_u64()) != Some(3) {
return Err(GraphError::new(
"GRAPH_SCHEMA_UNSUPPORTED",
"schemaVersion must be 3",
));
}
let graph = serde_json::from_str(text)
.map_err(|e| GraphError::new("GRAPH_JSON_INVALID", e.to_string()))?;
compile(graph)
compile(super::ast::parse(bytes)?)
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
@@ -834,6 +816,7 @@ fn accepts(c: TypeConstraint, ty: SemanticType) -> bool {
}
pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
super::ast::validate_pipeline_declarations(&graph)?;
if graph.nodes.len() > MAX_EXECUTIONS {
return Err(error(
"GRAPH_LIMIT_EXCEEDED",
@@ -3008,6 +2991,7 @@ pub fn compile(graph: Graph) -> Result<CompiledGraph, GraphError> {
graph_id: graph.graph_id,
revision: graph.revision,
node_count: graph.nodes.len() as u32,
pipelines: graph.pipelines,
resources,
executions,
render_passes,
+4 -2
View File
@@ -1,5 +1,6 @@
//! Device-free render graph compiler and compiled graph registry.
//! Device-free render graph AST, compiler, and compiled graph registry.
mod ast;
mod compiler;
pub(crate) use compiler::execution_attachments;
mod contracts;
@@ -9,6 +10,7 @@ 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::*;
@@ -17,7 +19,7 @@ pub use registry::{CompiledGraphId, Registry};
pub use runtime::*;
pub use schema::*;
pub const MAX_JSON_BYTES: usize = 1024 * 1024;
pub const MAX_AST_BYTES: usize = 1024 * 1024;
pub const MAX_EXECUTIONS: usize = 1024;
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
+2 -1
View File
@@ -9,6 +9,7 @@ pub struct CompiledGraph {
pub graph_id: String,
pub revision: u32,
pub node_count: u32,
pub pipelines: PipelineDeclarations,
pub resources: Vec<CompiledResource>,
pub executions: Vec<CompiledExecution>,
pub render_passes: Vec<PhysicalRenderPass>,
@@ -485,6 +486,6 @@ pub enum TextureUsage {
impl CompiledGraph {
pub fn summary(&self, id: [u32; 2]) -> serde_json::Value {
serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"physicalPassCount":self.render_passes.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count})
serde_json::json!({"compiledId":id,"graphId":self.graph_id,"revision":self.revision,"schemaVersion":self.schema_version,"nodeCount":self.node_count,"executionCount":self.executions.len(),"computePassCount":self.pipelines.compute.len(),"physicalPassCount":self.render_passes.len(),"resourceCount":self.resources.len(),"culledNodeCount":self.culled_node_count,"culledResourceCount":self.culled_resource_count,"transientSlotCount":self.transient_slot_count})
}
}
+15
View File
@@ -1656,6 +1656,21 @@ pub fn prepare_runtime_plan(
limits: Option<&wgpu::Limits>,
) -> Result<RuntimePlan, GraphError> {
validate_canonical_plan(graph)?;
if let Some(limits) = limits {
for (index, compute) in graph.pipelines.compute.iter().enumerate() {
if compute
.dispatch
.iter()
.any(|dimension| *dimension > limits.max_compute_workgroups_per_dimension)
{
return Err(error(
"GRAPH_RESOURCE_LIMIT",
"compute dispatch exceeds adapter limits",
format!("pipelines.compute[{index}].dispatch"),
));
}
}
}
if surface.width == 0 || surface.height == 0 {
return Err(error(
"GRAPH_SURFACE_INCOMPATIBLE",
+39 -2
View File
@@ -2,16 +2,18 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct Graph {
pub schema_version: u32,
pub graph_id: String,
pub revision: u32,
#[serde(default)]
pub pipelines: PipelineDeclarations,
pub nodes: Vec<Node>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Node {
pub id: String,
@@ -21,6 +23,41 @@ pub struct Node {
pub inputs: BTreeMap<String, Vec<NodeOutputRef>>,
}
/// GPU programs shipped with a graph AST and prepared with the graph loadout.
#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct PipelineDeclarations {
#[serde(default)]
pub render: Vec<RenderPipelineDeclaration>,
#[serde(default)]
pub compute: Vec<ComputePipelineDeclaration>,
}
/// A scene render pipeline using Yawn's fixed mesh/instance SOA vertex layout.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct RenderPipelineDeclaration {
pub name: String,
pub shader: String,
pub vertex_entry: String,
pub fragment_entry: String,
#[serde(default)]
pub double_sided: bool,
}
/// A binding-free compute pass dispatched before the graph's render passes.
///
/// Bindings are deliberately not implicit: shared SOA bindings will be added as an
/// explicit AST resource contract rather than inferred from shader source.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ComputePipelineDeclaration {
pub name: String,
pub shader: String,
pub entry: String,
pub dispatch: [u32; 3],
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum NodeState {
+5
View File
@@ -6,6 +6,11 @@ fn compile_value(value: Value) -> Result<CompiledGraph, GraphError> {
compile(serde_json::from_value(value).unwrap())
}
pub(crate) fn ast_bytes(value: &Value) -> Vec<u8> {
let graph: Graph = serde_json::from_value(value.clone()).unwrap();
super::ast::serialize(&graph).into_bytes()
}
fn input(node: &str, socket: &str) -> Value {
json!([{ "node": node, "socket": socket }])
}
+45 -56
View File
@@ -5,44 +5,6 @@ use crate::renderer::{
use super::super::scene::Scene;
fn encode_scene<'a, T: Scene>(
pass: &mut wgpu::RenderPass<'a>,
scene: &'a T,
gpu: &'a GpuSceneCache,
pipelines: &'a PipelineLibrary,
materials: &'a MaterialResources,
) {
for (i, bind_group) in scene.bind_groups().iter().enumerate() {
pass.set_bind_group(i as u32, bind_group, &[]);
}
if let (Some(p), Some(n), Some(u), Some(t), Some(i), Some(inst)) = (
&gpu.positions.buffer,
&gpu.normals.buffer,
&gpu.uvs.buffer,
&gpu.tangents.buffer,
&gpu.indices.buffer,
&gpu.instances.buffer,
) {
pass.set_vertex_buffer(0, p.slice(..));
pass.set_vertex_buffer(1, n.slice(..));
pass.set_vertex_buffer(2, u.slice(..));
pass.set_vertex_buffer(3, inst.slice(..));
pass.set_vertex_buffer(4, t.slice(..));
pass.set_index_buffer(i.slice(..), wgpu::IndexFormat::Uint32);
for draw in &gpu.draws {
pass.set_pipeline(pipelines.get_pipeline(draw.pipeline));
if pipelines.requires_material(draw.pipeline) {
pass.set_bind_group(2, materials.group(draw.material), &[]);
}
pass.draw_indexed(
draw.indices.clone(),
draw.base_vertex,
draw.instances.clone(),
);
}
}
}
pub(crate) fn encode_compiled<T: Scene>(
encoder: &mut wgpu::CommandEncoder,
surface: &wgpu::TextureView,
@@ -51,10 +13,24 @@ pub(crate) fn encode_compiled<T: Scene>(
gpu: &GpuSceneCache,
pipelines: &PipelineLibrary,
materials: &MaterialResources,
indirect_commands: &wgpu::Buffer,
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)),
});
pass.set_pipeline(&compute.pipeline);
pass.dispatch_workgroups(
compute.dispatch[0],
compute.dispatch[1],
compute.dispatch[2],
);
}
let view = |resource: u32| -> Result<&wgpu::TextureView, &'static str> {
let a = active
.runtime
@@ -193,10 +169,15 @@ pub(crate) fn encode_compiled<T: Scene>(
}
PreparedExecution::Pipeline {
base,
predicate_ordinal,
predicate,
variant,
..
} => {
let traversal = active
.runtime
.instance_traversal
.as_ref()
.ok_or("compiled graph instance traversal missing")?;
for (i, group) in scene.bind_groups().iter().enumerate() {
pass.set_bind_group(i as u32, group, &[]);
}
@@ -215,17 +196,30 @@ pub(crate) fn encode_compiled<T: Scene>(
pass.set_index_buffer(ix.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(3, inst.slice(..));
for (draw_index, draw) in gpu.draws.iter().enumerate() {
if !crate::renderer::instance_filter::evaluate(
traversal,
*predicate,
gpu.instance_records
.get(draw_index)
.ok_or("instance record missing")?,
gpu.local_aabb_records
.get(draw_index)
.ok_or("local aabb record missing")?,
*gpu.instance_type_records
.get(draw_index)
.ok_or("instance type record missing")?,
planes,
)? {
continue;
}
pass.set_pipeline(variant);
if pipelines.requires_material(*base) {
pass.set_bind_group(2, materials.group(draw.material), &[]);
}
pass.draw_indexed_indirect(
indirect_commands,
crate::renderer::instance_traversal::command_offset(
*predicate_ordinal,
gpu.draws.len(),
draw_index,
),
pass.draw_indexed(
draw.indices.clone(),
draw.base_vertex,
draw.instances.clone(),
);
}
}
@@ -236,18 +230,14 @@ pub(crate) fn encode_compiled<T: Scene>(
Ok(())
}
pub(crate) fn encode_immediate<T: Scene>(
pub(crate) fn encode_immediate(
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
scene: &T,
gpu: &GpuSceneCache,
pipelines: &PipelineLibrary,
materials: &MaterialResources,
profile: Option<&mut crate::renderer::profiler::ProfileFrame>,
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Immediate pipeline pass"),
let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("No active render graph"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
view: color,
@@ -271,7 +261,6 @@ pub(crate) fn encode_immediate<T: Scene>(
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: profile.and_then(|p| p.render_writes("immediate.pipeline")),
timestamp_writes: profile.and_then(|p| p.render_writes("no-active-graph")),
});
encode_scene(&mut pass, scene, gpu, pipelines, materials);
}
@@ -1,76 +0,0 @@
@group(0) @binding(0) var source_texture: texture_2d<f32>;
@group(0) @binding(1) var second_texture: texture_2d<f32>;
@group(0) @binding(2) var linear_clamp: sampler;
struct Parameters { values: array<vec4<f32>, 8> }
@group(0) @binding(3) var<uniform> parameters: Parameters;
struct VertexOut { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
@vertex fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut {
let positions = array(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));
let p = positions[index];
var out: VertexOut; out.position = vec4(p, 0.0, 1.0); out.uv = p * vec2(0.5, -0.5) + vec2(0.5); return out;
}
fn sample_source(uv: vec2<f32>) -> vec4<f32> { return textureSampleLevel(source_texture, linear_clamp, uv, 0.0); }
@fragment fn fs_copy(in: VertexOut) -> @location(0) vec4<f32> { return sample_source(in.uv); }
fn aces(x: vec3<f32>) -> vec3<f32> {
return clamp((x * (2.51 * x + vec3(0.03))) / (x * (2.43 * x + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0));
}
fn linear_to_srgb(x: vec3<f32>) -> vec3<f32> {
let safe = clamp(x, vec3(0.0), vec3(1.0));
let low = safe * 12.92; let high = 1.055 * pow(safe, vec3(1.0 / 2.4)) - vec3(0.055);
return select(high, low, safe <= vec3(0.0031308));
}
struct FrameCoordinates { uv: vec2<f32>, contained: bool }
fn frame_coordinates(position: vec2<f32>, surface: vec2<f32>, source: vec2<f32>, mode: f32) -> FrameCoordinates {
if mode < 0.5 { return FrameCoordinates(position / surface, true); }
let surface_aspect = surface.x / surface.y; let source_aspect = source.x / source.y; var size = surface;
if (mode < 1.5 && source_aspect > surface_aspect) || (mode > 1.5 && source_aspect < surface_aspect) { size.y = surface.x / source_aspect; } else { size.x = surface.y * source_aspect; }
let origin = (surface - size) * 0.5;
return FrameCoordinates((position - origin) / size, mode > 1.5 || (all(position >= origin) && all(position < origin + size)));
}
@fragment fn fs_frame_out(in: VertexOut) -> @location(0) vec4<f32> {
let surface=parameters.values[1].yz; let source=vec2<f32>(textureDimensions(source_texture));
let coordinates=frame_coordinates(in.position.xy,surface,source,parameters.values[1].x);
if !coordinates.contained {
var bg=parameters.values[2]; if parameters.values[0].w > 0.5 { bg=vec4(linear_to_srgb(bg.rgb),clamp(bg.a,0.0,1.0)); } return bg;
}
let sampled=sample_source(coordinates.uv); var rgb: vec3<f32>;
if parameters.values[0].x > 0.5 { rgb=max(sampled.rgb*exp2(parameters.values[0].z),vec3(0.0)); if parameters.values[0].y > 1.5 { rgb=aces(rgb); } else if parameters.values[0].y > 0.5 { rgb=rgb/(vec3(1.0)+rgb); } else { rgb=clamp(rgb,vec3(0.0),vec3(1.0)); } } else { rgb=clamp(sampled.rgb,vec3(0.0),vec3(1.0)); }
if parameters.values[0].w > 0.5 { rgb=linear_to_srgb(rgb); }
return vec4(clamp(rgb,vec3(0.0),vec3(1.0)),clamp(sampled.a,0.0,1.0));
}
fn grading_result(source: vec4<f32>, graded: vec3<f32>, factor: f32) -> vec4<f32> { return vec4(mix(source.rgb, graded, vec3(factor)), source.a); }
@fragment fn fs_color_balance(in: VertexOut) -> @location(0) vec4<f32> {
let c=sample_source(in.uv); var graded: vec3<f32>;
if parameters.values[0].x < 0.5 {
let lift=parameters.values[2].xyz+vec3(parameters.values[0].z); let lifted=(c.rgb-vec3(1.0))*(vec3(2.0)-lift)+vec3(1.0);
let gain=parameters.values[4].xyz*parameters.values[1].x; let gained=max(lifted*gain,vec3(0.0));
let gamma=max(parameters.values[3].xyz*parameters.values[0].w,vec3(0.000001)); graded=pow(gained,vec3(1.0)/gamma);
} else {
let slope=parameters.values[7].xyz*parameters.values[1].w; let offset=vec3(parameters.values[1].y)+(parameters.values[5].xyz-vec3(1.0));
let power=max(parameters.values[6].xyz*parameters.values[1].z,vec3(0.000001)); graded=pow(max(c.rgb*slope+offset,vec3(0.0)),power);
}
return grading_result(c,graded,parameters.values[0].y);
}
@fragment fn fs_exposure_contrast(in: VertexOut) -> @location(0) vec4<f32> {
let c=sample_source(in.uv); let exposed=c.rgb*exp2(parameters.values[0].x); let pivot=parameters.values[0].z;
let graded=sign(exposed)*vec3(pivot)*pow(abs(exposed)/vec3(pivot),vec3(parameters.values[0].y)); return grading_result(c,graded,parameters.values[0].w);
}
@fragment fn fs_saturation(in: VertexOut) -> @location(0) vec4<f32> { let c=sample_source(in.uv); let l=dot(c.rgb,vec3(0.2126,0.7152,0.0722)); return grading_result(c,mix(vec3(l),c.rgb,vec3(parameters.values[0].x)),parameters.values[0].y); }
@fragment fn fs_channel_mixer(in: VertexOut) -> @location(0) vec4<f32> { let c=sample_source(in.uv); let graded=vec3(dot(c.rgb,parameters.values[0].xyz),dot(c.rgb,parameters.values[1].xyz),dot(c.rgb,parameters.values[2].xyz)); return grading_result(c,graded,parameters.values[0].w); }
@fragment fn fs_bloom_extract(in: VertexOut) -> @location(0) vec4<f32> {
let c=sample_source(in.uv); let brightness=max(c.r,max(c.g,c.b)); let knee=max(parameters.values[0].y,0.00001); let soft=clamp((brightness-parameters.values[0].x+knee)/(2.0*knee),0.0,1.0); let contribution=max(brightness-parameters.values[0].x,0.0)+soft*soft*knee; return vec4(c.rgb*contribution/max(brightness,0.00001),1.0);
}
@fragment fn fs_bloom_blur(in: VertexOut) -> @location(0) vec4<f32> {
let size=vec2<f32>(textureDimensions(source_texture)); let step=parameters.values[0].xy*parameters.values[0].z/size;
var c=sample_source(in.uv)*0.227027; c+=sample_source(in.uv+step*1.384615)*0.316216; c+=sample_source(in.uv-step*1.384615)*0.316216; c+=sample_source(in.uv+step*3.230769)*0.070270; c+=sample_source(in.uv-step*3.230769)*0.070270; return c;
}
@fragment fn fs_bloom_composite(in: VertexOut) -> @location(0) vec4<f32> { let c=sample_source(in.uv); return vec4(c.rgb+textureSampleLevel(second_texture,linear_clamp,in.uv,0.0).rgb*parameters.values[0].x,c.a); }
fn luminance(c: vec3<f32>) -> f32 { return dot(c,vec3(0.2126,0.7152,0.0722)); }
@fragment fn fs_luminance_edge(in: VertexOut) -> @location(0) vec4<f32> {
let d=1.0/vec2<f32>(textureDimensions(source_texture)); var gx=0.0; var gy=0.0;
gx += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gx += -2.0*luminance(sample_source(in.uv+d*vec2(-1.0,0.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(1.0,0.0)).rgb); gx += -luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb);
gy += -luminance(sample_source(in.uv+d*vec2(-1.0,-1.0)).rgb)-2.0*luminance(sample_source(in.uv+d*vec2(0.0,-1.0)).rgb)-luminance(sample_source(in.uv+d*vec2(1.0,-1.0)).rgb); gy += luminance(sample_source(in.uv+d*vec2(-1.0,1.0)).rgb)+2.0*luminance(sample_source(in.uv+d*vec2(0.0,1.0)).rgb)+luminance(sample_source(in.uv+d*vec2(1.0,1.0)).rgb);
let edge=clamp(length(vec2(gx,gy))*parameters.values[0].x,0.0,1.0); return vec4(vec3(edge),1.0);
}
+11 -80
View File
@@ -3,7 +3,7 @@ use std::mem::size_of;
use bytemuck::{Pod, Zeroable};
use crate::{
render_data::{MaterialKey, MeshHandle, PipelineKey},
render_data::{MaterialKey, MeshHandle},
renderer::scene_frame::SceneFramePlan,
};
@@ -18,7 +18,6 @@ pub struct GpuInstance {
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DrawItem {
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub mesh: MeshHandle,
pub indices: std::ops::Range<u32>,
@@ -33,25 +32,6 @@ pub struct GpuLocalAabb {
pub max: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
pub struct DrawSlotMetadata {
pub index_count: u32,
pub first_index: u32,
pub base_vertex: i32,
pub instance_index: u32,
}
#[repr(C)]
#[derive(Clone, Copy, Debug, Pod, Zeroable, PartialEq, Eq)]
pub struct DrawIndexedIndirect {
pub index_count: u32,
pub instance_count: u32,
pub first_index: u32,
pub base_vertex: i32,
pub first_instance: u32,
}
#[derive(Default)]
pub struct GpuScenePlan {
pub positions: Vec<[f32; 3]>,
@@ -63,21 +43,13 @@ pub struct GpuScenePlan {
pub draws: Vec<DrawItem>,
pub local_aabbs: Vec<GpuLocalAabb>,
pub instance_types: Vec<[u32; 16]>,
pub draw_metadata: Vec<DrawSlotMetadata>,
}
impl GpuScenePlan {
pub fn build(data: &SceneFramePlan) -> Result<Self, &'static str> {
let mut p = Self::default();
let mut meshes: Vec<_> = data.meshes.iter().collect();
meshes.sort_by_key(|m| {
(
m.pipeline.get(),
m.material.get(),
m.handle.slot(),
m.handle.generation(),
)
});
meshes.sort_by_key(|m| (m.material.get(), m.handle.slot(), m.handle.generation()));
for mesh in meshes {
let occurrences: Vec<_> = data.mesh_occurrence_indices[mesh.occurrence_range.clone()]
.iter()
@@ -152,14 +124,7 @@ impl GpuScenePlan {
p.instance_types.push(occurrence.instance_type.words);
let base_vertex =
i32::try_from(vertex_start).map_err(|_| "base vertex exceeds i32")?;
p.draw_metadata.push(DrawSlotMetadata {
index_count: mesh.geometry.index_count,
first_index,
base_vertex,
instance_index,
});
p.draws.push(DrawItem {
pipeline: mesh.pipeline,
material: mesh.material,
mesh: mesh.handle,
indices: first_index
@@ -191,9 +156,9 @@ pub struct GpuSceneCache {
pub tangents: BufferSlot,
pub indices: BufferSlot,
pub instances: BufferSlot,
pub local_aabbs: BufferSlot,
pub instance_types: BufferSlot,
pub draw_metadata: BufferSlot,
pub instance_records: Vec<GpuInstance>,
pub local_aabb_records: Vec<GpuLocalAabb>,
pub instance_type_records: Vec<[u32; 16]>,
pub draws: Vec<DrawItem>,
}
@@ -248,9 +213,6 @@ impl GpuSceneCache {
bytes(&p.tangents)?,
bytes(&p.indices)?,
bytes(&p.instances)?.max(size_of::<GpuInstance>() as u64),
bytes(&p.local_aabbs)?.max(size_of::<GpuLocalAabb>() as u64),
bytes(&p.instance_types)?.max(size_of::<[u32; 16]>() as u64),
bytes(&p.draw_metadata)?.max(size_of::<DrawSlotMetadata>() as u64),
];
let slots = [
&mut self.positions,
@@ -259,9 +221,6 @@ impl GpuSceneCache {
&mut self.tangents,
&mut self.indices,
&mut self.instances,
&mut self.local_aabbs,
&mut self.instance_types,
&mut self.draw_metadata,
];
let usage = [
wgpu::BufferUsages::VERTEX,
@@ -269,10 +228,7 @@ impl GpuSceneCache {
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::VERTEX,
wgpu::BufferUsages::INDEX,
wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::STORAGE,
wgpu::BufferUsages::VERTEX,
];
let mut replaced = false;
for ((slot, &need), use_) in slots.into_iter().zip(&required).zip(usage) {
@@ -289,19 +245,13 @@ impl GpuSceneCache {
}
}
let zero_instance = GpuInstance::zeroed();
let zero_aabb = GpuLocalAabb::zeroed();
let zero_type = [0u32; 16];
let zero_metadata = DrawSlotMetadata::zeroed();
let contents: [&[u8]; 9] = [
let contents: [&[u8]; 6] = [
bytemuck::cast_slice(&p.positions),
bytemuck::cast_slice(&p.normals),
bytemuck::cast_slice(&p.uvs),
bytemuck::cast_slice(&p.tangents),
bytemuck::cast_slice(&p.indices),
logical_or_zero(&p.instances, &zero_instance),
logical_or_zero(&p.local_aabbs, &zero_aabb),
logical_or_zero(&p.instance_types, &zero_type),
logical_or_zero(&p.draw_metadata, &zero_metadata),
];
let slots = [
&self.positions,
@@ -310,9 +260,6 @@ impl GpuSceneCache {
&self.tangents,
&self.indices,
&self.instances,
&self.local_aabbs,
&self.instance_types,
&self.draw_metadata,
];
for (s, c) in slots.into_iter().zip(contents) {
if !c.is_empty() {
@@ -322,6 +269,9 @@ impl GpuSceneCache {
if replaced {
self.buffer_epoch = self.buffer_epoch.wrapping_add(1).max(1)
}
self.instance_records = p.instances;
self.local_aabb_records = p.local_aabbs;
self.instance_type_records = p.instance_types;
self.draws = p.draws;
self.revision = Some(data.revision);
Ok(())
@@ -367,33 +317,14 @@ mod tests {
assert_eq!(size_of::<GpuInstance>(), 112);
assert_eq!(size_of::<GpuLocalAabb>(), 32);
assert_eq!(size_of::<[u32; 16]>(), 64);
assert_eq!(size_of::<DrawSlotMetadata>(), 16);
assert_eq!(size_of::<DrawIndexedIndirect>(), 20)
}
#[test]
fn command_offset() {
let n = 7u64;
assert_eq!((3 * n + 2) * 20, 460)
}
#[test]
fn empty_traversal_records_have_exact_zero_floors() {
fn empty_instance_buffer_has_an_exact_zero_floor() {
assert_eq!(
logical_or_zero::<GpuInstance>(&[], &GpuInstance::zeroed()),
[0; 112]
);
assert_eq!(
logical_or_zero::<GpuLocalAabb>(&[], &GpuLocalAabb::zeroed()),
[0; 32]
);
assert_eq!(logical_or_zero::<[u32; 16]>(&[], &[0; 16]), [0; 64]);
assert_eq!(
logical_or_zero::<DrawSlotMetadata>(&[], &DrawSlotMetadata::zeroed()),
[0; 16]
);
assert_eq!(required_buffer_capacity(0, 112, 1024), Ok(112));
assert_eq!(required_buffer_capacity(0, 32, 1024), Ok(32));
assert_eq!(required_buffer_capacity(0, 64, 1024), Ok(64));
assert_eq!(required_buffer_capacity(0, 16, 1024), Ok(16));
}
}
+327
View File
@@ -0,0 +1,327 @@
//! CPU evaluation of graph-owned instance predicates.
//!
//! Shader source belongs to graph packages, so core evaluates its small typed
//! predicate IR directly instead of manufacturing a hidden compute shader.
use crate::render_graph::{
BooleanOp, CompareOp, ExprId, ExpressionOp, InstanceTraversalPlan, TypedLiteral,
};
use super::gpu_scene::{GpuInstance, GpuLocalAabb};
#[derive(Clone, Debug)]
enum Value {
Bool(bool),
F32(f32),
U32(u32),
Vector(Vec<f32>),
Matrix(Vec<Vec<f32>>),
Type([u32; 16]),
Aabb { min: [f32; 3], max: [f32; 3] },
}
fn literal(value: &TypedLiteral) -> Value {
match value {
TypedLiteral::Bool(value) => Value::Bool(*value),
TypedLiteral::F32(value) => Value::F32(*value),
TypedLiteral::U32(value) => Value::U32(*value),
TypedLiteral::Vec2(value) => Value::Vector(value.to_vec()),
TypedLiteral::Vec3(value) => Value::Vector(value.to_vec()),
TypedLiteral::Vec4(value) => Value::Vector(value.to_vec()),
TypedLiteral::Mat2(value) => {
Value::Matrix(value.iter().map(|column| column.to_vec()).collect())
}
TypedLiteral::Mat3(value) => {
Value::Matrix(value.iter().map(|column| column.to_vec()).collect())
}
TypedLiteral::Mat4(value) => {
Value::Matrix(value.iter().map(|column| column.to_vec()).collect())
}
TypedLiteral::U32x16(value) => Value::Type(*value),
TypedLiteral::LocalAabb { min, max } => Value::Aabb {
min: *min,
max: *max,
},
}
}
fn value<'a>(values: &'a [Value], id: ExprId) -> Result<&'a Value, &'static str> {
values.get(id.0 as usize).ok_or("predicate operand missing")
}
fn boolean(values: &[Value], id: ExprId) -> Result<bool, &'static str> {
match value(values, id)? {
Value::Bool(value) => Ok(*value),
_ => Err("predicate operand is not bool"),
}
}
fn f32_value(values: &[Value], id: ExprId) -> Result<f32, &'static str> {
match value(values, id)? {
Value::F32(value) => Ok(*value),
_ => Err("predicate operand is not f32"),
}
}
fn u32_value(values: &[Value], id: ExprId) -> Result<u32, &'static str> {
match value(values, id)? {
Value::U32(value) => Ok(*value),
_ => Err("predicate operand is not u32"),
}
}
fn compare<T: PartialEq + PartialOrd>(operation: CompareOp, left: T, right: T) -> bool {
match operation {
CompareOp::GreaterThan => left > right,
CompareOp::LessThan => left < right,
CompareOp::Equals => left == right,
}
}
fn transformed(model: &[[f32; 4]; 4], point: [f32; 3]) -> [f32; 4] {
std::array::from_fn(|row| {
model[0][row] * point[0]
+ model[1][row] * point[1]
+ model[2][row] * point[2]
+ model[3][row]
})
}
fn frustum_culled(
bounds: ([f32; 3], [f32; 3]),
model: &[[f32; 4]; 4],
planes: &[[f32; 4]; 6],
) -> bool {
planes.iter().any(|plane| {
(0..8).all(|corner| {
let local = std::array::from_fn(|axis| {
if corner & (1 << axis) == 0 {
bounds.0[axis]
} else {
bounds.1[axis]
}
});
let world = transformed(model, local);
plane
.iter()
.zip(world)
.map(|(left, right)| left * right)
.sum::<f32>()
< 0.0
})
})
}
/// Evaluates one compiled raster predicate for one dense scene occurrence.
pub fn evaluate(
plan: &InstanceTraversalPlan,
predicate: ExprId,
instance: &GpuInstance,
local_aabb: &GpuLocalAabb,
instance_type: [u32; 16],
planes: Option<&[[f32; 4]; 6]>,
) -> Result<bool, &'static str> {
let mut values = Vec::with_capacity(plan.expressions.expressions.len());
for expression in &plan.expressions.expressions {
let result = match &expression.op {
ExpressionOp::Literal { literal: item } => literal(item),
ExpressionOp::InstanceType { .. } => Value::Type(instance_type),
ExpressionOp::LocalAabb { .. } => Value::Aabb {
min: local_aabb.min[..3].try_into().unwrap(),
max: local_aabb.max[..3].try_into().unwrap(),
},
ExpressionOp::Not { value: operand } => Value::Bool(!boolean(&values, *operand)?),
ExpressionOp::Boolean {
operation,
operands,
} => {
let operands = operands
.iter()
.map(|operand| boolean(&values, *operand))
.collect::<Result<Vec<_>, _>>()?;
Value::Bool(match operation {
BooleanOp::And => operands.into_iter().all(|item| item),
BooleanOp::Or => operands.into_iter().any(|item| item),
BooleanOp::Xor => operands.into_iter().fold(false, |left, right| left ^ right),
BooleanOp::Xnor => {
!operands.into_iter().fold(false, |left, right| left ^ right)
}
})
}
ExpressionOp::CompareF32 {
operation,
left,
right,
} => Value::Bool(compare(
*operation,
f32_value(&values, *left)?,
f32_value(&values, *right)?,
)),
ExpressionOp::CompareU32 {
operation,
left,
right,
} => Value::Bool(compare(
*operation,
u32_value(&values, *left)?,
u32_value(&values, *right)?,
)),
ExpressionOp::VectorProject { vector, index } => match value(&values, *vector)? {
Value::Vector(vector) => Value::F32(
*vector
.get(*index as usize)
.ok_or("vector predicate index out of bounds")?,
),
_ => return Err("predicate operand is not vector"),
},
ExpressionOp::VectorConstruct { components } => Value::Vector(
components
.iter()
.map(|component| f32_value(&values, *component))
.collect::<Result<_, _>>()?,
),
ExpressionOp::MatrixColumn { matrix, index } => match value(&values, *matrix)? {
Value::Matrix(matrix) => Value::Vector(
matrix
.get(*index as usize)
.ok_or("matrix predicate index out of bounds")?
.clone(),
),
_ => return Err("predicate operand is not matrix"),
},
ExpressionOp::MatrixConstruct { columns } => Value::Matrix(
columns
.iter()
.map(|column| match value(&values, *column)? {
Value::Vector(column) => Ok(column.clone()),
_ => Err("matrix column is not vector"),
})
.collect::<Result<_, _>>()?,
),
ExpressionOp::TypeWord {
value: operand,
index,
} => match value(&values, *operand)? {
Value::Type(words) => Value::U32(words[*index as usize]),
_ => return Err("predicate operand is not u32x16"),
},
ExpressionOp::TypeConstruct { words } => {
if words.len() != 16 {
return Err("type predicate requires 16 words");
}
let mut result = [0; 16];
for (index, word) in words.iter().enumerate() {
result[index] = u32_value(&values, *word)?;
}
Value::Type(result)
}
ExpressionOp::U32Bit {
value: operand,
index,
} => Value::Bool(u32_value(&values, *operand)? & (1 << index) != 0),
ExpressionOp::U32Construct { bits } => {
if bits.len() > 32 {
return Err("u32 predicate has too many bits");
}
let mut result = 0;
for (index, bit) in bits.iter().enumerate() {
result |= u32::from(boolean(&values, *bit)?) << index;
}
Value::U32(result)
}
ExpressionOp::AabbMin { aabb } => match value(&values, *aabb)? {
Value::Aabb { min, .. } => Value::Vector(min.to_vec()),
_ => return Err("predicate operand is not aabb"),
},
ExpressionOp::AabbMax { aabb } => match value(&values, *aabb)? {
Value::Aabb { max, .. } => Value::Vector(max.to_vec()),
_ => return Err("predicate operand is not aabb"),
},
ExpressionOp::FrustumCulled { local_aabb, .. } => {
let bounds = match value(&values, *local_aabb)? {
Value::Aabb { min, max } => (*min, *max),
_ => return Err("predicate operand is not aabb"),
};
let planes = planes.ok_or("camera frustum missing")?;
Value::Bool(frustum_culled(bounds, &instance.model, planes))
}
};
values.push(result);
}
boolean(&values, predicate)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_graph::{
Expression, ExpressionPlan, NodeOutputRef, PipelinePredicatePlan, SemanticType,
};
fn origin() -> NodeOutputRef {
NodeOutputRef {
node: "test".into(),
socket: "value".into(),
}
}
#[test]
fn evaluates_type_bits_without_shader_source() {
let plan = InstanceTraversalPlan {
mesh: 0,
expressions: ExpressionPlan {
expressions: vec![
Expression {
semantic_type: SemanticType::U32x16,
op: ExpressionOp::InstanceType { mesh: 0 },
origin: origin(),
mesh_provenance: Some(0),
},
Expression {
semantic_type: SemanticType::U32,
op: ExpressionOp::TypeWord {
value: ExprId(0),
index: 0,
},
origin: origin(),
mesh_provenance: Some(0),
},
Expression {
semantic_type: SemanticType::Bool,
op: ExpressionOp::U32Bit {
value: ExprId(1),
index: 3,
},
origin: origin(),
mesh_provenance: Some(0),
},
],
},
pipelines: vec![PipelinePredicatePlan {
execution: 0,
predicate: ExprId(2),
ordinal: 0,
}],
requires_camera: false,
};
let mut words = [0; 16];
words[0] = 8;
assert!(evaluate(
&plan,
ExprId(2),
&GpuInstance {
model: crate::render_data::IDENTITY_MODEL_TRANSFORM,
normal_0: [0.; 4],
normal_1: [0.; 4],
normal_2: [0.; 4],
},
&GpuLocalAabb {
min: [-1., -1., -1., 0.],
max: [1., 1., 1., 0.],
},
words,
None,
)
.unwrap());
}
}
-531
View File
@@ -1,531 +0,0 @@
//! Graph-owned instance predicate compute support.
use crate::render_graph::{
BooleanOp, CompareOp, ExpressionOp, InstanceTraversalPlan, SemanticType, TypedLiteral,
};
use super::gpu_scene::{DrawIndexedIndirect, GpuSceneCache};
fn f(value: f32) -> Result<String, String> {
if !value.is_finite() {
return Err("non-finite expression literal".into());
}
Ok(format!("{value:?}"))
}
/// Generates the dense, single-invocation traversal body. Expressions are emitted in
/// `ExprId` order, so shared IR nodes are evaluated exactly once.
pub fn generate_wgsl(plan: &InstanceTraversalPlan) -> Result<String, String> {
let mut s = String::from("struct Params { planes: array<vec4<f32>,6>, instance_count:u32, pipeline_count:u32, _pad:vec2<u32> };\nstruct Inst{model:mat4x4<f32>,n0:vec4<f32>,n1:vec4<f32>,n2:vec4<f32>}; struct Aabb{min:vec4<f32>,max:vec4<f32>}; struct Type16{words:array<u32,16>}; struct Meta{index_count:u32,first_index:u32,base_vertex:i32,instance_index:u32}; struct Cmd{index_count:u32,instance_count:u32,first_index:u32,base_vertex:i32,first_instance:u32};\n@group(0) @binding(0)var<uniform>p:Params; @group(0) @binding(1)var<storage,read>instances:array<Inst>; @group(0) @binding(2)var<storage,read>aabbs:array<Aabb>; @group(0) @binding(3)var<storage,read>types:array<Type16>; @group(0) @binding(4)var<storage,read>metadata:array<Meta>; @group(0) @binding(5)var<storage,read_write>commands:array<Cmd>;\nstruct LocalAabb{min:vec3<f32>,max:vec3<f32>}; fn culled(i:u32,a:LocalAabb)->bool{var outside=false;for(var q=0u;q<6u;q++){var all=true;for(var c=0u;c<8u;c++){let v=vec3<f32>(select(a.min.x,a.max.x,(c&1u)!=0u),select(a.min.y,a.max.y,(c&2u)!=0u),select(a.min.z,a.max.z,(c&4u)!=0u));all=all&&(dot(p.planes[q],instances[i].model*vec4<f32>(v,1.0))<0.0);}outside=outside||all;}return outside;} @compute @workgroup_size(64) fn main(@builtin(global_invocation_id)gid:vec3<u32>){let i=gid.x;if(i>=p.instance_count){return;}\n");
for (i, e) in plan.expressions.expressions.iter().enumerate() {
let x = |id: crate::render_graph::ExprId| format!("e{}", id.0);
let rhs = match &e.op {
ExpressionOp::Literal { literal } => match literal {
TypedLiteral::Bool(v) => v.to_string(),
TypedLiteral::F32(v) => f(*v)?,
TypedLiteral::U32(v) => format!("{v}u"),
TypedLiteral::Vec2(v) => format!("vec2<f32>({},{})", f(v[0])?, f(v[1])?),
TypedLiteral::Vec3(v) => {
format!("vec3<f32>({},{},{})", f(v[0])?, f(v[1])?, f(v[2])?)
}
TypedLiteral::Vec4(v) => format!(
"vec4<f32>({},{},{},{})",
f(v[0])?,
f(v[1])?,
f(v[2])?,
f(v[3])?
),
TypedLiteral::U32x16(v) => format!(
"Type16(array<u32,16>({}))",
v.iter()
.map(|x| format!("{x}u"))
.collect::<Vec<_>>()
.join(",")
),
TypedLiteral::LocalAabb { min, max } => format!(
"LocalAabb(vec3<f32>({},{},{}),vec3<f32>({},{},{}))",
f(min[0])?,
f(min[1])?,
f(min[2])?,
f(max[0])?,
f(max[1])?,
f(max[2])?
),
TypedLiteral::Mat2(v) => matrix_literal("mat2x2<f32>", v)?,
TypedLiteral::Mat3(v) => matrix_literal("mat3x3<f32>", v)?,
TypedLiteral::Mat4(v) => matrix_literal("mat4x4<f32>", v)?,
},
ExpressionOp::InstanceType { .. } => "types[i]".into(),
ExpressionOp::LocalAabb { .. } => "LocalAabb(aabbs[i].min.xyz,aabbs[i].max.xyz)".into(),
ExpressionOp::Not { value } => format!("!{}", x(*value)),
ExpressionOp::Boolean {
operation,
operands,
} => {
let identity = matches!(operation, BooleanOp::And | BooleanOp::Xnor);
let operator = match operation {
BooleanOp::And => "&&",
BooleanOp::Or => "||",
BooleanOp::Xor | BooleanOp::Xnor => "!=",
};
let folded = operands
.iter()
.map(|operand| x(*operand))
.reduce(|left, right| format!("({left} {operator} {right})"))
.unwrap_or_else(|| identity.to_string());
if matches!(operation, BooleanOp::Xnor) && operands.len() > 1 {
format!("!{folded}")
} else if matches!(operation, BooleanOp::Xnor) && !operands.is_empty() {
format!("!({folded})")
} else {
folded
}
}
ExpressionOp::CompareF32 {
operation,
left,
right,
}
| ExpressionOp::CompareU32 {
operation,
left,
right,
} => format!(
"({} {} {})",
x(*left),
match operation {
CompareOp::GreaterThan => ">",
CompareOp::LessThan => "<",
CompareOp::Equals => "==",
},
x(*right)
),
ExpressionOp::VectorProject { vector, index } => {
let limit =
vector_width(&plan.expressions.expressions[vector.0 as usize].semantic_type)
.ok_or("vector projection source is not a vector")?;
fixed_index(*index, limit, "vector projection")?;
format!("{}[{}]", x(*vector), index)
}
ExpressionOp::VectorConstruct { components } => format!(
"{}({})",
wgsl_type(&e.semantic_type)?,
components
.iter()
.map(|id| x(*id))
.collect::<Vec<_>>()
.join(",")
),
ExpressionOp::MatrixColumn { matrix, index } => {
let limit =
matrix_width(&plan.expressions.expressions[matrix.0 as usize].semantic_type)
.ok_or("matrix projection source is not a matrix")?;
fixed_index(*index, limit, "matrix projection")?;
format!("{}[{}]", x(*matrix), index)
}
ExpressionOp::MatrixConstruct { columns } => format!(
"{}({})",
wgsl_type(&e.semantic_type)?,
columns
.iter()
.map(|id| x(*id))
.collect::<Vec<_>>()
.join(",")
),
ExpressionOp::TypeWord { value, index } => {
fixed_index(*index, 16, "u32x16 projection")?;
format!("{}.words[{}]", x(*value), index)
}
ExpressionOp::TypeConstruct { words } => format!(
"Type16(array<u32,16>({}))",
words.iter().map(|id| x(*id)).collect::<Vec<_>>().join(",")
),
ExpressionOp::U32Bit { value, index } => {
fixed_index(*index, 32, "u32 bit projection")?;
format!("(({} & (1u<<{}u))!=0u)", x(*value), index)
}
ExpressionOp::U32Construct { bits } => format!(
"({})",
bits.iter()
.enumerate()
.map(|(bit, id)| format!("select(0u,{}u,{})", 1u32 << bit, x(*id)))
.collect::<Vec<_>>()
.join("|")
),
ExpressionOp::AabbMin { aabb } => format!("{}.min", x(*aabb)),
ExpressionOp::AabbMax { aabb } => format!("{}.max", x(*aabb)),
ExpressionOp::FrustumCulled { local_aabb, .. } => {
format!("culled(i,{})", x(*local_aabb))
}
};
s.push_str(&format!("let e{i}={rhs};\n"));
}
for entry in &plan.pipelines {
s.push_str(&format!("{{let m=metadata[i];commands[{}u*p.instance_count+i]=Cmd(m.index_count,select(0u,1u,e{}),m.first_index,m.base_vertex,m.instance_index);}}\n",entry.ordinal,entry.predicate.0));
}
s.push('}');
if s.len() >= 1024 * 1024 {
return Err("generated traversal WGSL exceeds 1 MiB".into());
}
Ok(s)
}
fn fixed_index(index: u8, limit: u8, kind: &str) -> Result<(), String> {
(index < limit)
.then_some(())
.ok_or_else(|| format!("invalid fixed {kind} index"))
}
fn vector_width(ty: &SemanticType) -> Option<u8> {
match ty {
SemanticType::Vec2 => Some(2),
SemanticType::Vec3 => Some(3),
SemanticType::Vec4 => Some(4),
_ => None,
}
}
fn matrix_width(ty: &SemanticType) -> Option<u8> {
match ty {
SemanticType::Mat2 => Some(2),
SemanticType::Mat3 => Some(3),
SemanticType::Mat4 => Some(4),
_ => None,
}
}
fn wgsl_type(ty: &SemanticType) -> Result<&'static str, String> {
match ty {
SemanticType::Vec2 => Ok("vec2<f32>"),
SemanticType::Vec3 => Ok("vec3<f32>"),
SemanticType::Vec4 => Ok("vec4<f32>"),
SemanticType::Mat2 => Ok("mat2x2<f32>"),
SemanticType::Mat3 => Ok("mat3x3<f32>"),
SemanticType::Mat4 => Ok("mat4x4<f32>"),
_ => Err("invalid combine result type".into()),
}
}
fn matrix_literal<const N: usize>(name: &str, columns: &[[f32; N]; N]) -> Result<String, String> {
let values = columns
.iter()
.flatten()
.map(|v| f(*v))
.collect::<Result<Vec<_>, _>>()?;
Ok(format!("{name}({})", values.join(",")))
}
pub fn dispatch_count(instances: u32, pipelines: u32) -> u32 {
if pipelines == 0 {
0
} else {
instances.max(1).div_ceil(64)
}
}
pub fn command_offset(predicate_ordinal: u32, instance_count: usize, draw_index: usize) -> u64 {
(u64::from(predicate_ordinal) * instance_count as u64 + draw_index as u64)
* std::mem::size_of::<DrawIndexedIndirect>() as u64
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
planes: [[f32; 4]; 6],
instance_count: u32,
pipeline_count: u32,
pad: [u32; 2],
}
pub struct TraversalGpu {
graph: crate::render_graph::CompiledGraphId,
plan: InstanceTraversalPlan,
scene_epoch: u64,
draw_count: usize,
pipeline: wgpu::ComputePipeline,
params: wgpu::Buffer,
bind_group: wgpu::BindGroup,
pub commands: wgpu::Buffer,
}
impl TraversalGpu {
pub fn matches(
&self,
graph: crate::render_graph::CompiledGraphId,
plan: &InstanceTraversalPlan,
scene_epoch: u64,
draw_count: usize,
) -> bool {
self.graph == graph
&& self.plan == *plan
&& self.scene_epoch == scene_epoch
&& self.draw_count == draw_count
}
pub fn create(
device: &wgpu::Device,
graph: crate::render_graph::CompiledGraphId,
plan: &InstanceTraversalPlan,
gpu: &GpuSceneCache,
) -> Result<Self, String> {
let source = generate_wgsl(plan)?;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("instance traversal"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let entries = [
(0, wgpu::BufferBindingType::Uniform),
(1, wgpu::BufferBindingType::Storage { read_only: true }),
(2, wgpu::BufferBindingType::Storage { read_only: true }),
(3, wgpu::BufferBindingType::Storage { read_only: true }),
(4, wgpu::BufferBindingType::Storage { read_only: true }),
(5, wgpu::BufferBindingType::Storage { read_only: false }),
]
.map(|(binding, ty)| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("instance traversal"),
entries: &entries,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("instance traversal"),
bind_group_layouts: &[&layout],
push_constant_ranges: &[],
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("instance traversal"),
layout: Some(&pipeline_layout),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let params = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("instance traversal params"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let count = (gpu.draws.len() as u64)
.checked_mul(plan.pipelines.len() as u64)
.and_then(|n| n.checked_mul(std::mem::size_of::<DrawIndexedIndirect>() as u64))
.ok_or("indirect command size overflow")?
.max(std::mem::size_of::<DrawIndexedIndirect>() as u64);
if count > device.limits().max_buffer_size {
return Err("indirect command buffer exceeds device limit".into());
}
let commands = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("instance traversal commands"),
size: count,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::INDIRECT,
mapped_at_creation: false,
});
fn required(slot: &super::gpu_scene::BufferSlot) -> Result<&wgpu::Buffer, String> {
slot.buffer
.as_ref()
.ok_or_else(|| "instance traversal scene buffer missing".to_owned())
}
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("instance traversal"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: required(&gpu.instances)?.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: required(&gpu.local_aabbs)?.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: required(&gpu.instance_types)?.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: required(&gpu.draw_metadata)?.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: commands.as_entire_binding(),
},
],
});
Ok(Self {
graph,
plan: plan.clone(),
scene_epoch: gpu.buffer_epoch,
draw_count: gpu.draws.len(),
pipeline,
params,
bind_group,
commands,
})
}
pub(crate) fn encode(
&self,
encoder: &mut wgpu::CommandEncoder,
queue: &wgpu::Queue,
planes: Option<[[f32; 4]; 6]>,
instances: u32,
mut profile: Option<&mut super::profiler::ProfileFrame>,
) {
queue.write_buffer(
&self.params,
0,
bytemuck::bytes_of(&Params {
planes: planes.unwrap_or([[0.; 4]; 6]),
instance_count: instances,
pipeline_count: self.plan.pipelines.len() as u32,
pad: [0; 2],
}),
);
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("instance traversal"),
timestamp_writes: profile
.as_deref_mut()
.and_then(|p| p.compute_writes("instance_traversal")),
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.bind_group, &[]);
pass.dispatch_workgroups(
dispatch_count(instances, self.plan.pipelines.len() as u32),
1,
1,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::render_graph::{Expression, ExpressionPlan, NodeOutputRef};
fn expression(semantic_type: SemanticType, op: ExpressionOp) -> Expression {
Expression {
semantic_type,
op,
origin: NodeOutputRef {
node: "test".into(),
socket: "value".into(),
},
mesh_provenance: None,
}
}
#[test]
fn pipeline_major_offsets_and_single_dispatch_are_deterministic() {
assert_eq!(command_offset(0, 7, 6), 120);
assert_eq!(command_offset(1, 7, 0), 140);
assert_eq!(command_offset(3, 7, 2), 460);
assert_eq!(dispatch_count(1, 4), 1);
assert_eq!(dispatch_count(64, 4), 1);
assert_eq!(dispatch_count(65, 4), 2);
assert_eq!(dispatch_count(0, 4), 1);
}
#[test]
fn lowering_helpers_cover_matrices_and_reject_dynamic_indexes() {
assert_eq!(
matrix_literal("mat2x2<f32>", &[[1.0, 2.0], [3.0, 4.0]]).unwrap(),
"mat2x2<f32>(1.0,2.0,3.0,4.0)"
);
assert!(fixed_index(3, 3, "vector projection").is_err());
assert_eq!(wgsl_type(&SemanticType::Mat4).unwrap(), "mat4x4<f32>");
}
#[test]
fn variadic_boolean_wgsl_uses_identities_and_ordered_parity() {
let expressions = vec![
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::And,
operands: vec![],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xor,
operands: vec![],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xnor,
operands: vec![crate::render_graph::ExprId(0)],
},
),
expression(
SemanticType::Bool,
ExpressionOp::Boolean {
operation: BooleanOp::Xnor,
operands: vec![
crate::render_graph::ExprId(0),
crate::render_graph::ExprId(1),
crate::render_graph::ExprId(2),
],
},
),
];
let wgsl = generate_wgsl(&InstanceTraversalPlan {
mesh: 0,
expressions: ExpressionPlan { expressions },
pipelines: vec![],
requires_camera: false,
})
.unwrap();
assert!(wgsl.contains("let e0=true;"));
assert!(wgsl.contains("let e1=false;"));
assert!(wgsl.contains("let e2=!(e0);"));
assert!(wgsl.contains("let e3=!((e0 != e1) != e2);"));
}
#[test]
fn u32_construct_wgsl_is_parenthesized() {
let plan = InstanceTraversalPlan {
mesh: 0,
expressions: ExpressionPlan {
expressions: vec![
expression(
SemanticType::Bool,
ExpressionOp::Literal {
literal: TypedLiteral::Bool(true),
},
),
expression(
SemanticType::Bool,
ExpressionOp::Literal {
literal: TypedLiteral::Bool(false),
},
),
expression(
SemanticType::U32,
ExpressionOp::U32Construct {
bits: vec![
crate::render_graph::ExprId(0),
crate::render_graph::ExprId(1),
],
},
),
],
},
pipelines: vec![],
requires_camera: false,
};
let wgsl = generate_wgsl(&plan).unwrap();
assert_eq!(
wgsl.lines().find(|line| line.starts_with("let e2=")),
Some("let e2=(select(0u,1u,e0)|select(0u,2u,e1));")
);
}
}
+162 -170
View File
@@ -17,13 +17,14 @@ use crate::{
pub mod executors;
pub mod gpu_scene;
pub mod instance_traversal;
pub mod instance_filter;
pub mod material;
pub mod pipeline_library;
pub mod profiler;
pub mod scene;
pub mod scene_frame;
use pipeline_library::PipelineKey;
pub use pipeline_library::PipelineLibrary;
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
@@ -273,22 +274,6 @@ fn pack_frame_out_uniforms(
Some(FullscreenUniforms { values })
}
fn resolve_fullscreen_entry(key: &str) -> Option<&'static str> {
match key {
"fullscreen_copy" => Some("fs_copy"),
"frame_out" => Some("fs_frame_out"),
"color_balance" => Some("fs_color_balance"),
"exposure_contrast" => Some("fs_exposure_contrast"),
"saturation" => Some("fs_saturation"),
"channel_mixer" => Some("fs_channel_mixer"),
"bloom_extract" => Some("fs_bloom_extract"),
"bloom_blur" => Some("fs_bloom_blur"),
"bloom_composite" => Some("fs_bloom_composite"),
"luminance_edge" => Some("fs_luminance_edge"),
_ => None,
}
}
#[cfg(test)]
mod fullscreen_tests {
use super::*;
@@ -322,46 +307,6 @@ mod fullscreen_tests {
);
}
#[test]
fn fullscreen_entries_are_explicit() {
assert_eq!(resolve_fullscreen_entry("fullscreen_copy"), Some("fs_copy"));
assert_eq!(resolve_fullscreen_entry("frame_out"), Some("fs_frame_out"));
assert_eq!(resolve_fullscreen_entry("tone_map"), None);
assert_eq!(
resolve_fullscreen_entry("color_balance"),
Some("fs_color_balance")
);
assert_eq!(
resolve_fullscreen_entry("exposure_contrast"),
Some("fs_exposure_contrast")
);
assert_eq!(
resolve_fullscreen_entry("saturation"),
Some("fs_saturation")
);
assert_eq!(
resolve_fullscreen_entry("channel_mixer"),
Some("fs_channel_mixer")
);
assert_eq!(
resolve_fullscreen_entry("bloom_extract"),
Some("fs_bloom_extract")
);
assert_eq!(
resolve_fullscreen_entry("bloom_blur"),
Some("fs_bloom_blur")
);
assert_eq!(
resolve_fullscreen_entry("bloom_composite"),
Some("fs_bloom_composite")
);
assert_eq!(
resolve_fullscreen_entry("luminance_edge"),
Some("fs_luminance_edge")
);
assert_eq!(resolve_fullscreen_entry("unknown"), None);
}
fn surface(format: wgpu::TextureFormat) -> RuntimeSurfaceContract {
RuntimeSurfaceContract {
format,
@@ -800,7 +745,7 @@ mod fullscreen_tests {
.iter()
.all(|v| v.is_finite()));
}
// Both WGSL balance branches are neutral on nonnegative RGB with neutral controls.
// Both balance branches are neutral on nonnegative RGB with neutral controls.
let lgg = c; // lift=0/color=1, gamma=1/color=1, gain=1/color=1
let ops = c; // offset=0/color=1, power=1/color=1, slope=1/color=1
assert_eq!(lgg, c);
@@ -815,8 +760,8 @@ struct GpuTextureSlot {
enum PreparedExecution {
Pipeline {
base: crate::render_data::PipelineKey,
predicate_ordinal: u32,
base: PipelineKey,
predicate: crate::render_graph::ExprId,
variant: wgpu::RenderPipeline,
},
Fullscreen {
@@ -826,11 +771,18 @@ enum PreparedExecution {
},
}
struct PreparedCompute {
name: String,
pipeline: wgpu::ComputePipeline,
dispatch: [u32; 3],
}
struct ActiveCompiledGraph {
id: crate::render_graph::CompiledGraphId,
graph: crate::render_graph::CompiledGraph,
runtime: crate::render_graph::RuntimePlan,
textures: Vec<Vec<GpuTextureSlot>>,
compute: Vec<PreparedCompute>,
executions: Vec<PreparedExecution>,
_fullscreen_layout: wgpu::BindGroupLayout,
}
@@ -1033,7 +985,7 @@ mod switch_request_tests {
let mut graph = crate::render_graph::tests::full_cull_graph();
graph["graphId"] = serde_json::json!(graph_id);
graph["revision"] = serde_json::json!(revision);
serde_json::to_vec(&graph).unwrap()
crate::render_graph::tests::ast_bytes(&graph)
}
use super::*;
@@ -1133,7 +1085,7 @@ mod switch_request_tests {
let mut registry = crate::render_graph::Registry::default();
let mut graph = crate::render_graph::tests::full_cull_graph();
graph["graphId"] = serde_json::json!("switch");
let bytes = serde_json::to_vec(&graph).unwrap();
let bytes = crate::render_graph::tests::ast_bytes(&graph);
let (id, _) = registry.compile(&bytes).unwrap();
let active = "existing_graph";
let pending: Option<&str> = None;
@@ -1151,11 +1103,10 @@ mod switch_request_tests {
assert_eq!(active, "existing_graph");
assert_eq!(pending, None);
let invalid_replacement =
br#"{"schemaVersion":3,"graphId":"switch","revision":2,"nodes":[],"unexpected":true}"#;
let invalid_replacement = b"(yawn-graph 1 (id \"switch\") (revision 2) (pipelines (object (field \"render\" (array)) (field \"compute\" (array)))) (nodes) (unexpected true))";
assert_eq!(
registry.compile(invalid_replacement).unwrap_err().code,
"GRAPH_JSON_INVALID"
"GRAPH_AST_INVALID"
);
let stored = registry.get(id).unwrap();
assert_eq!(stored.revision, 1);
@@ -1296,11 +1247,12 @@ pub struct Renderer<T: scene::Scene> {
resources: PipelineLibrary,
scene: T,
render_data: RenderData,
shared_soa: crate::shared_soa::SharedSoaRegistry,
shared_soa_init_sent: bool,
snapshot: crate::shared_snapshot::SharedSnapshot,
snapshot_init_sent: bool,
scene_frame: scene_frame::SceneFrameCache,
gpu_scene: gpu_scene::GpuSceneCache,
instance_traversal: Option<instance_traversal::TraversalGpu>,
materials: material::MaterialResources,
pub(crate) command_ring: Option<&'static CommandRing>,
pending_replies: Vec<JsValue>,
@@ -1445,20 +1397,22 @@ impl<T: Scene + 'static> Renderer<T> {
}
let outcome: Result<JsValue, &'static str> = (|| match opcode {
1 => {
if words[3] > 1 {
if words[4] > 1 {
return Err("INVALID_FRAMING");
}
let bytes = crate::take_payload(words[2]).ok_or("PAYLOAD_MISSING")?;
let bytes = self
.shared_soa
.read_fixed_bytes(words[2], words[3])
.map_err(|_| "SHARED_UPLOAD_INVALID")?;
let imported =
crate::gltf::decode_gltf_owned(bytes).map_err(|_| "GLB_INVALID")?;
let pipelines = Self::ensure_gltf_pipelines(&mut self.resources, &self.context);
// Build a complete GPU candidate first. Neither the live scene nor
// its material epoch changes if image decode/resource creation fails.
let prepared_materials = self
.materials
.prepare(&self.context.device, &self.context.queue, &imported)
.map_err(|_| "MATERIAL_INVALID")?;
let installed = install_imported(&mut self.render_data, &imported, pipelines)
let installed = install_imported(&mut self.render_data, &imported)
.map_err(|_| "INSTALL_FAILED")?;
// RenderData replacement and material publication are adjacent in
// this synchronous command, preventing a frame with mixed assets.
@@ -1487,7 +1441,7 @@ impl<T: Scene + 'static> Renderer<T> {
(radius * 0.001).max(0.1),
(radius * 6.0).max(1.1),
);
if words[3] == 1 {
if words[4] == 1 {
self.scene.set_camera_look_at(
center + ultraviolet::Vec3::new(0.0, radius * 0.05, 0.0),
center + ultraviolet::Vec3::new(radius, 0.0, 0.0),
@@ -1558,33 +1512,21 @@ impl<T: Scene + 'static> Renderer<T> {
.map_err(|e| render_data_error_code(&e))?;
Ok(js_sys::Array::of2(&h.slot().into(), &h.generation().into()).into())
}
5 => {
let h = InstanceHandle::from_parts(words[2], words[3]);
let mut m = [[0.; 4]; 4];
for i in 0..16 {
m[i / 4][i % 4] = f32::from_bits(words[4 + i]);
}
self.render_data
.set_instance_transform(h, m)
.map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED)
}
6 => {
self.render_data
.destroy_instance(InstanceHandle::from_parts(words[2], words[3]))
.map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED)
}
10 => {
self.render_data
.set_instance_type(
InstanceHandle::from_parts(words[2], words[3]),
InstanceType {
words: std::array::from_fn(|i| words[4 + i]),
},
)
.map_err(|e| render_data_error_code(&e))?;
Ok(JsValue::UNDEFINED)
11 => {
let bytes = crate::take_payload(words[2]).ok_or("PAYLOAD_MISSING")?;
let descriptor = self
.shared_soa
.allocate_json(&bytes, self.render_data.capacities())
.map_err(|_| "SOA_LAYOUT_INVALID")?;
let json =
serde_json::to_string(&descriptor).map_err(|_| "SOA_LAYOUT_INVALID")?;
Ok(js_sys::JSON::parse(&json).map_err(|_| "SOA_LAYOUT_INVALID")?)
}
_ => Err("UNKNOWN_OPCODE"),
})();
@@ -1629,28 +1571,6 @@ impl<T: Scene + 'static> Renderer<T> {
self.context.depth_view = view;
}
fn ensure_gltf_pipelines(
resources: &mut PipelineLibrary,
context: &RendererContext,
) -> [crate::render_data::PipelineKey; 2] {
let layout = gpu_scene::vertex_layouts();
let culled = resources.get_or_create_pipeline(
&context.device,
"gltf_standard",
&layout,
include_str!("../gltf.wgsl"),
context.initial_surface_config.format,
);
let double_sided = resources.get_or_create_pipeline(
&context.device,
"gltf_standard_double_sided",
&layout,
include_str!("../gltf.wgsl"),
context.initial_surface_config.format,
);
[culled, double_sided]
}
fn plan_compiled(
&self,
graph: &crate::render_graph::CompiledGraph,
@@ -1679,6 +1599,31 @@ impl<T: Scene + 'static> Renderer<T> {
) -> Result<ActiveCompiledGraph, crate::render_graph::GraphError> {
use crate::render_graph::*;
let fail = |message| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", message);
let vertex_layouts = gpu_scene::vertex_layouts();
let render_declarations: std::collections::HashMap<_, _> = graph
.pipelines
.render
.iter()
.map(|declaration| (declaration.name.as_str(), declaration))
.collect();
let authored_pipelines: std::collections::HashMap<_, _> = graph
.pipelines
.render
.iter()
.filter(|declaration| {
crate::render_graph::contract(&declaration.name)
.is_some_and(crate::render_graph::Contract::is_raster_draw)
})
.map(|declaration| {
let key = self.resources.get_or_create_authored_pipeline(
&self.context.device,
declaration,
&vertex_layouts,
self.context.initial_surface_config.format,
);
(declaration.name.clone(), key)
})
.collect();
let resolved_pipelines = graph
.executions
.iter()
@@ -1687,8 +1632,9 @@ impl<T: Scene + 'static> Renderer<T> {
let NormalizedParameters::Raster { .. } = &execution.parameters else {
return Ok(None);
};
self.resources
.find_pipeline(&execution.executor.key)
authored_pipelines
.get(&execution.executor.key)
.copied()
.map(Some)
.ok_or_else(|| {
GraphError::at(
@@ -1765,6 +1711,44 @@ impl<T: Scene + 'static> Renderer<T> {
));
}
}
let compute_layout =
self.context
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("graph compute pipeline layout"),
bind_group_layouts: &[],
push_constant_ranges: &[],
});
let compute = graph
.pipelines
.compute
.iter()
.map(|declaration| {
let shader =
self.context
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&declaration.name),
source: wgpu::ShaderSource::Wgsl(declaration.shader.as_str().into()),
});
let pipeline =
self.context
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(&declaration.name),
layout: Some(&compute_layout),
module: &shader,
entry_point: Some(&declaration.entry),
compilation_options: Default::default(),
cache: None,
});
PreparedCompute {
name: declaration.name.clone(),
pipeline,
dispatch: declaration.dispatch,
}
})
.collect();
let mut textures = Vec::with_capacity(runtime.allocations.classes.len());
for class in &runtime.allocations.classes {
let mut gpu_class = Vec::with_capacity(class.slots.len());
@@ -1861,13 +1845,6 @@ impl<T: Scene + 'static> Renderer<T> {
bind_group_layouts: &[&fullscreen_layout],
push_constant_ranges: &[],
});
let shader = self
.context
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(" fullscreen"),
source: wgpu::ShaderSource::Wgsl(include_str!("fullscreen_copy.wgsl").into()),
});
let sampler = self
.context
.device
@@ -1974,15 +1951,26 @@ impl<T: Scene + 'static> Renderer<T> {
.descriptor
.format
};
let entry = resolve_fullscreen_entry(&execution.executor.key)
.ok_or_else(|| fail("fullscreen executor mismatch"))?;
let declaration = render_declarations
.get(execution.executor.key.as_str())
.copied()
.ok_or_else(|| fail("fullscreen pipeline declaration missing"))?;
let shader =
self.context
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&declaration.name),
source: wgpu::ShaderSource::Wgsl(
declaration.shader.as_str().into(),
),
});
let pipeline = self.context.device.create_render_pipeline(
&wgpu::RenderPipelineDescriptor {
label: Some(" post pipeline"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
entry_point: Some(&declaration.vertex_entry),
buffers: &[],
compilation_options: Default::default(),
},
@@ -1991,7 +1979,7 @@ impl<T: Scene + 'static> Renderer<T> {
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry),
entry_point: Some(&declaration.fragment_entry),
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
blend: None,
@@ -2137,13 +2125,13 @@ impl<T: Scene + 'static> Renderer<T> {
.map_err(|e| GraphError::new("GRAPH_RUNTIME_PLAN_INVALID", e))?;
executions.push(PreparedExecution::Pipeline {
base,
predicate_ordinal: runtime
predicate: runtime
.instance_traversal
.as_ref()
.and_then(|p| {
p.pipelines.iter().find(|v| v.execution as usize == index)
})
.map(|p| p.ordinal)
.map(|p| p.predicate)
.ok_or_else(|| fail("pipeline predicate missing"))?,
variant,
});
@@ -2156,6 +2144,7 @@ impl<T: Scene + 'static> Renderer<T> {
graph,
runtime,
textures,
compute,
executions,
_fullscreen_layout: fullscreen_layout,
})
@@ -2387,9 +2376,10 @@ impl<T: Scene + 'static> Renderer<T> {
let mut render_data =
RenderData::new(RenderDataConfig::default()).expect("valid render data config");
let scene = T::setup(&context, &mut resources, &mut render_data);
let shared_soa = crate::shared_soa::SharedSoaRegistry::new(render_data.capacities())
.expect("default shared SOA layouts are valid");
let materials = material::MaterialResources::new(&context.device, &context.queue);
resources.set_material_bind_group_layout(&materials.layout);
Self::ensure_gltf_pipelines(&mut resources, &context);
Self {
events_chan,
@@ -2397,11 +2387,12 @@ impl<T: Scene + 'static> Renderer<T> {
scene,
resources,
render_data,
shared_soa,
shared_soa_init_sent: false,
snapshot: crate::shared_snapshot::SharedSnapshot::new(),
snapshot_init_sent: false,
scene_frame: Default::default(),
gpu_scene: Default::default(),
instance_traversal: None,
materials,
command_ring: None,
pending_replies: Vec::new(),
@@ -2433,6 +2424,45 @@ impl<T: Scene + 'static> Renderer<T> {
if !self.drain_commands() {
return;
}
let soa_layout_changed = match self
.shared_soa
.sync_capacities(self.render_data.capacities())
{
Ok(changed) => changed,
Err(error) => {
self.post_fatal("SOA_ALLOCATION_FAILED", &error.to_string());
return;
}
};
self.shared_soa
.synchronize_render_data(&mut self.render_data);
if !self.shared_soa_init_sent || soa_layout_changed {
let message = js_sys::Object::new();
let message_type = if self.shared_soa_init_sent {
"soa-layout"
} else {
"soa-init"
};
let _ = js_sys::Reflect::set(&message, &"type".into(), &message_type.into());
match self.shared_soa.descriptors().and_then(|descriptors| {
serde_json::to_string(&descriptors)
.map_err(|_| crate::shared_soa::SharedSoaError::SizeOverflow)
}) {
Ok(json) => {
if let Ok(descriptors) = js_sys::JSON::parse(&json) {
let _ = js_sys::Reflect::set(&message, &"arrays".into(), &descriptors);
let global =
js_sys::global().unchecked_into::<DedicatedWorkerGlobalScope>();
let _ = global.post_message(&message);
self.shared_soa_init_sent = true;
}
}
Err(error) => {
self.post_fatal("SOA_ALLOCATION_FAILED", &error.to_string());
return;
}
}
}
let frame_plan = match self.scene_frame.get_or_build(&self.render_data) {
Ok(plan) => plan,
Err(error) => {
@@ -2616,40 +2646,6 @@ impl<T: Scene + 'static> Renderer<T> {
});
let encode_result = if let Some(active) = rendering_compiled {
(|| -> Result<(), &'static str> {
let plan = active
.runtime
.instance_traversal
.as_ref()
.ok_or("compiled graph instance traversal missing")?;
let rebuild = self.instance_traversal.as_ref().is_none_or(|traversal| {
!traversal.matches(
active.id,
plan,
self.gpu_scene.buffer_epoch,
self.gpu_scene.draws.len(),
)
});
if rebuild {
self.instance_traversal = Some(
instance_traversal::TraversalGpu::create(
&self.context.device,
active.id,
plan,
&self.gpu_scene,
)
.map_err(|error| {
log::error!("instance traversal preparation failed: {error}");
"instance traversal preparation failed"
})?,
);
}
self.instance_traversal.as_ref().unwrap().encode(
&mut encoder,
&self.context.queue,
planes,
self.gpu_scene.draws.len() as u32,
profile_frame.as_mut(),
);
executors::encode_compiled(
&mut encoder,
&texture_view,
@@ -2658,7 +2654,7 @@ impl<T: Scene + 'static> Renderer<T> {
&self.gpu_scene,
&self.resources,
&self.materials,
&self.instance_traversal.as_ref().unwrap().commands,
planes.as_ref(),
profile_frame.as_mut(),
)
})()
@@ -2667,10 +2663,6 @@ impl<T: Scene + 'static> Renderer<T> {
&mut encoder,
&texture_view,
&self.context.depth_view,
&self.scene,
&self.gpu_scene,
&self.resources,
&self.materials,
profile_frame.as_mut(),
);
Ok(())
+31 -55
View File
@@ -1,9 +1,21 @@
use std::{collections::HashMap, num::NonZeroU32};
use crate::render_data::PipelineKey;
use super::DEPTH_FORMAT;
#[repr(transparent)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) struct PipelineKey(u32);
impl PipelineKey {
pub const fn new(value: u32) -> Self {
Self(value)
}
pub const fn get(self) -> u32 {
self.0
}
}
/// Identity of a set of bind-group layouts registered with this library.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)]
pub struct PipelineLayoutKey(u64);
@@ -127,7 +139,6 @@ pub struct PipelineLibrary {
default_layout: Option<PipelineLayoutKey>,
material_layout: Option<PipelineLayoutKey>,
next_layout: u64,
named_bases: HashMap<String, (PipelineKey, RenderPipelineKey)>,
descriptor_cache: HashMap<RenderPipelineKey, PipelineKey>,
}
@@ -141,7 +152,6 @@ impl PipelineLibrary {
default_layout: None,
material_layout: None,
next_layout: 0,
named_bases: HashMap::new(),
descriptor_cache: HashMap::new(),
}
}
@@ -181,11 +191,6 @@ impl PipelineLibrary {
shader: &str,
format: wgpu::TextureFormat,
) -> RenderPipelineSpec {
let (vertex_entry, fragment_entry) = if name == "triangle_colored" {
("v_main", "f_main")
} else {
("vs_main", "fs_main")
};
let stage = |entry: &str| OwnedProgrammableStage {
shader_source: shader.to_owned(),
entry_point: entry.to_owned(),
@@ -198,7 +203,7 @@ impl PipelineLibrary {
} else {
self.default_layout
},
vertex: stage(vertex_entry),
vertex: stage("vs_main"),
vertex_layouts: layouts
.iter()
.map(|layout| OwnedVertexBufferLayout {
@@ -207,7 +212,7 @@ impl PipelineLibrary {
attributes: layout.attributes.to_vec(),
})
.collect(),
fragment: Some(stage(fragment_entry)),
fragment: Some(stage("fs_main")),
primitive: wgpu::PrimitiveState {
cull_mode: (name != "gltf_standard_double_sided").then_some(wgpu::Face::Back),
..Default::default()
@@ -230,7 +235,7 @@ impl PipelineLibrary {
}
/// Creates or reuses a pipeline solely by its owned descriptor identity.
pub fn get_or_create_from_spec(
pub(crate) fn get_or_create_from_spec(
&mut self,
device: &wgpu::Device,
spec: &RenderPipelineSpec,
@@ -325,60 +330,31 @@ impl PipelineLibrary {
pipeline_key
}
pub fn create_pipeline(
/// Creates a graph-owned scene pipeline from its authored declaration.
pub(crate) fn get_or_create_authored_pipeline(
&mut self,
device: &wgpu::Device,
name: &str,
declaration: &crate::render_graph::RenderPipelineDeclaration,
layouts: &[wgpu::VertexBufferLayout],
shader: &str,
format: wgpu::TextureFormat,
) -> Result<PipelineKey, String> {
let spec = self.compatibility_spec(name, layouts, shader, format);
let descriptor = spec.key();
if let Some((_, existing)) = self.named_bases.get(name) {
return Err(if existing == &descriptor {
format!("Pipeline '{name}' already exists")
} else {
format!("Pipeline '{name}' already exists with a different descriptor")
});
}
let key = self.get_or_create_from_spec(device, &spec, Some(name));
self.named_bases.insert(name.to_owned(), (key, descriptor));
Ok(key)
}
pub fn find_pipeline(&self, name: &str) -> Option<PipelineKey> {
self.named_bases.get(name).map(|v| v.0)
}
pub fn get_or_create_pipeline(
&mut self,
device: &wgpu::Device,
name: &str,
layouts: &[wgpu::VertexBufferLayout],
shader: &str,
format: wgpu::TextureFormat,
) -> PipelineKey {
let wanted = self.compatibility_spec(name, layouts, shader, format).key();
if let Some((key, existing)) = self.named_bases.get(name) {
assert_eq!(
existing, &wanted,
"Pipeline '{name}' requested with a different descriptor"
);
return *key;
}
self.create_pipeline(device, name, layouts, shader, format)
.unwrap_or_else(|e| panic!("Failed to create pipeline '{name}': {e}"))
}
pub fn get_pipeline(&self, key: PipelineKey) -> &wgpu::RenderPipeline {
&self.pipelines[key.get() as usize]
let mut spec =
self.compatibility_spec(&declaration.name, layouts, &declaration.shader, format);
spec.vertex.entry_point = declaration.vertex_entry.clone();
spec.fragment
.as_mut()
.expect("scene 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))
}
pub fn requires_material(&self, key: PipelineKey) -> bool {
pub(crate) fn requires_material(&self, key: PipelineKey) -> bool {
self.material_layout.is_some()
&& self.specs[key.get() as usize].layout == self.material_layout
}
pub fn create_target_variant(
pub(crate) fn create_target_variant(
&self,
device: &wgpu::Device,
base: PipelineKey,
+1
View File
@@ -13,6 +13,7 @@ const SLOT_COUNT: usize = 4;
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)]
+1 -4
View File
@@ -4,14 +4,13 @@ use thiserror::Error;
use crate::render_data::{
affine_world_aabb, Aabb, GeometryRange, InstanceHandle, InstanceType, MaterialKey, MeshHandle,
ModelTransform, NormalMatrix, PipelineKey, RenderData,
ModelTransform, NormalMatrix, RenderData,
};
#[derive(Clone, Debug)]
pub struct SceneFrameMesh {
pub handle: MeshHandle,
pub geometry: GeometryRange,
pub pipeline: PipelineKey,
pub material: MaterialKey,
pub instance_type: InstanceType,
pub local_aabb: Aabb,
@@ -115,7 +114,6 @@ impl SceneFramePlan {
.map(|(dense, (handle, mesh))| SceneFrameMesh {
handle,
geometry: mesh.geometry,
pipeline: mesh.pipeline,
material: mesh.material,
instance_type: mesh.default_instance_type,
local_aabb: mesh.local_aabb,
@@ -168,7 +166,6 @@ mod tests {
tangents: &[[1., 0., 0., 1.]; 3],
uvs: &[[0., 0.]; 3],
indices: &[0, 1, 2],
pipeline: PipelineKey::new(0),
material: crate::render_data::MaterialKey::DEFAULT,
default_instance_type: instance_type,
default_transform: IDENTITY_MODEL_TRANSFORM,
+826
View File
@@ -0,0 +1,826 @@
//! Extensible, SIMD-aligned SOA columns in shared WebAssembly memory.
use std::{
collections::BTreeMap,
sync::atomic::{AtomicU32, Ordering},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::render_data::{InstanceHandle, RenderData, RenderDataCapacities};
pub const MAGIC: u32 = u32::from_le_bytes(*b"YSOA");
pub const VERSION: u32 = 1;
pub const HEADER_WORDS: usize = 16;
pub const DATA_OFFSET: u32 = 64;
#[repr(C, align(64))]
pub struct SharedBlock([AtomicU32; 16]);
impl SharedBlock {
fn zeroed() -> Self {
Self(std::array::from_fn(|_| AtomicU32::new(0)))
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScalarType {
U32,
I32,
F32,
}
impl ScalarType {
fn tag(self) -> u32 {
match self {
Self::U32 => 1,
Self::I32 => 2,
Self::F32 => 3,
}
}
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ArrayDomain {
Mesh,
Instance,
Fixed,
}
impl ArrayDomain {
fn tag(self) -> u32 {
match self {
Self::Mesh => 1,
Self::Instance => 2,
Self::Fixed => 3,
}
}
fn capacity(self, capacities: RenderDataCapacities, fixed: Option<u32>) -> Option<u32> {
match self {
Self::Mesh => Some(capacities.meshes),
Self::Instance => Some(capacities.instances),
Self::Fixed => fixed,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ArrayRequest {
pub name: String,
pub domain: ArrayDomain,
pub scalar: ScalarType,
pub lanes: u32,
pub stride: Option<u32>,
pub length: Option<u32>,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ArrayDescriptor {
pub id: u32,
pub name: String,
pub domain: ArrayDomain,
pub scalar: ScalarType,
pub lanes: u32,
pub stride: u32,
pub length: u32,
pub capacity: u32,
pub control_ptr: u32,
pub data_offset: u32,
pub byte_length: u32,
pub layout_epoch: u32,
pub writable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub generation_guard: Option<ArrayDomain>,
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum SharedSoaError {
#[error("shared SOA request is not valid JSON: {0}")]
InvalidJson(String),
#[error("shared SOA array name is invalid")]
InvalidName,
#[error("shared SOA lanes must be in 1..=64")]
InvalidLanes,
#[error("shared SOA stride must fit all lanes and be a multiple of 16 bytes")]
InvalidStride,
#[error("fixed shared SOA arrays require a nonzero length")]
InvalidLength,
#[error("shared SOA array already exists with a different layout")]
LayoutConflict,
#[error("shared SOA allocation size overflow")]
SizeOverflow,
#[error("shared SOA allocation failed")]
AllocationFailed,
#[error("shared SOA array is unknown")]
UnknownArray,
#[error("shared SOA byte uploads require a packed fixed array")]
NotPackedFixed,
#[error("shared SOA byte range exceeds the array length")]
ByteRange,
#[error("shared SOA array is currently being written")]
Busy,
}
struct SharedArray {
id: u32,
request: ArrayRequest,
stride_words: u32,
length: u32,
capacity: u32,
layout_epoch: u32,
storage: Box<[SharedBlock]>,
retired: Vec<Box<[SharedBlock]>>,
consumed_sequence: u32,
consumed_slot_sequences: Vec<u32>,
generation_guard: Option<ArrayDomain>,
writable: bool,
}
fn generation_guard(name: &str) -> Option<ArrayDomain> {
match name {
"instance.transform" | "instance.type" => Some(ArrayDomain::Instance),
_ => None,
}
}
fn writable(name: &str) -> bool {
!matches!(name, "instance.generation" | "mesh.generation")
}
impl SharedArray {
fn new(
id: u32,
request: ArrayRequest,
stride_words: u32,
capacity: u32,
) -> Result<Self, SharedSoaError> {
let generation_guard = generation_guard(&request.name);
let writable = writable(&request.name);
let mut array = Self {
id,
request,
stride_words,
length: capacity,
capacity,
layout_epoch: 1,
storage: allocate(capacity, stride_words)?,
retired: Vec::new(),
consumed_sequence: 0,
consumed_slot_sequences: vec![0; capacity as usize],
generation_guard,
writable,
};
array.initialize_header();
Ok(array)
}
fn initialize_header(&mut self) {
for (index, value) in [
MAGIC,
VERSION,
self.id,
self.request.scalar.tag(),
self.request.lanes,
self.stride_words,
self.length,
self.capacity,
self.request.domain.tag(),
0,
self.layout_epoch,
0,
0,
0,
0,
0,
]
.into_iter()
.enumerate()
{
self.word(index).store(value, Ordering::Relaxed);
}
self.consumed_sequence = 0;
}
fn word(&self, index: usize) -> &AtomicU32 {
let block = index / 16;
let lane = index % 16;
&self.storage[block].0[lane]
}
fn data_word(&self, slot: u32, lane: u32) -> &AtomicU32 {
let index = HEADER_WORDS + (slot * self.stride_words + lane) as usize;
self.word(index)
}
fn descriptor(&self) -> Result<ArrayDescriptor, SharedSoaError> {
let control_ptr = pointer(&self.storage)?;
let byte_length = self
.capacity
.checked_mul(self.stride_words)
.and_then(|words| words.checked_mul(4))
.ok_or(SharedSoaError::SizeOverflow)?;
Ok(ArrayDescriptor {
id: self.id,
name: self.request.name.clone(),
domain: self.request.domain,
scalar: self.request.scalar,
lanes: self.request.lanes,
stride: self.stride_words * 4,
length: self.length,
capacity: self.capacity,
control_ptr,
data_offset: DATA_OFFSET,
byte_length,
layout_epoch: self.layout_epoch,
writable: self.writable,
generation_guard: self.generation_guard,
})
}
fn resize(&mut self, capacity: u32) -> Result<bool, SharedSoaError> {
if capacity <= self.capacity {
self.length = capacity;
self.word(6).store(capacity, Ordering::Release);
return Ok(false);
}
let replacement = allocate(capacity, self.stride_words)?;
let old_words = HEADER_WORDS
+ self
.capacity
.checked_mul(self.stride_words)
.ok_or(SharedSoaError::SizeOverflow)? as usize;
for index in HEADER_WORDS..old_words {
let block = index / 16;
let lane = index % 16;
replacement[block].0[lane]
.store(self.word(index).load(Ordering::Acquire), Ordering::Relaxed);
}
let old = std::mem::replace(&mut self.storage, replacement);
self.retired.push(old);
self.capacity = capacity;
self.length = capacity;
self.consumed_slot_sequences.resize(capacity as usize, 0);
self.layout_epoch = self
.layout_epoch
.checked_add(1)
.ok_or(SharedSoaError::SizeOverflow)?;
self.initialize_header();
self.word(10).store(self.layout_epoch, Ordering::Relaxed);
Ok(true)
}
fn try_lock(&self) -> Option<u32> {
let sequence = self.word(9).load(Ordering::Acquire);
if sequence & 1 != 0 {
return None;
}
self.word(9)
.compare_exchange(
sequence,
sequence.wrapping_add(1),
Ordering::AcqRel,
Ordering::Acquire,
)
.ok()
.map(|_| sequence)
}
fn unlock(&mut self, sequence: u32) {
let next = sequence.wrapping_add(2) & !1;
self.consumed_sequence = next;
self.word(9).store(next, Ordering::Release);
}
fn changed(&self) -> bool {
let sequence = self.word(9).load(Ordering::Acquire);
sequence & 1 == 0 && sequence != self.consumed_sequence
}
}
fn allocate(capacity: u32, stride_words: u32) -> Result<Box<[SharedBlock]>, SharedSoaError> {
let words = capacity
.checked_mul(stride_words)
.and_then(|value| value.checked_add(HEADER_WORDS as u32))
.ok_or(SharedSoaError::SizeOverflow)?;
let blocks = words.checked_add(15).ok_or(SharedSoaError::SizeOverflow)? / 16;
let blocks = usize::try_from(blocks).map_err(|_| SharedSoaError::SizeOverflow)?;
let mut storage = Vec::new();
storage
.try_reserve_exact(blocks)
.map_err(|_| SharedSoaError::AllocationFailed)?;
storage.resize_with(blocks, SharedBlock::zeroed);
Ok(storage.into_boxed_slice())
}
#[cfg(target_arch = "wasm32")]
fn pointer(storage: &[SharedBlock]) -> Result<u32, SharedSoaError> {
u32::try_from(storage.as_ptr() as usize).map_err(|_| SharedSoaError::SizeOverflow)
}
#[cfg(not(target_arch = "wasm32"))]
fn pointer(_storage: &[SharedBlock]) -> Result<u32, SharedSoaError> {
Ok(0)
}
fn valid_name(value: &str) -> bool {
let mut chars = value.chars();
chars
.next()
.is_some_and(|character| character.is_ascii_alphabetic())
&& chars.all(|character| {
character.is_ascii_alphanumeric() || matches!(character, '_' | '.' | '-')
})
&& value.len() <= 64
}
/// Owns stable shared columns. Replaced allocations are retained so stale external
/// views can never alias newly allocated Rust objects.
pub struct SharedSoaRegistry {
arrays: BTreeMap<String, SharedArray>,
next_id: u32,
published_instance_generations: BTreeMap<u32, u32>,
published_mesh_generations: BTreeMap<u32, u32>,
}
impl SharedSoaRegistry {
pub fn new(capacities: RenderDataCapacities) -> Result<Self, SharedSoaError> {
let mut registry = Self {
arrays: BTreeMap::new(),
next_id: 1,
published_instance_generations: BTreeMap::new(),
published_mesh_generations: BTreeMap::new(),
};
for request in [
ArrayRequest {
name: "instance.transform".into(),
domain: ArrayDomain::Instance,
scalar: ScalarType::F32,
lanes: 16,
stride: Some(80),
length: None,
},
ArrayRequest {
name: "instance.type".into(),
domain: ArrayDomain::Instance,
scalar: ScalarType::U32,
lanes: 16,
stride: Some(80),
length: None,
},
ArrayRequest {
name: "instance.generation".into(),
domain: ArrayDomain::Instance,
scalar: ScalarType::U32,
lanes: 1,
stride: Some(16),
length: None,
},
ArrayRequest {
name: "mesh.generation".into(),
domain: ArrayDomain::Mesh,
scalar: ScalarType::U32,
lanes: 1,
stride: Some(16),
length: None,
},
] {
registry.allocate(request, capacities)?;
}
Ok(registry)
}
pub fn allocate_json(
&mut self,
bytes: &[u8],
capacities: RenderDataCapacities,
) -> Result<ArrayDescriptor, SharedSoaError> {
let request = serde_json::from_slice(bytes)
.map_err(|error| SharedSoaError::InvalidJson(error.to_string()))?;
self.allocate(request, capacities)
}
pub fn allocate(
&mut self,
request: ArrayRequest,
capacities: RenderDataCapacities,
) -> Result<ArrayDescriptor, SharedSoaError> {
if !valid_name(&request.name) {
return Err(SharedSoaError::InvalidName);
}
if !(1..=64).contains(&request.lanes) {
return Err(SharedSoaError::InvalidLanes);
}
let physical_lanes = request
.lanes
.checked_add(if generation_guard(&request.name).is_some() {
2
} else {
0
})
.ok_or(SharedSoaError::SizeOverflow)?;
let minimum_stride = physical_lanes
.checked_mul(4)
.ok_or(SharedSoaError::SizeOverflow)?;
let stride = request
.stride
.unwrap_or_else(|| (minimum_stride + 15) & !15);
if stride < minimum_stride || stride % 16 != 0 {
return Err(SharedSoaError::InvalidStride);
}
let capacity = request
.domain
.capacity(capacities, request.length)
.filter(|capacity| *capacity > 0)
.ok_or(SharedSoaError::InvalidLength)?;
if let Some(existing) = self.arrays.get_mut(&request.name) {
if existing.request.domain != request.domain
|| existing.request.scalar != request.scalar
|| existing.request.lanes != request.lanes
|| existing.stride_words != stride / 4
{
return Err(SharedSoaError::LayoutConflict);
}
if request.domain == ArrayDomain::Fixed {
existing.resize(capacity)?;
}
return existing.descriptor();
}
let id = self.next_id;
self.next_id = self
.next_id
.checked_add(1)
.ok_or(SharedSoaError::SizeOverflow)?;
let name = request.name.clone();
let array = SharedArray::new(id, request, stride / 4, capacity)?;
let descriptor = array.descriptor()?;
self.arrays.insert(name, array);
Ok(descriptor)
}
pub fn descriptors(&self) -> Result<Vec<ArrayDescriptor>, SharedSoaError> {
self.arrays.values().map(SharedArray::descriptor).collect()
}
/// Copies a stable byte snapshot from a packed fixed array. Writers publish a
/// complete upload with the same sequence lock used by all shared SOA columns.
pub fn read_fixed_bytes(
&mut self,
id: u32,
byte_length: u32,
) -> Result<Vec<u8>, SharedSoaError> {
let array = self
.arrays
.values_mut()
.find(|array| array.id == id)
.ok_or(SharedSoaError::UnknownArray)?;
if array.request.domain != ArrayDomain::Fixed
|| array.request.scalar != ScalarType::U32
|| array.stride_words != array.request.lanes
{
return Err(SharedSoaError::NotPackedFixed);
}
let available = array
.length
.checked_mul(array.request.lanes)
.and_then(|words| words.checked_mul(4))
.ok_or(SharedSoaError::SizeOverflow)?;
if byte_length == 0 || byte_length > available {
return Err(SharedSoaError::ByteRange);
}
let mut bytes = Vec::new();
bytes
.try_reserve_exact(byte_length as usize)
.map_err(|_| SharedSoaError::AllocationFailed)?;
let sequence = array.try_lock().ok_or(SharedSoaError::Busy)?;
let word_length = byte_length.div_ceil(4);
for index in 0..word_length {
let slot = index / array.request.lanes;
let lane = index % array.request.lanes;
bytes.extend_from_slice(
&array
.data_word(slot, lane)
.load(Ordering::Acquire)
.to_le_bytes(),
);
}
bytes.truncate(byte_length as usize);
array.unlock(sequence);
Ok(bytes)
}
/// 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;
for array in self.arrays.values_mut() {
if array.request.domain == ArrayDomain::Fixed {
continue;
}
let capacity = array
.request
.domain
.capacity(capacities, None)
.expect("non-fixed domains always resolve");
changed |= array.resize(capacity)?;
}
Ok(changed)
}
fn take_instance_words(
&mut self,
name: &str,
handles: &[InstanceHandle],
) -> Option<Vec<(InstanceHandle, Vec<u32>)>> {
let array = self.arrays.get_mut(name)?;
if !array.changed() {
return None;
}
let sequence = array.try_lock()?;
let mut values = Vec::new();
for handle in handles.iter().copied() {
let slot = handle.slot() as usize;
let mutation_sequence = array
.data_word(handle.slot(), array.request.lanes + 1)
.load(Ordering::Acquire);
if array.consumed_slot_sequences[slot] == mutation_sequence {
continue;
}
array.consumed_slot_sequences[slot] = mutation_sequence;
let expected_generation = array
.data_word(handle.slot(), array.request.lanes)
.load(Ordering::Acquire);
if expected_generation == handle.generation() {
let words = (0..array.request.lanes)
.map(|lane| array.data_word(handle.slot(), lane).load(Ordering::Acquire))
.collect();
values.push((handle, words));
}
}
array.unlock(sequence);
Some(values)
}
/// Publishes newly live slots, then applies committed mutable columns. Existing
/// slots are never republished, so a concurrent shared-memory write cannot be
/// overwritten by an unrelated render-data revision.
pub fn synchronize_render_data(&mut self, data: &mut RenderData) {
self.publish_instance_handles(data);
self.publish_mesh_handles(data);
let handles: Vec<_> = data.instances().map(|(handle, _)| handle).collect();
if let Some(transforms) = self.take_instance_words("instance.transform", &handles) {
for (handle, words) in transforms {
let mut transform = [[0.0; 4]; 4];
for (index, word) in words.into_iter().enumerate() {
transform[index / 4][index % 4] = f32::from_bits(word);
}
let _ = data.set_instance_transform(handle, transform);
}
}
if let Some(types) = self.take_instance_words("instance.type", &handles) {
for (handle, words) in types {
let Ok(words) = <Vec<u32> as TryInto<[u32; 16]>>::try_into(words) else {
continue;
};
let _ = data.set_instance_type(handle, crate::render_data::InstanceType { words });
}
}
}
fn publish_instance_handles(&mut self, data: &RenderData) {
let instances: Vec<_> = data.instances().map(|(_, value)| value).collect();
let current: BTreeMap<_, _> = instances
.iter()
.map(|instance| (instance.handle.slot(), instance.handle.generation()))
.collect();
let changed: Vec<_> = instances
.iter()
.filter(|instance| {
self.published_instance_generations
.get(&instance.handle.slot())
!= Some(&instance.handle.generation())
})
.collect();
let removed: Vec<_> = self
.published_instance_generations
.keys()
.filter(|slot| !current.contains_key(slot))
.copied()
.collect();
if changed.is_empty() && removed.is_empty() {
return;
}
for name in ["instance.transform", "instance.type"] {
let Some(array) = self.arrays.get_mut(name) else {
return;
};
let Some(sequence) = array.try_lock() else {
return;
};
for instance in &changed {
match name {
"instance.transform" => {
for (lane, value) in instance.model.iter().flatten().enumerate() {
array
.data_word(instance.handle.slot(), lane as u32)
.store(value.to_bits(), Ordering::Relaxed);
}
}
"instance.type" => {
for (lane, value) in instance.instance_type.words.iter().enumerate() {
array
.data_word(instance.handle.slot(), lane as u32)
.store(*value, Ordering::Relaxed);
}
}
_ => unreachable!(),
}
array
.data_word(instance.handle.slot(), array.request.lanes)
.store(instance.handle.generation(), Ordering::Relaxed);
let mutation_sequence = array
.data_word(instance.handle.slot(), array.request.lanes + 1)
.load(Ordering::Relaxed);
array.consumed_slot_sequences[instance.handle.slot() as usize] = mutation_sequence;
}
array.unlock(sequence);
}
let Some(generations) = self.arrays.get_mut("instance.generation") else {
return;
};
let Some(sequence) = generations.try_lock() else {
return;
};
for slot in removed {
generations.data_word(slot, 0).store(0, Ordering::Relaxed);
}
for instance in changed {
generations
.data_word(instance.handle.slot(), 0)
.store(instance.handle.generation(), Ordering::Relaxed);
}
generations.unlock(sequence);
self.published_instance_generations = current;
}
fn publish_mesh_handles(&mut self, data: &RenderData) {
let meshes: Vec<_> = data.meshes().map(|(_, value)| value).collect();
let current: BTreeMap<_, _> = meshes
.iter()
.map(|mesh| (mesh.handle.slot(), mesh.handle.generation()))
.collect();
if current == self.published_mesh_generations {
return;
}
let Some(generations) = self.arrays.get_mut("mesh.generation") else {
return;
};
let Some(sequence) = generations.try_lock() else {
return;
};
for slot in self
.published_mesh_generations
.keys()
.filter(|slot| !current.contains_key(slot))
{
generations.data_word(*slot, 0).store(0, Ordering::Relaxed);
}
for mesh in meshes.iter().filter(|mesh| {
self.published_mesh_generations.get(&mesh.handle.slot())
!= Some(&mesh.handle.generation())
}) {
generations
.data_word(mesh.handle.slot(), 0)
.store(mesh.handle.generation(), Ordering::Relaxed);
}
generations.unlock(sequence);
self.published_mesh_generations = current;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn capacities(instances: u32) -> RenderDataCapacities {
RenderDataCapacities {
vertices: 0,
indices: 0,
meshes: 8,
instances,
}
}
#[test]
fn custom_layouts_are_aligned_idempotent_and_conflict_checked() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let request = ArrayRequest {
name: "instance.velocity".into(),
domain: ArrayDomain::Instance,
scalar: ScalarType::F32,
lanes: 3,
stride: None,
length: None,
};
let first = registry.allocate(request.clone(), capacities(8)).unwrap();
let second = registry.allocate(request, capacities(8)).unwrap();
assert_eq!(first, second);
assert_eq!((first.stride, first.capacity), (16, 8));
let conflict = ArrayRequest {
lanes: 4,
..serde_json::from_str::<ArrayRequest>(
r#"{"name":"instance.velocity","domain":"instance","scalar":"f32","lanes":3,"stride":null,"length":null}"#,
)
.unwrap()
};
assert_eq!(
registry.allocate(conflict, capacities(8)),
Err(SharedSoaError::LayoutConflict)
);
}
#[test]
fn domain_growth_replaces_layout_and_retains_old_storage() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let old = registry.arrays["instance.transform"].storage.as_ptr();
assert!(registry.sync_capacities(capacities(16)).unwrap());
let array = &registry.arrays["instance.transform"];
assert_ne!(old, array.storage.as_ptr());
assert_eq!(array.retired.len(), 1);
assert_eq!(array.descriptor().unwrap().capacity, 16);
}
#[test]
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(),
domain: ArrayDomain::Fixed,
scalar: ScalarType::U32,
lanes: 4,
stride: Some(16),
length: Some(length),
};
let first = registry.allocate(request(1), capacities(8)).unwrap();
let second = registry.allocate(request(2), capacities(8)).unwrap();
assert_eq!(first.id, second.id);
assert_eq!((second.length, second.capacity), (2, 2));
let array = registry.arrays.get_mut("upload.gltf").unwrap();
let sequence = array.try_lock().unwrap();
array
.data_word(0, 0)
.store(u32::from_le_bytes(*b"glTF"), 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"
);
}
#[test]
fn guarded_columns_ignore_stale_slot_writers() {
let mut registry = SharedSoaRegistry::new(capacities(8)).unwrap();
let handle = InstanceHandle::from_parts(2, 7);
let array = registry.arrays.get_mut("instance.transform").unwrap();
let descriptor = array.descriptor().unwrap();
assert_eq!(
(descriptor.stride, descriptor.generation_guard),
(80, Some(ArrayDomain::Instance))
);
array
.data_word(2, 0)
.store(1.0f32.to_bits(), Ordering::Relaxed);
array.data_word(2, 16).store(6, Ordering::Relaxed);
array.data_word(2, 17).store(1, Ordering::Relaxed);
array.word(9).store(2, Ordering::Release);
assert!(registry
.take_instance_words("instance.transform", &[handle])
.unwrap()
.is_empty());
let array = registry.arrays.get_mut("instance.transform").unwrap();
array
.data_word(2, 0)
.store(2.0f32.to_bits(), Ordering::Relaxed);
array.data_word(2, 16).store(7, Ordering::Relaxed);
array.data_word(2, 17).store(2, Ordering::Relaxed);
array.word(9).store(6, Ordering::Release);
let values = registry
.take_instance_words("instance.transform", &[handle])
.unwrap();
assert_eq!(values.len(), 1);
assert_eq!(f32::from_bits(values[0].1[0]), 2.0);
}
}
-115
View File
@@ -1,115 +0,0 @@
import { descriptors } from "./catalog.js";
const input = (node, socket) => [{ node, socket }];
const node = (id, key, parameters = {}, inputs = {}) => ({
id, state: "enabled", executor: { key, version: descriptors[key].version }, parameters, inputs,
});
const texture = (format, scale = 1, heightScale = scale, sampleCount = 1) => ({
texture: {
dimension: "d2", format,
extent: { kind: "surface_relative", width: { numerator: 1, denominator: scale }, height: { numerator: 1, denominator: heightScale }, depthOrArrayLayers: 1 },
mipLevelCount: 1, sampleCount, viewFormats: [],
},
residency: "transient",
});
const frameOut = (hdr, options = {}) => ({ surfaceFormat: "preferred", hdrEnabled: hdr, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1], ...options });
const predicates = (withCulling = false) => {
const result = [
node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }),
node("type_bits", "separate_u32_bits", { valueDefault: 0 }, { value: input("type_words", "word0") }),
node("ground_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit1")] }),
node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }),
node("standard_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit2"), ...input("not_double", "value")] }),
node("double_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit3")] }),
];
if (!withCulling) return { nodes: result, classes: { ground: "ground_class", pbr: "standard_class", pbr_double: "double_class" } };
result.push(
node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }),
node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }),
);
for (const id of ["ground_class", "standard_class", "double_class"])
result.find((item) => item.id === id).inputs.inputs.push(...input("not_culled", "value"));
return { nodes: result, classes: { ground: "ground_class", pbr: "standard_class", pbr_double: "double_class" } };
};
const scene = (colorTarget, clearColor = [0.015, 0.02, 0.03, 1], heightScale = 1, withCulling = false, sampleCount = 1) => {
const classification = predicates(withCulling);
const target = colorTarget || "hdr";
return [
...(!colorTarget ? [node("hdr", "texture", texture("rgba16_float", 1, heightScale, sampleCount))] : []),
node("scene_depth", "texture", texture("depth32_float", 1, heightScale, sampleCount)),
node("mesh", "mesh"),
...classification.nodes,
node("ground", "ground_plane", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.ground, "value"), color: input(target, "texture"), depth: input("scene_depth", "texture") }),
node("pbr", "gltf_standard", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr, "value"), color: input(target, "texture"), depth: input("scene_depth", "texture") }),
node("pbr_double", "gltf_standard_double_sided", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor, predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input(classification.classes.pbr_double, "value"), color: input(target, "texture"), depth: input("scene_depth", "texture") }),
];
};
const graph = (graphId, nodes) => Object.freeze({ schemaVersion: 3, graphId, revision: 4, nodes });
const direct = (graphId, clearColor) => graph(graphId, [
node("ldr", "texture", texture("rgba8_unorm")),
...scene("ldr", clearColor),
node("frame_out", "frame_out", frameOut(false), { color: input("pbr_double", "color") }),
]);
export const midnight = direct("preset_midnight", [0.015, 0.06, 0.18, 1]);
export const ember = direct("preset_ember", [0.18, 0.035, 0.012, 1]);
export const hdr = graph("preset_hdr_fullscreen", [
...scene(),
node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }),
]);
export const msaa = graph("preset_msaa", [
node("msaa_hdr", "texture", texture("rgba16_float", 1, 1, 4)),
...scene("msaa_hdr", undefined, 1, false, 4),
node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") }),
]);
export const culling = graph("preset_gpu_culling", (() => {
return [...scene(undefined, undefined, 1, true), node("frame_out", "frame_out", frameOut(true), { color: input("pbr_double", "color") })];
})());
const postPreset = (graphId, kind) => {
const nodes = [...scene()];
let source = "pbr_double";
if (kind === "edges") {
nodes.splice(0, 0, node("edge_hdr", "texture", texture("rgba16_float")));
nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") }));
source = "edges";
}
if (kind === "bloom" || kind === "combined") {
nodes.splice(0, 0,
node("half_a", "texture", texture("rgba16_float", 2)), node("half_b", "texture", texture("rgba16_float", 2)),
node("half_c", "texture", texture("rgba16_float", 2)), node("composite_hdr", "texture", texture("rgba16_float")));
nodes.push(
node("extract", "bloom_extract", { threshold: 1, knee: 0.5 }, { source: input("pbr_double", "color"), colorTarget: input("half_a", "texture") }),
node("blur_h", "bloom_blur", { direction: [1, 0], radius: 1 }, { source: input("extract", "color"), colorTarget: input("half_b", "texture") }),
node("blur_v", "bloom_blur", { direction: [0, 1], radius: 1 }, { source: input("blur_h", "color"), colorTarget: input("half_c", "texture") }),
node("composite", "bloom_composite", { intensity: 0.8 }, { source: input("pbr_double", "color"), bloom: input("blur_v", "color"), colorTarget: input("composite_hdr", "texture") }),
);
source = "composite";
if (kind === "combined") {
nodes.splice(0, 0, node("edge_hdr", "texture", texture("rgba16_float")));
nodes.push(node("edges", "luminance_edge", { strength: 2 }, { source: input(source, "color"), colorTarget: input("edge_hdr", "texture") }));
source = "edges";
}
}
nodes.push(node("frame_out", "frame_out", frameOut(true), { color: input(source, "color") }));
return graph(graphId, nodes);
};
export const tone = postPreset("preset_tone", "tone");
const displayPreset = (id, parameters, heightScale = 1) => graph(id, [
...scene(undefined, undefined, heightScale),
node("frame_out", "frame_out", parameters, { color: input("pbr_double", "color") }),
]);
export const contain = displayPreset("preset_contain", frameOut(false, { scaleMode: "contain", filter: "nearest", backgroundColor: [0.18, 0.18, 0.18, 0.25] }), 2);
export const reinhard = displayPreset("preset_reinhard", frameOut(true, { toneMapper: "reinhard", exposureStops: 2, scaleMode: "cover", filter: "nearest" }), 2);
export const linear = displayPreset("preset_linear", frameOut(true, { toneMapper: "none", exposureStops: 2, outputTransfer: "linear" }));
export const edges = postPreset("preset_edges", "edges");
export const bloom = postPreset("preset_bloom", "bloom");
export const combined = postPreset("preset_combined", "combined");
export const grading = graph("preset_grading", [
node("balance_hdr", "texture", texture("rgba16_float")), node("exposure_hdr", "texture", texture("rgba16_float")), node("saturation_hdr", "texture", texture("rgba16_float")), node("mixer_hdr", "texture", texture("rgba16_float")),
...scene(),
node("balance", "color_balance", { mode: "lift_gamma_gain", factor: 1, lift: 0, liftColor: [1,1,1,1], gamma: 1, gammaColor: [1,1,1,1], gain: 1, gainColor: [1,1,1,1], offset: 0, offsetColor: [1,1,1,1], power: 1, powerColor: [1,1,1,1], slope: 1, slopeColor: [1,1,1,1] }, { source: input("pbr_double", "color"), colorTarget: input("balance_hdr", "texture") }),
node("exposure", "exposure_contrast", { exposureStops: 0, contrast: 1, pivot: 0.18, factor: 1 }, { source: input("balance", "color"), colorTarget: input("exposure_hdr", "texture") }),
node("saturation", "saturation", { saturation: 1, factor: 1 }, { source: input("exposure", "color"), colorTarget: input("saturation_hdr", "texture") }),
node("mixer", "channel_mixer", { redOutput: [1,0,0], greenOutput: [0,1,0], blueOutput: [0,0,1], factor: 1 }, { source: input("saturation", "color"), colorTarget: input("mixer_hdr", "texture") }),
node("frame_out", "frame_out", frameOut(true), { color: input("mixer", "color") }),
]);
export const renderGraphPresets = Object.freeze({ midnight, ember, hdr, msaa, culling, tone, contain, reinhard, linear, grading, edges, bloom, combined });
BIN
View File
Binary file not shown.
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

+2 -2
View File
@@ -4,11 +4,11 @@ import {
addNodeItems,
moveAddNodeSelection,
searchAddNodeItems,
} from "../static/render-graph/add-node-menu.js";
} from "../examples/render-graph-studio/render-graph/add-node-menu.js";
import {
createNodeIdAllocator,
spawnRequestedNode,
} from "../static/render-graph/node-spawn.js";
} from "../examples/render-graph-studio/render-graph/node-spawn.js";
test("add-node model contains all final catalog types in application groups", () => {
assert.equal(addNodeItems.length, 44);
+187
View File
@@ -0,0 +1,187 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
animateInstance,
canonicalAstExample,
classifyInstance,
compileAndSwitch,
computePipelineExample,
connectFromWorker,
createInstance,
createVelocityColumn,
customRenderPipelineExample,
defaultPipelineExample,
dropActiveGraph,
fluentGraphExample,
fxNodeExportExample,
importGltf,
jsoGraphExample,
loadCompleteScene,
pickNearest,
setVelocity,
simpleSceneShader,
updateInstance,
} from "../examples/cookbook/index.js";
test("cookbook graph recipes produce canonical addon-owned ASTs", () => {
const canonical = canonicalAstExample();
assert.equal(canonical.ast.id, "shared_dag");
assert.equal(
(canonical.source.match(/\(ref "source" "value"\)/g) ?? []).length,
2,
);
assert.deepEqual(
[jsoGraphExample().id, fluentGraphExample().id],
["jso_graph", "fluent_graph"],
);
const fxnode = fxNodeExportExample();
assert.equal(fxnode.kind, "yawn-render-graph");
assert.equal(fxnode.pipelines.render.length, 4);
const defaults = defaultPipelineExample();
assert.deepEqual(
defaults.pipelines.render.map(({ name }) => name),
[
"ground_plane",
"gltf_standard",
"gltf_standard_double_sided",
"frame_out",
],
);
assert.equal(defaults.pipelines.compute.length, 1);
const custom = customRenderPipelineExample();
assert.equal(custom.pipelines.render[0].shader, simpleSceneShader);
assert.match(simpleSceneShader, /@vertex fn vertex_main/);
const compute = computePipelineExample().pipelines.compute[0];
assert.deepEqual(
[compute.entry, compute.dispatch],
["initialize", [4, 1, 1]],
);
});
test("cookbook graph lifecycle recipe serializes, switches, and drops", async () => {
const calls = [];
const core = {
compileGraph(source) {
calls.push(["compile", source]);
return Promise.resolve({ compiledId: [3, 4] });
},
switchCompiledGraph(id) {
calls.push(["switch", id]);
return Promise.resolve();
},
switchToImmediate() {
calls.push(["immediate"]);
return Promise.resolve();
},
dropCompiledGraph(id) {
calls.push(["drop", id]);
return Promise.resolve();
},
};
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]],
]);
});
test("cookbook mutation recipes use handles and SOA writes directly", async () => {
const calls = [];
const column = {
write(slot, values) {
calls.push(["velocity", slot, values]);
},
};
const core = {
allocateArray(layout) {
calls.push(["allocate", layout]);
return Promise.resolve(column);
},
setInstanceTransform(handle, transform) {
calls.push(["transform", handle, transform]);
},
setInstanceType(handle, words) {
calls.push(["type", handle, words]);
},
};
const instance = {
handle: [7, 2],
setTransform(transform) {
calls.push(["wrapped-transform", transform]);
},
setType(words) {
calls.push(["wrapped-type", words]);
},
};
const mesh = {
createInstance(transform) {
calls.push(["create", transform]);
return Promise.resolve(instance);
},
};
const transform = Array.from({ length: 16 }, (_, index) => index);
const words = Array(16).fill(1);
assert.equal(await createInstance(mesh, transform), instance);
updateInstance(instance, transform, words);
const velocity = await createVelocityColumn(core);
setVelocity(velocity, instance, [1, 2, 3]);
animateInstance(core, instance, transform);
classifyInstance(core, instance, words);
assert.deepEqual(calls.find(([name]) => name === "allocate")[1], {
name: "instance.velocity",
domain: "instance",
scalar: "f32",
lanes: 4,
});
assert.deepEqual(
calls.find(([name]) => name === "velocity"),
["velocity", 7, [1, 2, 3, 0]],
);
assert.ok(calls.some(([name]) => name === "transform"));
assert.ok(calls.some(([name]) => name === "type"));
});
test("cookbook picking and worker-to-worker recipes use public facades", async () => {
const picked = await pickNearest(
{
pickRay: async () => ({
epoch: 5,
hits: [{ instance: [9, 3], distance: 2 }],
}),
},
[0, 0, 0],
[0, 0, -1],
);
assert.deepEqual(picked.hits[0].instance.handle, [9, 3]);
class Port extends EventTarget {
postMessage() {}
start() {
queueMicrotask(() =>
this.dispatchEvent(
new MessageEvent("message", {
data: { type: "soa-init", arrays: [] },
}),
),
);
}
terminate() {}
}
const core = await connectFromWorker(new Port());
assert.equal(core.constructor.name, "YawnCore");
core.dispose();
assert.equal(typeof importGltf, "function");
assert.equal(typeof loadCompleteScene, "function");
});
+3 -4
View File
@@ -1,12 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, loadouts, LoadoutError } from "../static/demo-loadouts.js";
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, loadDemoLoadout, loadouts } from "../examples/render-graph-studio/demo-loadouts.js";
function parseGlb(buffer){const view=new DataView(buffer);assert.equal(view.getUint32(0,true),0x46546c67);assert.equal(view.getUint32(4,true),2);assert.equal(view.getUint32(8,true),buffer.byteLength);const length=view.getUint32(12,true);assert.equal(view.getUint32(16,true),0x4e4f534a);const json=JSON.parse(new TextDecoder().decode(new Uint8Array(buffer,20,length)).trim());const bin=20+length;assert.equal(view.getUint32(bin+4,true),0x004e4942);assert.equal(view.getUint32(bin,true),json.buffers[0].byteLength);return json;}
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,"Phase 6 deterministic PBR gallery");});
test("loadout dropdown preserves every demo scene and exposes the material gallery",async()=>{const html=await (await import("node:fs/promises")).readFile(new URL("../static/index.html",import.meta.url),"utf8");for(const id of ["cubes","spheres","materials","manor","sponza"])assert.match(html,new RegExp(`<option value="${id}">`));});
test("LFS pointers and HTTP failures are explicit and stable",async()=>{const pointer=new TextEncoder().encode("version https://git-lfs.github.com/spec/v1\noid sha256:abc\n").buffer;assert.equal(isGitLfsPointer(pointer),true);await assert.rejects(loadDemoLoadout("manor",{fetchImpl:async()=>({ok:true,arrayBuffer:async()=>pointer})}),e=>e instanceof LoadoutError&&e.code==="LOADOUT_LFS_POINTER");await assert.rejects(loadDemoLoadout("sponza",{fetchImpl:async()=>({ok:false,status:404})}),e=>e.code==="LOADOUT_HTTP"&&/404/.test(e.message));});
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("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/)});
+1 -1
View File
@@ -5,7 +5,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { build } from "vite";
import { fxNodeComposition } from "../static/render-graph/catalog.js";
import { fxNodeComposition } from "@yawn/render-graph-fxnode/catalog";
test("production render graph composition passes fxnode's public validator", async () => {
const directory = await mkdtemp(
+71
View File
@@ -0,0 +1,71 @@
import test from "node:test";
import assert from "node:assert/strict";
import { GltfImporter } from "@yawn/gltf-import";
import { writeSharedUpload } from "../addons/gltf-import/src/shared-upload.js";
class WorkerMock extends EventTarget {
messages = [];
terminated = false;
postMessage(message) { this.messages.push(message); }
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
terminate() { this.terminated = true; }
}
const tick = () => new Promise(resolve => setImmediate(resolve));
test("glTF addon stages fetched bytes in shared SOA and commits only metadata", async () => {
const memory = new SharedArrayBuffer(4096);
const descriptor = {
id: 9,
name: "upload.gltf",
domain: "fixed",
scalar: "u32",
lanes: 4,
stride: 16,
length: 2,
capacity: 2,
controlPtr: 0,
dataOffset: 64,
byteLength: 32,
layoutEpoch: 1,
writable: true,
};
new Int32Array(memory, 0, 16).set([0x414f5359, 1, 9, 1, 4, 4, 2, 2, 3]);
const array = { share: () => ({ buffer: memory, descriptor }) };
const calls = [];
const core = {
async allocateArray(layout) { calls.push(["allocate", layout]); return array; },
async commitGlbUpload(value, byteLength, options) {
calls.push(["commit", value, byteLength, options]);
return { meshes: [] };
},
};
const worker = new WorkerMock();
const importer = new GltfImporter(core, { workerFactory: () => worker });
const loading = importer.load("https://example.test/scene.glb", { framing: "interior" });
await tick();
assert.deepEqual(worker.messages[0], {
type: "load",
request: 1,
url: "https://example.test/scene.glb",
});
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,
}]);
assert.equal(worker.messages[1].buffer, memory);
assert.equal(worker.messages[1].descriptor, descriptor);
assert.equal(Object.hasOwn(worker.messages[1], "bytes"), false);
const bytes = Uint8Array.from({ length: 20 }, (_, index) => index + 1);
writeSharedUpload(memory, descriptor, bytes);
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" }]);
importer.dispose();
assert.equal(worker.terminated, true);
});
+4 -4
View File
@@ -1,10 +1,10 @@
import test from "node:test";
import assert from "node:assert/strict";
import { culling } from "../static/render-graph/presets.js";
import { culling } from "../examples/render-graph-studio/render-graph/presets.js";
import {
CATALOG_VERSION, GRAPH_ID, semanticCatalog, nodeDefinitions, descriptors, socketTypes,
} from "../static/render-graph/catalog.js";
import { adaptFxNodeSnapshot, mapAuthoringDiagnostic } from "../static/render-graph/adapter.js";
} from "@yawn/render-graph-fxnode/catalog";
import { adaptFxNodeSnapshot, mapAuthoringDiagnostic } from "@yawn/render-graph-fxnode";
const authoredNode = (id, typeId) => {
const definition = nodeDefinitions[typeId];
@@ -123,7 +123,7 @@ test("adapter preserves ordered multisocket links and indexed diagnostics", () =
};
const graph = adaptFxNodeSnapshot(raw, 2);
const targetIndex = graph.nodes.findIndex((node) => node.id === "target");
assert.equal(graph.schemaVersion, 3);
assert.deepEqual([graph.kind, graph.version, graph.id], ["yawn-render-graph", 1, GRAPH_ID]);
assert.deepEqual(graph.nodes[targetIndex].inputs.inputs, [
{ node: "source_a", socket: "value" },
{ node: "source_b", socket: "value" },
+90
View File
@@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
GraphAstError,
createGraphAst,
reference,
serializeGraphAst,
} from "@yawn/render-graph-ast";
import { RenderGraph, graphFromObject, loadGraph } from "@yawn/render-graph-js";
import { defaultPipelines } from "@yawn/default-pipelines";
const node = (id, inputs = {}) => ({
id,
state: "enabled",
executor: { key: "and", version: 2 },
parameters: {},
inputs,
});
test("JSO and builder frontends export the same canonical AST", () => {
const description = {
id: "shared_dag",
revision: 3,
pipelines: {
compute: [{
name: "prepare",
shader: "@compute @workgroup_size(1) fn main() {}",
entry: "main",
dispatch: [1, 1, 1],
}],
},
nodes: [
node("source"),
node("left", { inputs: [reference("source", "value")] }),
node("right", { inputs: [reference("source", "value")] }),
],
};
const objectAst = graphFromObject(description);
const builderAst = new RenderGraph("shared_dag", 3)
.computePipeline(description.pipelines.compute[0])
.node("source", "and", { version: 2 })
.node("left", "and", { version: 2, inputs: description.nodes[1].inputs })
.node("right", "and", { version: 2, inputs: description.nodes[2].inputs })
.ast();
assert.deepEqual(builderAst, objectAst);
const source = serializeGraphAst(objectAst);
assert.equal((source.match(/\(ref "source" "value"\)/g) ?? []).length, 2);
assert.match(source, /^\(yawn-graph 1/);
assert.equal(source.trimEnd().endsWith("))"), true);
});
test("canonical AST is immutable and rejects duplicate declarations", () => {
const ast = createGraphAst({ id: "immutable", revision: 1, nodes: [] });
assert.equal(Object.isFrozen(ast), true);
assert.equal(Object.isFrozen(ast.nodes), true);
assert.throws(
() => createGraphAst({
id: "duplicates",
revision: 1,
pipelines: {
render: [{ name: "same", shader: "", vertexEntry: "vs_main", fragmentEntry: "fs_main" }],
compute: [{ name: "same", shader: "", entry: "main", dispatch: [1, 1, 1] }],
},
nodes: [],
}),
error => error instanceof GraphAstError && error.code === "AST_PIPELINE_DUPLICATE",
);
});
test("JSO addon owns AST serialization and graph loading", async () => {
const calls = [];
const core = { compileGraph(source) { calls.push(source); return Promise.resolve({ compiledId: [1, 2] }); } };
const description = { id: "loaded", revision: 1, nodes: [] };
assert.deepEqual(await loadGraph(core, description), { compiledId: [1, 2] });
assert.match(calls[0], /^\(yawn-graph 1/);
await new RenderGraph("builder", 1).load(core);
assert.match(calls[1], /\(id "builder"\)/);
});
test("optional pipelines carry every shader and compute declaration outside core", () => {
assert.deepEqual(defaultPipelines.render.map(({ name }) => name), [
"ground_plane", "gltf_standard", "gltf_standard_double_sided", "frame_out",
]);
assert.match(defaultPipelines.render[1].shader, /@vertex/);
assert.match(defaultPipelines.render[3].shader, /@fragment/);
assert.match(defaultPipelines.compute[0].shader, /@compute/);
assert.deepEqual(defaultPipelines.compute[0].dispatch, [1, 1, 1]);
});
+11 -13
View File
@@ -1,23 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as presets from "../static/render-graph/presets.js";
import { descriptors } from "../static/render-graph/catalog.js";
import * as presets from "../examples/render-graph-studio/render-graph/presets.js";
import { descriptors } from "@yawn/render-graph-fxnode/catalog";
test("all presets use current schemas, versions, and one frame output", () => {
assert.equal(Object.keys(presets.renderGraphPresets).length, 13);
test("the JSO example is a complete canonical AST with external pipelines", () => {
assert.deepEqual(Object.keys(presets.renderGraphPresets), ["jso"]);
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
assert.deepEqual([graph.schemaVersion, graph.revision], [3, 4], name);
assert.deepEqual([graph.kind, graph.version, graph.revision], ["yawn-render-graph", 1, 1], name);
assert.equal(new Set(graph.nodes.map((node) => node.id)).size, graph.nodes.length, name);
assert.equal(graph.nodes.filter((node) => node.executor.key === "frame_out").length, 1, name);
for (const node of graph.nodes)
assert.equal(node.executor.version, descriptors[node.executor.key].version, `${name}:${node.id}`);
assert.deepEqual(graph.pipelines.render.map(({ name }) => name), [
"ground_plane", "gltf_standard", "gltf_standard_double_sided", "frame_out",
]);
assert.deepEqual(graph.pipelines.compute.map(({ name }) => name), ["initialize_scene"]);
}
const authoredX4 = Object.entries(presets.renderGraphPresets).flatMap(([name, graph]) =>
graph.nodes.filter((node) => node.parameters?.texture?.sampleCount === 4)
.map((node) => `${name}:${node.id}`));
assert.deepEqual(authoredX4, ["msaa:msaa_hdr", "msaa:scene_depth"]);
assert.equal(typeof presets.msaa.nodes.find((node) => node.id === "msaa_hdr")
.parameters.texture.sampleCount, "number");
});
test("presets classify demo-owned enable and material bits through type.words[0] predicates", () => {
@@ -35,7 +33,7 @@ test("presets classify demo-owned enable and material bits through type.words[0]
}
});
test("culling adds a local-AABB expression to each material predicate", () => {
test("the example adds a local-AABB expression to each material predicate", () => {
const byId = Object.fromEntries(presets.culling.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.cull.inputs, {
mesh: [{ node: "mesh", socket: "mesh" }], localAabb: [{ node: "mesh", socket: "localAabb" }],
@@ -45,7 +43,7 @@ test("culling adds a local-AABB expression to each material predicate", () => {
assert.equal(byId[id].inputs.inputs.at(-1).node, "not_culled");
});
test("scene pipelines directly share matching explicit color and depth targets", () => {
test("scene pipelines directly share matching transient color and depth targets", () => {
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node]));
const color = byId.ground.inputs.color;
-145
View File
@@ -1,145 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import * as rendererModule from "../static/renderer-client.js";
const { RendererClient, RendererError } = rendererModule;
const TYPE = [0,1,2,4,8,16,32,64,128,256,512,1024,2048,4096,0x80000000,0xffffffff];
class WorkerMock extends EventTarget {
messages=[]; transfers=[]; terminated=false;
postMessage(message, transfer=[]) { this.messages.push(message); this.transfers.push(transfer); }
terminate(){this.terminated=true;}
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
}
function fixture() {
const memory = new WebAssembly.Memory({initial:4, maximum:8, shared:true});
const header = new Int32Array(memory.buffer, 0, 16);
header.set([0x4e574159,2,1024,40,0,0]);
const worker = new WorkerMock();
const bridge = {memory,ringPtr:0,worker,freed:false,free(){this.freed=true;}};
const client = new RendererClient(bridge);
return {memory,header,worker,bridge,client};
}
async function imported(f) {
const loading=f.client.replaceSceneGlb(new ArrayBuffer(8));
f.worker.reply({type:"payload-ready",id:1});
await Promise.resolve();
f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[{handle:[7,3],defaultInstance:[8,5],defaultType:[1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]}]}});
return (await loading)[0];
}
test("replaceSceneGlb is opcode 1", async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8));f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.equal(new Int32Array(f.memory.buffer,64,40)[1],1);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});assert.deepEqual(await pending,[]);});
test("scene replacement carries the framing mode in opcode 1",async()=>{const f=fixture(),pending=f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"interior"});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();assert.deepEqual([...new Int32Array(f.memory.buffer,64,40).slice(1,5)],[1,1,1,1]);f.worker.reply({type:"reply",request:1,ok:true,result:{meshes:[]}});await pending;await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8),{framing:"bad"}),TypeError)});
test("writes tagged fixed-slot protocol and resolves reply", async () => {
const f=fixture(); const mesh=await imported(f);
assert.equal(mesh.setVisible,undefined);
assert.equal(mesh.setType,undefined);
assert.equal(typeof mesh.defaultInstance.setType,"function");
const pending=mesh.defaultInstance.setType(TYPE);
const {memory,header,worker}=f;
assert.equal(Atomics.load(header,5),2);
const slot=new Int32Array(memory.buffer,64+160,40);
assert.deepEqual([...slot.slice(0,21)].map(x=>x>>>0),[2,10,2,8,5,...TYPE]);
worker.reply({type:"reply",request:2,ok:true,code:"OK"}); await pending;
});
test("maps stable errors and gates destroyed instances", async () => {
const f=fixture(), mesh=await imported(f); const {worker}=f;
const creating=mesh.createInstance(new Float32Array([1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]),{type:TYPE});
worker.reply({type:"reply",request:2,ok:true,result:[4,2]}); const instance=await creating;
assert.equal(instance.setVisible,undefined);
const destroying=instance.destroy(); worker.reply({type:"reply",request:3,ok:true}); await destroying;
assert.throws(()=>instance.setType(TYPE), error=>error instanceof RendererError&&error.code==="STALE_HANDLE");
});
test("rejects protocol mismatch", () => {
const {memory,worker}=fixture(); new Int32Array(memory.buffer)[1]=1;
assert.throws(()=>new RendererClient({memory,ringPtr:0,worker}), /PROTOCOL_MISMATCH/);
});
test("pending reply exists before ring publication", async () => {
const f=fixture(), mesh=await imported(f); const {worker}=f;
const pending=mesh.defaultInstance.setType(TYPE);
worker.reply({type:"reply",request:2,ok:true});
await pending;
});
test("profile snapshots have a dedicated getter", () => {
const f=fixture(), snapshot={type:"profile-snapshot",available:true,epoch:3,passes:{forward:1.25}};
const dispatch=globalThis.dispatchEvent; globalThis.dispatchEvent=()=>true;
try { f.worker.reply(snapshot); assert.strictEqual(f.client.profile,snapshot); }
finally { globalThis.dispatchEvent=dispatch; f.client.dispose(); }
});
test("worker failures and dispose reject every pending operation", async () => {
const f=fixture(), mesh=await imported(f); const {worker,client,bridge}=f;
const a=mesh.defaultInstance.setType(TYPE), b=mesh.defaultInstance.setType([...TYPE].reverse());
worker.dispatchEvent(new Event("error"));
await assert.rejects(a,/WORKER_ERROR/); await assert.rejects(b,/WORKER_ERROR/);
client.dispose(); assert.equal(worker.terminated,true); assert.equal(bridge.freed,true);
});
test("import always releases staged payload when ring is full", async () => {
const {header,worker,client}=fixture(); Atomics.store(header,5,1024);
const loading=client.replaceSceneGlb(new ArrayBuffer(8));
worker.reply({type:"payload-ready",id:1});
await assert.rejects(loading,/RING_FULL/);
assert.equal(worker.messages.at(-1).type,"payload-release");
});
test("does not export handle constructors or internal mutation methods", () => {
const {client}=fixture();
assert.equal(rendererModule.VISIBLE,undefined);
assert.equal(rendererModule.Mesh,undefined);
assert.equal(rendererModule.Instance,undefined);
assert.equal(client._meshFlags,undefined);
assert.equal(client._createInstance,undefined);
});
test("corrupt backlog closes and terminates the transport", async () => {
const f=fixture(); Atomics.store(f.header,5,1025);
const loading=f.client.replaceSceneGlb(new ArrayBuffer(8));
// Payload staging must first acknowledge before enqueue sees corruption.
f.worker.reply({type:"payload-ready",id:1});
await assert.rejects(loading,/RING_CORRUPT/);
assert.equal(Atomics.load(f.header,6),1);
assert.equal(f.worker.terminated,true);
});
test("import rejects immediately after disposal", async () => {
const f=fixture();
f.client.dispose();
await assert.rejects(f.client.replaceSceneGlb(new ArrayBuffer(8)),/DISPOSED/);
assert.equal(f.worker.messages.length,0);
});
test("import rejects when disposed during asynchronous source loading", async () => {
const f=fixture();
const originalFetch=globalThis.fetch;
let finishFetch;
globalThis.fetch=()=>new Promise(resolve=>{finishFetch=resolve;});
try {
const loading=f.client.replaceSceneGlb("model.glb");
f.client.dispose();
finishFetch({arrayBuffer:async()=>new ArrayBuffer(8)});
await assert.rejects(loading,/DISPOSED/);
assert.equal(f.worker.messages.length,0);
} finally {
globalThis.fetch=originalFetch;
}
});
test("compile transfers payload and waits for ready before opcode 7", async()=>{
const f=fixture(), pending=f.client.compileGraph({schemaVersion:2});
assert.equal(f.worker.transfers[0].length,1); assert.equal(Atomics.load(f.header,5),0);
f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve();
assert.equal(new Int32Array(f.memory.buffer,64,40)[1],7);
f.worker.reply({type:"reply",request:1,ok:true,result:{compiledId:[2,3]}});
assert.deepEqual(await pending,{compiledId:[2,3]});
});
test("flat error reply preserves structured details", async()=>{
const f=fixture(), pending=f.client.compileGraph({}); f.worker.reply({type:"payload-ready",id:1}); await Promise.resolve();
f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_INVALID_ID",details:{message:"bad",path:"graphId"}});
await assert.rejects(pending,e=>e instanceof RendererError&&e.code==="GRAPH_INVALID_ID"&&e.details.path==="graphId"&&e.message==="bad");
});
test("compile releases payload after success", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:true,result:{}});await p;assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("compile releases payload after backend error", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();f.worker.reply({type:"reply",request:1,ok:false,code:"X",details:{message:"x"}});await assert.rejects(p);assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("compile rejects circular and BigInt JSON", async()=>{const f=fixture(),x={};x.x=x;await assert.rejects(f.client.compileGraph(x),/circular/i);await assert.rejects(f.client.compileGraph({x:1n}),e=>e.code==="GRAPH_JSON_INVALID");});
test("compile rejects oversized encoding", async()=>{const f=fixture();await assert.rejects(f.client.compileGraph({x:"x".repeat(1024*1024)}),e=>e.code==="GRAPH_PAYLOAD_TOO_LARGE");assert.equal(f.worker.messages.length,0);});
test("drop graph emits opcode 8 and validates id", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([9,4]);const slot=new Int32Array(f.memory.buffer,64,40);assert.deepEqual([...slot.slice(1,5)],[8,1,9,4]);f.worker.reply({type:"reply",request:1,ok:true});await p;assert.throws(()=>f.client.dropCompiledGraph([1]),TypeError);});
test("ring-full graph compile releases staged payload", async()=>{const f=fixture();Atomics.store(f.header,5,1024);const p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await assert.rejects(p,e=>e.code==="RING_FULL");assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("disposal while graph payload is pending releases and rejects", async()=>{const f=fixture(),p=f.client.compileGraph({});f.client.dispose();await assert.rejects(p,e=>e.code==="DISPOSED");assert.equal(f.worker.messages.at(-1).type,"payload-release");});
test("payload transfer uses the exact encoded ArrayBuffer", async()=>{const f=fixture(),graph={schemaVersion:2};const expected=new TextEncoder().encode(JSON.stringify(graph));const p=f.client.compileGraph(graph);const transferred=f.worker.transfers[0][0];assert.strictEqual(f.worker.messages[0].buffer,transferred);assert.deepEqual(new Uint8Array(transferred),expected);f.client.dispose();await assert.rejects(p);});
test("cycle error details are preserved exactly", async()=>{const f=fixture(),p=f.client.compileGraph({});f.worker.reply({type:"payload-ready",id:1});await Promise.resolve();const details={message:"cycle",kind:"cycle",edges:[{from:"a",resource:{id:"r",version:0},to:"b"}]};f.worker.reply({type:"reply",request:1,ok:false,code:"GRAPH_CYCLE",details});await assert.rejects(p,e=>e.details===details&&e.details.edges[0].from==="a");});
test("error without details leaves details undefined", async()=>{const f=fixture(),p=f.client.dropCompiledGraph([1,1]);f.worker.reply({type:"reply",request:1,ok:false,code:"STALE_GRAPH_ID"});await assert.rejects(p,e=>e instanceof RendererError&&e.details===undefined&&e.message==="STALE_GRAPH_ID");});
test("switch methods emit exact opcode 9 words", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([9,4]);assert.deepEqual([...new Int32Array(f.memory.buffer,64,40).slice(1,7)],[9,1,1,9,4,0]);f.worker.reply({type:"reply",request:1,ok:true});await a;await new Promise(queueMicrotask);const b=f.client.switchToImmediate();assert.deepEqual([...new Int32Array(f.memory.buffer,224,40).slice(1,7)],[9,2,0,0,0,0]);f.worker.reply({type:"reply",request:2,ok:true});await b;assert.throws(()=>f.client.switchCompiledGraph([0,0]),TypeError);});
test("graph lifecycle FIFO recovers after failure", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();assert.equal(Atomics.load(f.header,5),1);f.worker.reply({type:"reply",request:1,ok:false,code:"X"});await assert.rejects(a);await new Promise(queueMicrotask);assert.equal(Atomics.load(f.header,5),2);f.worker.reply({type:"reply",request:2,ok:true});await b;});
test("dispose rejects queued graph lifecycle calls", async()=>{const f=fixture();const a=f.client.switchCompiledGraph([1,1]),b=f.client.switchToImmediate();f.client.dispose();await assert.rejects(a,/DISPOSED/);await assert.rejects(b,/DISPOSED/);});
+9 -8
View File
@@ -1,8 +1,8 @@
import test from "node:test";
import assert from "node:assert/strict";
import { SnapshotReader, SnapshotProtocolError } from "../static/render-data-snapshot.js";
import { DerivedBvh } from "../static/bvh-core.js";
import { RendererClient } from "../static/renderer-client.js";
import { SnapshotReader, SnapshotProtocolError } from "../packages/yawn-core/src/snapshot.js";
import { DerivedBvh } from "../addons/mesh-handles/src/bvh-core.js";
import { YawnCore } from "../packages/yawn-core/src/index.js";
const align16 = value => (value + 15) & ~15;
const componentCounts = [1, 1, 3, 3, 1, 1, 1, 1, 16, 3, 3, 16];
@@ -108,14 +108,16 @@ class WorkerMock extends EventTarget {
reply(data) { this.dispatchEvent(new MessageEvent("message", { data })); }
}
test("renderer pick returns instance metadata handles and exact epoch", async () => {
test("core picking returns protocol handles and 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, workerFactory: () => bvhWorker, free() {} };
const client = new RendererClient(bridge);
const bridge = { memory: scene.memory, ringPtr: ring, worker: rendererWorker, pickingWorkerFactory: () => bvhWorker, free() {} };
const client = new YawnCore(bridge);
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]);
@@ -124,8 +126,7 @@ test("renderer pick returns instance metadata handles and exact epoch", async ()
const result = await picking;
assert.equal(result.epoch, 1);
assert.equal(result.hits[0].distance, 2);
assert.equal(typeof result.hits[0].instance.setType, "function");
assert.equal(result.hits[0].instance.setVisible, undefined);
assert.deepEqual(result.hits[0].instance, [10, 3]);
client.dispose();
assert.equal(bvhWorker.terminated, true);
});
+241
View File
@@ -0,0 +1,241 @@
import test from "node:test";
import assert from "node:assert/strict";
import { YawnCore, RendererError } from "@yawn/core";
import { MeshHandles } from "@yawn/mesh-handles";
import { createGraphAst, serializeGraphAst } from "@yawn/render-graph-ast";
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];
class WorkerMock extends EventTarget {
messages = [];
transfers = [];
terminated = false;
postMessage(message, transfer = []) {
this.messages.push(message);
this.transfers.push(transfer);
}
terminate() {
this.terminated = true;
}
reply(data) {
this.dispatchEvent(new MessageEvent("message", { data }));
}
}
function installArray(memory, descriptor) {
const control = new Int32Array(memory.buffer, descriptor.controlPtr, 16);
control.set([
0x414f5359,
1,
descriptor.id,
{ u32: 1, i32: 2, f32: 3 }[descriptor.scalar],
descriptor.lanes,
descriptor.stride / 4,
descriptor.length,
descriptor.capacity,
{ mesh: 1, instance: 2, fixed: 3 }[descriptor.domain],
0,
descriptor.layoutEpoch,
]);
return descriptor;
}
function setup() {
const memory = new WebAssembly.Memory({ initial: 8, maximum: 16, shared: true });
const ring = new Int32Array(memory.buffer, 0, 16);
ring.set([0x4e574159, 2, 1024, 40, 0, 0]);
const transform = installArray(memory, {
id: 1, name: "instance.transform", domain: "instance", scalar: "f32", lanes: 16,
stride: 80, length: 16, capacity: 16, controlPtr: 196608, dataOffset: 64,
byteLength: 1280, layoutEpoch: 1, writable: true, generationGuard: "instance",
});
const type = installArray(memory, {
id: 2, name: "instance.type", domain: "instance", scalar: "u32", lanes: 16,
stride: 80, length: 16, capacity: 16, controlPtr: 198016, dataOffset: 64,
byteLength: 1280, layoutEpoch: 1, writable: true, generationGuard: "instance",
});
const generation = installArray(memory, {
id: 3, name: "instance.generation", domain: "instance", scalar: "u32", lanes: 1,
stride: 16, length: 16, capacity: 16, controlPtr: 199424, dataOffset: 64,
byteLength: 256, layoutEpoch: 1, writable: false,
});
const meshGeneration = installArray(memory, {
id: 4, name: "mesh.generation", domain: "mesh", scalar: "u32", lanes: 1,
stride: 16, length: 16, capacity: 16, controlPtr: 199744, dataOffset: 64,
byteLength: 256, layoutEpoch: 1, writable: false,
});
const upload = installArray(memory, {
id: 5, name: "upload.gltf", domain: "fixed", scalar: "u32", lanes: 4,
stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64,
byteLength: 256, layoutEpoch: 1, writable: true,
});
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 };
}
async function imported(fixture) {
const loading = fixture.core.commitGlbUpload(fixture.core.array("upload.gltf"), 8);
fixture.worker.reply({
type: "reply",
request: 1,
ok: true,
result: {
meshes: [{
handle: [7, 3],
defaultInstance: [8, 5],
defaultType: TYPE,
}],
},
});
const [mesh] = fixture.handles.fromImportedScene(await loading);
const generations = new Int32Array(
fixture.memory.buffer,
fixture.generation.controlPtr + fixture.generation.dataOffset,
fixture.generation.byteLength / 4,
);
Atomics.store(generations, 8 * (fixture.generation.stride / 4), 5);
return mesh;
}
test("core commits shared scene uploads 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]);
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { meshes: [] } });
assert.deepEqual(await pending, { meshes: [] });
});
test("mesh handles are a separate conventional facade over core commands", async () => {
const fixture = setup();
const mesh = await imported(fixture);
assert.deepEqual(mesh.handle, [7, 3]);
assert.deepEqual(mesh.defaultInstance.handle, [8, 5]);
const creating = mesh.createInstance(IDENTITY, { type: TYPE });
const slot = new Int32Array(fixture.memory.buffer, 64 + 160, 40);
assert.equal(slot[1], 3);
fixture.worker.reply({ type: "reply", request: 2, ok: true, result: [4, 2] });
const instance = await creating;
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);
const before = Atomics.load(fixture.ring, 5);
mesh.defaultInstance.setType(TYPE);
mesh.defaultInstance.setTransform(IDENTITY);
assert.equal(Atomics.load(fixture.ring, 5), before);
const type = new Int32Array(
fixture.memory.buffer,
fixture.type.controlPtr + fixture.type.dataOffset,
fixture.type.byteLength / 4,
);
const base = 8 * (fixture.type.stride / 4);
assert.deepEqual([...type.slice(base, base + 16)].map(value => value >>> 0), TYPE);
assert.equal(Atomics.load(type, base + 16) >>> 0, 5);
assert.equal(Atomics.load(type, base + 17) >>> 0, 1);
});
test("generation columns reject stale handles and are read-only", async () => {
const fixture = setup();
const mesh = await imported(fixture);
const generations = new Int32Array(
fixture.memory.buffer,
fixture.generation.controlPtr + fixture.generation.dataOffset,
fixture.generation.byteLength / 4,
);
Atomics.store(generations, 8 * (fixture.generation.stride / 4), 6);
assert.throws(() => mesh.defaultInstance.setTransform(IDENTITY), error => error.code === "STALE_HANDLE");
assert.throws(() => fixture.core.array("instance.generation").write(8, [6]), error => error.code === "SOA_READ_ONLY");
});
test("custom SOA columns are allocated through the payload command", async () => {
const fixture = setup();
const pending = fixture.core.allocateArray({
name: "instance.velocity", domain: "instance", scalar: "f32", lanes: 4,
});
await Promise.resolve();
fixture.worker.reply({ type: "payload-ready", id: 1 });
await Promise.resolve();
assert.equal(new Int32Array(fixture.memory.buffer, 64, 40)[1], 11);
const descriptor = installArray(fixture.memory, {
id: 5, name: "instance.velocity", domain: "instance", scalar: "f32", lanes: 4,
stride: 16, length: 16, capacity: 16, controlPtr: 200064, dataOffset: 64,
byteLength: 256, layoutEpoch: 1, writable: true,
});
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: descriptor });
const array = await pending;
array.write(3, [1, 2, 3, 4]);
assert.deepEqual(array.read(3), [1, 2, 3, 4]);
});
test("graph compilation transfers canonical S-expressions, including pipeline declarations", async () => {
const fixture = setup();
const graph = createGraphAst({
id: "compute_graph",
revision: 1,
pipelines: {
compute: [{
name: "prepare",
shader: "@compute @workgroup_size(1) fn main() {}",
entry: "main",
dispatch: [1, 2, 3],
}],
},
nodes: [],
});
const pending = fixture.core.compileGraph(serializeGraphAst(graph));
const payload = fixture.worker.messages[0];
assert.match(new TextDecoder().decode(payload.buffer), /^\(yawn-graph 1/);
assert.match(new TextDecoder().decode(payload.buffer), /\"dispatch\" \(array 1 2 3\)/);
fixture.worker.reply({ type: "payload-ready", id: 1 });
await Promise.resolve();
fixture.worker.reply({ type: "reply", request: 1, ok: true, result: { compiledId: [2, 3] } });
assert.deepEqual(await pending, { compiledId: [2, 3] });
});
test("graph lifecycle remains FIFO and uses opcodes 8 and 9", async () => {
const fixture = setup();
const first = fixture.core.switchCompiledGraph([9, 4]);
const second = fixture.core.dropCompiledGraph([2, 1]);
assert.equal(Atomics.load(fixture.ring, 5), 1);
fixture.worker.reply({ type: "reply", request: 1, ok: true });
await first;
await new Promise(queueMicrotask);
assert.equal(Atomics.load(fixture.ring, 5), 2);
const secondSlot = new Int32Array(fixture.memory.buffer, 64 + 160, 40);
assert.equal(secondSlot[1], 8);
fixture.worker.reply({ type: "reply", request: 2, ok: true });
await second;
});
test("transport failures reject pending work and dispose owned resources", async () => {
const fixture = setup();
const pending = fixture.core.dropCompiledGraph([1, 1]);
fixture.worker.dispatchEvent(new Event("error"));
await assert.rejects(pending, error => error instanceof RendererError && error.code === "WORKER_ERROR");
assert.equal(fixture.worker.terminated, true);
assert.equal(fixture.bridge.freed, true);
});

Some files were not shown because too many files have changed in this diff Show More