diff --git a/.agents/setup b/.agents/setup index a800549..e6ddab9 100755 --- a/.agents/setup +++ b/.agents/setup @@ -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 diff --git a/README.md b/README.md index cbc7843..7005141 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/addons/default-pipelines/package.json b/addons/default-pipelines/package.json new file mode 100644 index 0000000..c115260 --- /dev/null +++ b/addons/default-pipelines/package.json @@ -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" +} diff --git a/addons/default-pipelines/src/index.js b/addons/default-pipelines/src/index.js new file mode 100644 index 0000000..f8e8055 --- /dev/null +++ b/addons/default-pipelines/src/index.js @@ -0,0 +1,86 @@ +export const gltfShader = /* wgsl */ ` +struct UniformData { mouse_move: vec2, mouse_click: vec2, resolution: vec2, time: f32, _padding0: f32, camera_position: vec4 } +struct MaterialData { base_color_factor: vec4, emissive_factor: vec4, surface_factors: vec4, alpha_optics: vec4, flags: vec4, uv_sets: vec4, debug_extras: vec4 } +@group(0) @binding(0) var uni: UniformData; +@group(1) @binding(0) var view_proj: mat4x4; +@group(2) @binding(0) var material: MaterialData; +@group(2) @binding(1) var base_tex: texture_2d; +@group(2) @binding(2) var mr_tex: texture_2d; +@group(2) @binding(3) var normal_tex: texture_2d; +@group(2) @binding(4) var occlusion_tex: texture_2d; +@group(2) @binding(5) var emissive_tex: texture_2d; +@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, @location(1) normal: vec3, @location(2) uv: vec2, @location(3) model_col0: vec4, @location(4) model_col1: vec4, @location(5) model_col2: vec4, @location(6) model_col3: vec4, @location(7) normal_col0: vec4, @location(8) normal_col1: vec4, @location(9) normal_col2: vec4, @location(10) tangent: vec4 } +struct VertexOutput { @builtin(position) clip_position: vec4, @location(0) world_pos: vec3, @location(1) normal: vec3, @location(2) tangent: vec3, @location(3) bitangent: vec3, @location(4) uv: vec2, @location(5) @interpolate(flat) determinant_sign: f32 } +fn safe_normalize(v: vec3, fallback: vec3) -> vec3 { 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(in.model_col0, in.model_col1, in.model_col2, in.model_col3); + let linear = mat3x3(in.model_col0.xyz, in.model_col1.xyz, in.model_col2.xyz); let nm = mat3x3(in.normal_col0.xyz, in.normal_col1.xyz, in.normal_col2.xyz); + let world = model * vec4(in.pos, 1.0); let n = safe_normalize(nm * in.normal, vec3(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(0,1,0), vec3(1,0,0), abs(n.x) < 0.9), n); } t = safe_normalize(t, vec3(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(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, mr: vec2, normal_map: vec3, ao: f32, emissive: vec3 } +fn sample_closure(uv: vec2) -> Closure { + let bits = material.flags.x; var c: Closure; + c.base = material.base_color_factor * select(vec4(1), textureSample(base_tex, base_sampler, uv), (bits & 1u) != 0u); + let mr = select(vec4(1), textureSample(mr_tex, mr_sampler, uv), (bits & 2u) != 0u); c.mr = vec2(clamp(material.surface_factors.x * mr.b,0,1), clamp(material.surface_factors.y * mr.g,0.045,1)); + c.normal_map = select(vec3(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(1), textureSample(emissive_tex, emissive_sampler, uv).rgb, (bits & 16u) != 0u); return c; +} +fn schlick(f0: vec3, v_h: f32) -> vec3 { return f0 + (vec3(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 { + 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(in.tangent,in.bitangent,in.normal)*safe_normalize(vec3(map.xy*material.surface_factors.z,map.z),vec3(0,0,1)),in.normal)*orientation; + let v=safe_normalize(uni.camera_position.xyz-in.world_pos,n); let l=safe_normalize(vec3(0.35,1,0.45),vec3(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(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(1)-direct_f)*(1.0-c.mr.x)*c.base.rgb/3.14159265; + let sun=(diffuse+spec)*nl*vec3(3.0,2.85,2.65); let up=clamp(n.y*0.5+0.5,0,1); let sky=mix(vec3(0.055,0.045,0.035),vec3(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(0.04,0.035,0.03),vec3(0.28,0.42,0.7),horizon)*(1.0-0.65*c.mr.y); + let color=sun+(vec3(1)-env_f)*(1.0-c.mr.x)*c.base.rgb*sky*c.ao+env_spec*c.ao+c.emissive; return vec4(color,1.0); +}`; + +export const groundShader = /* wgsl */ ` +@group(0) @binding(0) var application: array, 3>; +@group(1) @binding(0) var view_proj: mat4x4; +struct Input { @location(0) position: vec3, @location(1) normal: vec3, @location(2) uv: vec2, @location(3) c0: vec4, @location(4) c1: vec4, @location(5) c2: vec4, @location(6) c3: vec4 } +struct Output { @builtin(position) position: vec4, @location(0) normal: vec3 } +@vertex fn vs_main(input: Input) -> Output { var output: Output; output.position=view_proj*mat4x4(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 { 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; +@group(0) @binding(1) var second_texture: texture_2d; +@group(0) @binding(2) var linear_clamp: sampler; +struct Parameters { values: array, 8> } +@group(0) @binding(3) var parameters: Parameters; +struct VertexOut { @builtin(position) position: vec4, @location(0) uv: vec2 } +@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)->vec3{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)->vec3{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{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] }), + ]), +}); diff --git a/addons/gltf-import/package.json b/addons/gltf-import/package.json new file mode 100644 index 0000000..79bb41a --- /dev/null +++ b/addons/gltf-import/package.json @@ -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" +} diff --git a/addons/gltf-import/src/index.js b/addons/gltf-import/src/index.js new file mode 100644 index 0000000..270fcc5 --- /dev/null +++ b/addons/gltf-import/src/index.js @@ -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"); + } +} diff --git a/addons/gltf-import/src/shared-upload.js b/addons/gltf-import/src/shared-upload.js new file mode 100644 index 0000000..646270b --- /dev/null +++ b/addons/gltf-import/src/shared-upload.js @@ -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); + } +} diff --git a/addons/gltf-import/src/worker.js b/addons/gltf-import/src/worker.js new file mode 100644 index 0000000..62209ae --- /dev/null +++ b/addons/gltf-import/src/worker.js @@ -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" }); + } +}); diff --git a/addons/mesh-handles/package.json b/addons/mesh-handles/package.json new file mode 100644 index 0000000..c338c13 --- /dev/null +++ b/addons/mesh-handles/package.json @@ -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" + } +} diff --git a/static/bvh-core.js b/addons/mesh-handles/src/bvh-core.js similarity index 98% rename from static/bvh-core.js rename to addons/mesh-handles/src/bvh-core.js index 4c320bd..66c4056 100644 --- a/static/bvh-core.js +++ b/addons/mesh-handles/src/bvh-core.js @@ -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) { diff --git a/static/bvh-worker.js b/addons/mesh-handles/src/bvh-worker.js similarity index 95% rename from static/bvh-worker.js rename to addons/mesh-handles/src/bvh-worker.js index 6727e69..598954f 100644 --- a/static/bvh-worker.js +++ b/addons/mesh-handles/src/bvh-worker.js @@ -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) { diff --git a/addons/mesh-handles/src/index.js b/addons/mesh-handles/src/index.js new file mode 100644 index 0000000..9464ca0 --- /dev/null +++ b/addons/mesh-handles/src/index.js @@ -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; } +} diff --git a/addons/render-graph-ast/package.json b/addons/render-graph-ast/package.json new file mode 100644 index 0000000..6742f9f --- /dev/null +++ b/addons/render-graph-ast/package.json @@ -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" +} diff --git a/addons/render-graph-ast/src/index.js b/addons/render-graph-ast/src/index.js new file mode 100644 index 0000000..93d513f --- /dev/null +++ b/addons/render-graph-ast/src/index.js @@ -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; diff --git a/addons/render-graph-fxnode/package.json b/addons/render-graph-fxnode/package.json new file mode 100644 index 0000000..4f904bb --- /dev/null +++ b/addons/render-graph-fxnode/package.json @@ -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" + } +} diff --git a/static/render-graph/adapter.js b/addons/render-graph-fxnode/src/adapter.js similarity index 98% rename from static/render-graph/adapter.js rename to addons/render-graph-fxnode/src/adapter.js index 8a263ce..0453c0d 100644 --- a/static/render-graph/adapter.js +++ b/addons/render-graph-fxnode/src/adapter.js @@ -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) { diff --git a/static/render-graph/catalog.js b/addons/render-graph-fxnode/src/catalog.js similarity index 99% rename from static/render-graph/catalog.js rename to addons/render-graph-fxnode/src/catalog.js index ad31bfa..92b09cf 100644 --- a/static/render-graph/catalog.js +++ b/addons/render-graph-fxnode/src/catalog.js @@ -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] }); diff --git a/addons/render-graph-fxnode/src/index.js b/addons/render-graph-fxnode/src/index.js new file mode 100644 index 0000000..96e6985 --- /dev/null +++ b/addons/render-graph-fxnode/src/index.js @@ -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"; diff --git a/addons/render-graph-js/package.json b/addons/render-graph-js/package.json new file mode 100644 index 0000000..c7c7979 --- /dev/null +++ b/addons/render-graph-js/package.json @@ -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" + } +} diff --git a/addons/render-graph-js/src/index.js b/addons/render-graph-js/src/index.js new file mode 100644 index 0000000..446078e --- /dev/null +++ b/addons/render-graph-js/src/index.js @@ -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 }; diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..465be8e --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/cookbook/01-canonical-ast.js b/examples/cookbook/01-canonical-ast.js new file mode 100644 index 0000000..91d874d --- /dev/null +++ b/examples/cookbook/01-canonical-ast.js @@ -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) }; +} diff --git a/examples/cookbook/02-jso-graph.js b/examples/cookbook/02-jso-graph.js new file mode 100644 index 0000000..098acdc --- /dev/null +++ b/examples/cookbook/02-jso-graph.js @@ -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: {}, + }, + ], + }); +} diff --git a/examples/cookbook/03-fluent-builder.js b/examples/cookbook/03-fluent-builder.js new file mode 100644 index 0000000..71f82e9 --- /dev/null +++ b/examples/cookbook/03-fluent-builder.js @@ -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(); +} diff --git a/examples/cookbook/04-fxnode-export.js b/examples/cookbook/04-fxnode-export.js new file mode 100644 index 0000000..9e98abe --- /dev/null +++ b/examples/cookbook/04-fxnode-export.js @@ -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 }, + ); +} diff --git a/examples/cookbook/05-default-pipelines.js b/examples/cookbook/05-default-pipelines.js new file mode 100644 index 0000000..ce8c9da --- /dev/null +++ b/examples/cookbook/05-default-pipelines.js @@ -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(); +} diff --git a/examples/cookbook/06-custom-render-pipeline.js b/examples/cookbook/06-custom-render-pipeline.js new file mode 100644 index 0000000..164abd9 --- /dev/null +++ b/examples/cookbook/06-custom-render-pipeline.js @@ -0,0 +1,31 @@ +import { RenderGraph } from "@yawn/render-graph-js"; + +export const simpleSceneShader = /* wgsl */ ` +@group(1) @binding(0) var view_projection: mat4x4; +struct Input { + @location(0) position: vec3, + @location(3) model_0: vec4, + @location(4) model_1: vec4, + @location(5) model_2: vec4, + @location(6) model_3: vec4, +} +@vertex fn vertex_main(input: Input) -> @builtin(position) vec4 { + let model = mat4x4(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 { + 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(); +} diff --git a/examples/cookbook/07-compute-pipeline.js b/examples/cookbook/07-compute-pipeline.js new file mode 100644 index 0000000..cee506f --- /dev/null +++ b/examples/cookbook/07-compute-pipeline.js @@ -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(); +} diff --git a/examples/cookbook/08-compile-and-switch.js b/examples/cookbook/08-compile-and-switch.js new file mode 100644 index 0000000..1309be5 --- /dev/null +++ b/examples/cookbook/08-compile-and-switch.js @@ -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); +} diff --git a/examples/cookbook/09-gltf-import-worker.js b/examples/cookbook/09-gltf-import-worker.js new file mode 100644 index 0000000..d791eb2 --- /dev/null +++ b/examples/cookbook/09-gltf-import-worker.js @@ -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(); + } +} diff --git a/examples/cookbook/10-mesh-instances.js b/examples/cookbook/10-mesh-instances.js new file mode 100644 index 0000000..1809662 --- /dev/null +++ b/examples/cookbook/10-mesh-instances.js @@ -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); +} diff --git a/examples/cookbook/11-custom-soa-column.js b/examples/cookbook/11-custom-soa-column.js new file mode 100644 index 0000000..e412b24 --- /dev/null +++ b/examples/cookbook/11-custom-soa-column.js @@ -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]); +} diff --git a/examples/cookbook/12-direct-sab-animation.js b/examples/cookbook/12-direct-sab-animation.js new file mode 100644 index 0000000..c1e06ee --- /dev/null +++ b/examples/cookbook/12-direct-sab-animation.js @@ -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); +} diff --git a/examples/cookbook/13-bvh-picking.js b/examples/cookbook/13-bvh-picking.js new file mode 100644 index 0000000..5c8fd6c --- /dev/null +++ b/examples/cookbook/13-bvh-picking.js @@ -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, + }); +} diff --git a/examples/cookbook/14-worker-to-worker.js b/examples/cookbook/14-worker-to-worker.js new file mode 100644 index 0000000..d63eea3 --- /dev/null +++ b/examples/cookbook/14-worker-to-worker.js @@ -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; +} diff --git a/examples/cookbook/15-complete-scene.js b/examples/cookbook/15-complete-scene.js new file mode 100644 index 0000000..fe065a9 --- /dev/null +++ b/examples/cookbook/15-complete-scene.js @@ -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; + } +} diff --git a/examples/cookbook/README.md b/examples/cookbook/README.md new file mode 100644 index 0000000..9b8766a --- /dev/null +++ b/examples/cookbook/README.md @@ -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 1–7 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. diff --git a/examples/cookbook/index.js b/examples/cookbook/index.js new file mode 100644 index 0000000..1033571 --- /dev/null +++ b/examples/cookbook/index.js @@ -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"; diff --git a/static/demo-loadouts.js b/examples/render-graph-studio/demo-loadouts.js similarity index 87% rename from static/demo-loadouts.js rename to examples/render-graph-studio/demo-loadouts.js index 34245cf..2c0b30b 100644 --- a/static/demo-loadouts.js +++ b/examples/render-graph-studio/demo-loadouts.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}`); } diff --git a/static/index.html b/examples/render-graph-studio/index.html similarity index 86% rename from static/index.html rename to examples/render-graph-studio/index.html index 0ad68f9..e6d5197 100644 --- a/static/index.html +++ b/examples/render-graph-studio/index.html @@ -3,7 +3,7 @@ - Yawn Render Graph Demo + Yawn Package Integration Example