feat: add gpu driven render graph pipeline

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 20:26:31 +00:00
co-authored by heaust
parent d4e8634f67
commit 05311e564c
51 changed files with 13262 additions and 743 deletions
+113
View File
@@ -0,0 +1,113 @@
import test from "node:test";
import assert from "node:assert/strict";
import { addNodeItems, moveAddNodeSelection, searchAddNodeItems } from "../static/render-graph/add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "../static/render-graph/node-spawn.js";
test("add-node model contains all 17 catalog types in application groups", () => {
assert.equal(addNodeItems.length, 17);
assert.deepEqual([...new Set(addNodeItems.map((item) => item.group))], ["Source", "Compute", "Render / post", "Present"]);
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 17);
assert.deepEqual(searchAddNodeItems("tone render").map((item) => item.typeId), ["tone_map"]);
assert.deepEqual(searchAddNodeItems("no such node"), []);
});
test("menu selection wraps and handles an empty search", () => {
assert.equal(moveAddNodeSelection(0, -1, 3), 2);
assert.equal(moveAddNodeSelection(2, 1, 3), 0);
assert.equal(moveAddNodeSelection(0, 1, 0), -1);
});
test("allocator avoids existing and session-reserved IDs and is bounded", () => {
const values = ["a-a", "a-a", "b-b"];
const allocate = createNodeIdAllocator(() => values.shift());
assert.equal(allocate(["node_aa"]), "node_bb");
assert.throws(() => createNodeIdAllocator(() => "bad id")([]), /Unable to allocate/);
});
test("all 17 types spawn with exact position, current version and generated ID", async () => {
let revision = 5, expectedType;
const request = { compositionRevision: 5, viewPosition: { x: 12.25, y: -4 } };
const root = { getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }) };
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async (params, options) => {
assert.equal(params.typeId, expectedType);
assert.strictEqual(params.viewPosition, request.viewPosition);
assert.match(params.nodeId, /^node_/);
assert.deepEqual(options, { expectedVersion: 91 });
},
};
let id = 0;
const allocate = createNodeIdAllocator(() => `00000000-0000-0000-0000-${String(++id).padStart(12, "0")}`);
for (const item of addNodeItems) {
expectedType = item.typeId;
assert.equal(await spawnRequestedNode(root, view, request, item.typeId, allocate), true);
}
revision = 6;
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false);
});
test("spawn rechecks composition after getState and propagates add errors", async () => {
let revision = 2;
const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } };
const root = { getState: async () => { revision++; return { version: 3, nodes: [] }; } };
const allocate = createNodeIdAllocator(() => "a");
const view = { getHostSnapshot: () => ({ compositionRevision: revision }), addNode: async () => { throw Error("must not add"); } };
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false);
revision = 2;
root.getState = async () => ({ version: 3, nodes: [] });
await assert.rejects(spawnRequestedNode(root, view, request, "tone_map", allocate), /must not add/);
});
test("spawn cancels when a pending getState becomes mutated or dead", async () => {
const request = { compositionRevision: 2, viewPosition: { x: 1, y: 2 } };
let resolveState, revision = 2, alive = true, adds = 0;
const root = { getState: () => new Promise((resolve) => { resolveState = resolve; }) };
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async () => { adds++; },
};
const pendingMutation = spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive);
revision++;
resolveState({ version: 1, nodes: [] });
assert.equal(await pendingMutation, false);
revision = 2;
const pendingDestroy = spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive);
alive = false;
resolveState({ version: 1, nodes: [] });
assert.equal(await pendingDestroy, false);
assert.equal(adds, 0);
});
test("spawn has a final liveness guard after ID allocation", async () => {
let alive = true, adds = 0;
const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } };
const root = { getState: async () => ({ version: 1, nodes: [] }) };
const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => { adds++; } };
const result = await spawnRequestedNode(root, view, request, "tone_map", () => {
alive = false;
return "node_reserved";
}, () => alive);
assert.equal(result, false);
assert.equal(adds, 0);
});
test("spawn suppresses teardown RPC rejections but propagates genuine live add errors", async () => {
const request = { compositionRevision: 1, viewPosition: { x: 0, y: 0 } };
let alive = true;
const view = { getHostSnapshot: () => ({ compositionRevision: 1 }), addNode: async () => {} };
const root = { getState: async () => { alive = false; throw Error("detached state"); } };
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive), false);
alive = true;
root.getState = async () => ({ version: 1, nodes: [] });
view.addNode = async () => { alive = false; throw Error("detached add"); };
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", () => "node_b", () => alive), false);
alive = true;
view.addNode = async () => { throw Error("live add failure"); };
await assert.rejects(
spawnRequestedNode(root, view, request, "tone_map", () => "node_c", () => alive),
/live add failure/,
);
});
+3 -1
View File
@@ -1,10 +1,12 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createCubeGeometry, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, LoadoutError } from "../static/demo-loadouts.js";
import { createCubeGeometry, createMaterialGalleryGlb, createUvSphereGeometry, encodeGeometryGlb, isGitLfsPointer, loadDemoLoadout, loadouts, LoadoutError } from "../static/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 legacy scenes 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("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/)});
+27
View File
@@ -0,0 +1,27 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, writeFile, rm } from "node:fs/promises";
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";
test("production render graph composition passes fxnode's public validator", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "yawn-fxnode-validator-"));
try {
const entry = path.join(directory, "entry.js");
await writeFile(entry, `export { validateFxNodeComposition } from ${JSON.stringify(pathToFileURL(path.resolve("vendor/fxnode/src/index.ts")).href)};`);
await build({
configFile: false,
logLevel: "silent",
build: { lib: { entry, formats: ["es"], fileName: "validator" }, outDir: directory, emptyOutDir: false },
});
const { validateFxNodeComposition } = await import(`${pathToFileURL(path.join(directory, "validator.js")).href}?${Date.now()}`);
const result = validateFxNodeComposition(fxNodeComposition);
assert.equal(result.ok, true, result.ok ? undefined : JSON.stringify(result.issues, null, 2));
assert.equal(Object.keys(fxNodeComposition.nodes).length, 17);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
+285 -11
View File
@@ -1,15 +1,289 @@
import test from "node:test";
import assert from "node:assert/strict";
import { adaptFxNodeSnapshot, AuthoringGraphError } from "../static/render-graph/adapter.js";
import {
adaptFxNodeSnapshot,
AuthoringGraphError,
getSourceMap,
mapAuthoringDiagnostic,
} from "../static/render-graph/adapter.js";
import { RendererError } from "../static/renderer-client.js";
import {
semanticCatalog,
nodeDefinitions,
GRAPH_ID,
CATALOG_VERSION,
socketTypes,
} from "../static/render-graph/catalog.js";
import { culling } from "../static/render-graph/presets.js";
import { AuthoringController } from "../static/render-graph/authoring-controller.js";
const sockets={surface_color:[["surface","surface:surface","output","surface"]],depth32:[["depth","depth:depth","output","depth"]],scene_forward:[["color","forward:color","input","surface"],["depth","forward:depth","input","depth"],["result","forward:result","output","surface"]],present:[["surface","present:surface","input","surface"]]};
function fixture(){const nodes=Object.entries(sockets).map(([typeId,list],i)=>({id:["surface","depth","forward","present"][i],typeId,typeVersion:1,known:true,muted:false,parameters:typeId==="scene_forward"?{clearColor:{kind:"color",value:[.1,.2,.3,1]},clearDepth:{kind:"number",value:.5}}:{},sockets:list.map(([key,id,direction,dataType])=>({key,id,direction,dataType}))}));return {version:4,graphId:"demo_forward",catalogVersion:1,nodes,links:[{id:"l1",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false},{id:"l2",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false},{id:"l3",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false}],metadata:{layout:"ignored"}}}
const rejects=(mutate,code)=>{const x=structuredClone(fixture());mutate(x);assert.throws(()=>adaptFxNodeSnapshot(x),e=>e instanceof AuthoringGraphError&&e.code===code)};
test("adapter emits exact deterministic V1 without fxnode state",()=>{const a=adaptFxNodeSnapshot(fixture(),7),b=fixture();b.nodes.reverse();b.links.reverse();assert.deepEqual(adaptFxNodeSnapshot(b,7),a);assert.equal(a.revision,7);assert.deepEqual(a.passes[0].writes[0].access.load.value,[.1,.2,.3,1]);for(const token of ["position","socketId","known","fxnode"])assert.equal(JSON.stringify(a).includes(token),false)});
test("adapter rejects node catalog, identity, sockets, links, topology and parameters",()=>{
rejects(x=>x.nodes[0].known=false,"AUTHORING_NODE_UNKNOWN");rejects(x=>delete x.nodes[0].muted,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].muted=true,"AUTHORING_NODE_MUTED");rejects(x=>x.nodes[0].typeVersion=2,"AUTHORING_NODE_VERSION");rejects(x=>x.nodes[0].typeId="other","AUTHORING_NODE_TYPE");rejects(x=>x.nodes[0].id="bad id","AUTHORING_ID");rejects(x=>x.nodes[1].id=x.nodes[0].id,"AUTHORING_ID_DUPLICATE");rejects(x=>x.nodes[0].sockets[0].direction="input","AUTHORING_SOCKET");rejects(x=>x.links[0].muted=true,"AUTHORING_TOPOLOGY");rejects(x=>x.links.pop(),"AUTHORING_TOPOLOGY");rejects(x=>x.nodes[2].parameters.clearDepth.value=Infinity,"AUTHORING_PARAMETER");
function fixture() {
const nodes = culling.nodes.map((n) => {
const d = semanticCatalog[n.executor.key];
const definition = nodeDefinitions[n.executor.key];
return {
id: n.id,
typeId: n.executor.key,
typeVersion: 1,
known: true,
muted: n.state !== "enabled",
position: { x: 10, y: 20 },
size: { x: 200, y: 120 },
label: n.id,
collapsed: false,
extensions: {},
parameters: Object.fromEntries(
Object.entries(definition.parameters).map(([key, schema]) => [
key,
{
kind: schema.type,
value: structuredClone(n.parameters[key] ?? schema.default.value),
},
]),
),
sockets: [
...Object.entries(d.inputs).map(([key, x]) => ({
key,
id: `${n.id}:${key}`,
direction: "input",
dataType: x.authoringType ?? x.accepted.types[0],
label: key,
accepts: socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom,
visible: true,
maxIncomingLinks: 1,
})),
...Object.entries(d.outputs).map(([key]) => ({
key,
id: `${n.id}:${key}`,
direction: "output",
dataType: definition.sockets[key].type,
label: key,
accepts: [],
visible: true,
maxIncomingLinks: 0,
})),
],
};
});
const links = [];
for (const n of culling.nodes)
for (const [socket, from] of Object.entries(n.inputs))
links.push({
id: `l_${from.node}_${from.socket}_${n.id}_${socket}`,
fromNodeId: from.node,
fromSocketId: `${from.node}:${from.socket}`,
toNodeId: n.id,
toSocketId: `${n.id}:${socket}`,
muted: false,
extensions: {},
});
return {
graphId: GRAPH_ID,
catalogVersion: CATALOG_VERSION,
nodes,
links,
metadata: { layout: "ignored" },
version: 1,
};
}
test("catalog exhaustively mirrors all current V2 contracts", () => {
assert.deepEqual(
Object.keys(semanticCatalog).sort(),
[
"surface_target",
"texture_spec",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"visibility_flags",
"frustum_cull",
"mesh_query",
"depth_stencil_config",
"legacy_forward",
"fullscreen_copy",
"tone_map",
"bloom_extract",
"bloom_blur",
"bloom_composite",
"luminance_edge",
"present",
].sort(),
);
for (const c of Object.values(semanticCatalog)) {
assert.ok(c.execution);
assert.ok(c.inputs);
assert.ok(c.outputs);
assert.ok(c.parameters);
}
});
test("adapter deterministically emits strict V2, permits repeated types, omits muted links and maps sources", () => {
const x = fixture(),
a = adaptFxNodeSnapshot(x, 7);
x.nodes.reverse();
x.links.reverse();
assert.deepEqual(adaptFxNodeSnapshot(x, 7), a);
assert.equal(a.schemaVersion, 2);
assert.equal(a.graphId, GRAPH_ID);
assert.equal(
a.nodes.filter((n) => n.executor.key === "texture_spec").length,
2,
);
assert.ok(
Object.values(getSourceMap(a)).some((source) => source.input === "source"),
);
x.links.find((l) => l.id === "l_forward_color_copy_source").muted = true;
assert.equal(
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "copy").inputs.source,
undefined,
);
});
test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type mismatches", () => {
const reject = (fn, code) => {
const x = fixture();
fn(x);
assert.throws(
() => adaptFxNodeSnapshot(x),
(e) => e instanceof AuthoringGraphError && e.code === code,
);
};
reject((x) => (x.graphId = "bad"), "AUTHORING_CATALOG");
reject((x) => (x.nodes[0].id = "bad id"), "AUTHORING_ID");
reject((x) => (x.nodes[1].id = x.nodes[0].id), "AUTHORING_ID_DUPLICATE");
reject((x) => (x.nodes[0].typeId = "wat"), "AUTHORING_NODE_TYPE");
reject((x) => (x.nodes[0].sockets = []), "AUTHORING_SOCKET_SET");
reject((x) => (x.links[0].toSocketId = "missing:x"), "AUTHORING_LINK");
reject((x) => {
const link = x.links.find((l) => l.toSocketId === "copy:source");
link.fromNodeId = "scene";
link.fromSocketId = "scene:scene";
}, "AUTHORING_LINK_TYPE");
});
test("adapter counts only active incoming links and reports socket overflow", () => {
const x = fixture();
const active = x.links.find((link) => link.toSocketId === "copy:source");
x.links.push({ ...structuredClone(active), id: "muted_duplicate", muted: true });
assert.doesNotThrow(() => adaptFxNodeSnapshot(x));
x.links.push({ ...structuredClone(active), id: "active_overflow" });
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_LINK_INCOMING" && error.details.socketId === "copy:source",
);
});
test("source map covers Rust fields, nested values, every input and is deeply frozen", () => {
const snapshot = fixture();
snapshot.links.find((link) => link.toSocketId === "copy:source").muted = true;
const ir = adaptFxNodeSnapshot(snapshot, 9), map = getSourceMap(ir);
for (const path of ["schemaVersion", "graphId", "revision", "nodes", "nodes[0].id", "nodes[0].state", "nodes[0].executor.key", "nodes[0].executor.version", "nodes[0].parameters", "nodes[0].inputs"])
assert.ok(map[path], path);
for (const [index, node] of ir.nodes.entries())
for (const input of Object.keys(semanticCatalog[node.executor.key].inputs))
assert.ok(map[`nodes[${index}].inputs.${input}`]);
assert.ok(Object.keys(map).some((path) => /parameters\..+\[|parameters\..+\..+/.test(path)));
const socket = Object.values(map).find((source) => source.kind === "socket");
const unconnected = Object.values(map).find((source) => source.unconnected === true);
const link = Object.values(map).find((source) => source.kind === "link");
assert.ok(socket?.socketId && unconnected?.socketId);
assert.equal(ir.nodes.find((node) => node.id === "copy").inputs.source, undefined);
for (const field of ["linkId", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted"])
assert.ok(Object.hasOwn(link, field), field);
assert.ok(Object.isFrozen(map) && Object.isFrozen(link));
});
test("diagnostic mapper creates a frozen RendererError DTO with fallbacks and prefix matching", () => {
const ir = adaptFxNodeSnapshot(fixture());
const original = new RendererError("GRAPH_INPUT", { message: "bad", field: "nodes[0].executor.key.more", nested: { x: 1 } });
const mapped = mapAuthoringDiagnostic(ir, original);
assert.notStrictEqual(mapped, original);
assert.equal(mapped.code, original.code);
assert.equal(mapped.source.kind, "node");
assert.equal(mapped.diagnostic, undefined);
assert.ok(Object.isFrozen(mapped) && Object.isFrozen(mapped.details.nested));
assert.equal(Object.isFrozen(original), false);
original.details.nested.x = 2;
assert.equal(mapped.details.nested.x, 1);
const unmatchedOriginal = new RendererError("GRAPH_INPUT", {
message: "unmapped",
path: "resources[0]",
});
const unmatched = mapAuthoringDiagnostic(ir, unmatchedOriginal);
assert.notStrictEqual(unmatched, unmatchedOriginal);
assert.equal(unmatched.source, undefined);
assert.ok(Object.isFrozen(unmatched) && Object.isFrozen(unmatched.details));
assert.equal(Object.isFrozen(unmatchedOriginal), false);
});
test("controller keeps last-good through failures and only drops after successful switch", async () => {
let fail = false;
const calls = [];
const renderer = {
compileGraph: async (ir) => ({
compiledId: [ir.revision, 1],
revision: ir.revision,
}),
switchCompiledGraph: async (id) => {
calls.push(["switch", id]);
if (fail) throw Error("switch");
},
dropCompiledGraph: async (id) => calls.push(["drop", id]),
};
const c = new AuthoringController({
renderer,
adapt: (_, revision) => ({ revision }),
});
c.markDirty({});
await c.apply();
fail = true;
c.markDirty({});
await assert.rejects(c.apply());
assert.deepEqual(calls, [
["switch", [1, 1]],
["switch", [2, 1]],
]);
fail = false;
await c.apply();
assert.deepEqual(calls.at(-1), ["drop", [1, 1]]);
await c.destroy();
});
test("controller shares in-flight apply", async () => {
let release;
const gate = new Promise((r) => (release = r));
const c = new AuthoringController({
adapt: (_, revision) => ({ revision }),
renderer: {
compileGraph: async (ir) => {
await gate;
return { compiledId: [ir.revision, 1], revision: ir.revision };
},
switchCompiledGraph: async () => {},
dropCompiledGraph: async () => {},
},
});
c.markDirty({});
const a = c.apply();
assert.strictEqual(c.apply(), a);
release();
await a;
});
test("controller retains mapped diagnostic while apply rejects the original and subscriptions agree", async () => {
const original = new RendererError("GRAPH_BAD", { path: "nodes[0].id", message: "bad" });
const states = [];
const c = new AuthoringController({
adapt: (snapshot, revision) => adaptFxNodeSnapshot(snapshot, revision),
renderer: { compileGraph: async () => { throw original; }, switchCompiledGraph: async () => {}, dropCompiledGraph: async () => {} },
});
c.subscribe((state) => states.push(state));
c.markDirty(fixture());
await assert.rejects(c.apply(), (error) => error === original);
assert.notStrictEqual(states.at(-1).error, original);
let subscribed;
c.subscribe((state) => { subscribed = state; })();
assert.strictEqual(subscribed.error, states.at(-1).error);
await c.destroy();
});
test("apply after destroy does not compile and destroy returns one strict promise", async () => {
let compiles = 0;
const c = new AuthoringController({ adapt: () => ({}), renderer: { compileGraph: async () => { compiles++; }, dropCompiledGraph: async () => {}, switchCompiledGraph: async () => {} } });
c.markDirty({});
const first = c.destroy();
assert.strictEqual(c.destroy(), first);
assert.strictEqual(await c.apply(), null);
await first;
assert.equal(compiles, 0);
});
test("adapter carries scene mute and bounds revisions",()=>{const x=fixture();x.nodes[2].muted=true;assert.equal(adaptFxNodeSnapshot(x).passes[0].state,"disabled");assert.throws(()=>adaptFxNodeSnapshot(fixture(),0x100000000),e=>e.code==="AUTHORING_REVISION")});
test("controller orders compile/switch, revisions and shares one in-flight apply",async()=>{let release;const gate=new Promise(r=>release=r),calls=[];const renderer={async compileGraph(ir){calls.push(`compile:${ir.revision}`);await gate;return{compiledId:[0,1]}},async switchCompiledGraph(id){calls.push(`switch:${id}`)}};const c=new AuthoringController({renderer,getState:async()=>({})});const adapt=(_,r)=>({revision:r}),a=c.apply(adapt);assert.strictEqual(c.apply(adapt),a);c.markDirty();release();await a;assert.deepEqual(calls,["compile:1","switch:0,1"]);assert.equal(c.revision,1);assert.equal(c.dirty,true)});
test("controller reserves revisions across compile and switch failures",async()=>{let failure="compile",revisions=[];const c=new AuthoringController({getState:async()=>({}),renderer:{compileGraph:async ir=>{revisions.push(ir.revision);if(failure==="compile")throw Error("no");return{compiledId:[0,1]}},switchCompiledGraph:async()=>{if(failure==="switch")throw Error("no")}}});await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));failure="switch";await assert.rejects(c.apply((_,r)=>({revision:r})));failure=null;await c.apply((_,r)=>({revision:r}));assert.deepEqual(revisions,[1,2,3,4]);assert.equal(c.revision,4)});
+96 -2
View File
@@ -1,4 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
import { ember, midnight, renderGraphPresets } from "../static/render-graph/presets.js";
test("Phase 8 presets are unique activatable V1 scene-forward graphs",()=>{assert.deepEqual(Object.keys(renderGraphPresets),["midnight","ember"]);assert.deepEqual([midnight.graphId,ember.graphId],["preset_midnight","preset_ember"]);assert.notDeepEqual(midnight.passes[0].writes[0].access.load.value,ember.passes[0].writes[0].access.load.value);for(const graph of [midnight,ember]){assert.equal(graph.schemaVersion,1);assert.equal(graph.revision,1);assert.equal(graph.passes.length,1);assert.equal(graph.passes[0].state,"enabled");assert.deepEqual(graph.passes[0].executor,{key:"scene_forward",version:1});assert.equal(graph.outputs[0].name,"present");}});
import {
culling,
ember,
hdr,
midnight,
renderGraphPresets,
} from "../static/render-graph/presets.js";
test("Phase 4 unit 4 presets preserve V1 graphs and add the V2 HDR fullscreen topology", () => {
assert.deepEqual(Object.keys(renderGraphPresets), [
"midnight",
"ember",
"hdr",
"culling",
"tone",
"edges",
"bloom",
"combined",
]);
assert.deepEqual(
[midnight.graphId, ember.graphId],
["preset_midnight", "preset_ember"],
);
assert.notDeepEqual(
midnight.passes[0].writes[0].access.load.value,
ember.passes[0].writes[0].access.load.value,
);
for (const graph of [midnight, ember]) {
assert.equal(graph.schemaVersion, 1);
assert.equal(graph.revision, 1);
assert.equal(graph.passes.length, 1);
assert.equal(graph.passes[0].state, "enabled");
assert.deepEqual(graph.passes[0].executor, {
key: "scene_forward",
version: 1,
});
assert.equal(graph.outputs[0].name, "present");
}
assert.equal(hdr.schemaVersion, 2);
assert.equal(hdr.revision, 1);
assert.equal(hdr.graphId, "preset_hdr_fullscreen");
assert.equal(
new Set(Object.values(renderGraphPresets).map((graph) => graph.graphId))
.size,
8,
);
assert.deepEqual(
hdr.nodes.map((node) => [node.id, node.executor.key]),
[
["surface", "surface_target"],
["hdr", "texture_spec"],
["depth", "texture_spec"],
["scene", "scene_table"],
["visible", "visibility_flags"],
["query", "mesh_query"],
["depth_config", "depth_stencil_config"],
["forward", "legacy_forward"],
["copy", "fullscreen_copy"],
["present", "present"],
],
);
const byId = Object.fromEntries(hdr.nodes.map((node) => [node.id, node]));
assert.equal(byId.hdr.parameters.texture.format, "rgba16_float");
assert.deepEqual(byId.query.inputs, {
scene: { node: "scene", socket: "scene" },
isVisible: { node: "visible", socket: "flags" },
});
assert.deepEqual(byId.query.parameters.filters, [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "any" },
]);
assert.deepEqual(byId.forward.inputs.colorTarget, {
node: "hdr",
socket: "spec",
});
assert.deepEqual(byId.copy.executor, { key: "fullscreen_copy", version: 1 });
assert.deepEqual(byId.copy.inputs, {
source: { node: "forward", socket: "color" },
colorTarget: { node: "surface", socket: "surface" },
});
assert.deepEqual(byId.present.inputs.surface, {
node: "copy",
socket: "color",
});
assert.deepEqual(
culling.nodes
.filter((node) => ["frustum_cull", "mesh_query"].includes(node.executor.key))
.map((node) => node.executor.key),
["frustum_cull", "mesh_query"],
);
const cullingQuery = culling.nodes.find((node) => node.id === "query");
assert.equal(cullingQuery.parameters.filters[1].predicate, "required_false");
assert.deepEqual(cullingQuery.inputs.isFrustumCulled, {
node: "cull",
socket: "flags",
});
});
+6
View File
@@ -55,6 +55,12 @@ test("pending reply exists before ring publication", async () => {
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.setVisible(true), b=mesh.setVisible(false);