refactor: redesign render graph nodes

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-28 13:00:29 +00:00
co-authored by heaust
parent fc8c16daec
commit edc7c8ef29
23 changed files with 5229 additions and 1810 deletions
+156 -36
View File
@@ -1,13 +1,26 @@
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";
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"]);
test("add-node model contains all 13 catalog types in application groups", () => {
assert.equal(addNodeItems.length, 13);
assert.deepEqual(
[...new Set(addNodeItems.map((item) => item.group))],
["Source", "Compute", "CPU preparation", "Render / post", "Frame"],
);
assert.equal(new Set(addNodeItems.map((item) => item.typeId)).size, 13);
assert.deepEqual(
searchAddNodeItems("tone render").map((item) => item.typeId),
["tone_map"],
);
assert.deepEqual(searchAddNodeItems("no such node"), []);
});
@@ -21,13 +34,19 @@ 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/);
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;
test("all 13 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 root = {
getState: async () => ({ version: 91, nodes: [{ id: "existing" }] }),
};
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async (params, options) => {
@@ -38,41 +57,89 @@ test("all 17 types spawn with exact position, current version and generated ID",
},
};
let id = 0;
const allocate = createNodeIdAllocator(() => `00000000-0000-0000-0000-${String(++id).padStart(12, "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);
assert.equal(
await spawnRequestedNode(root, view, request, item.typeId, allocate),
true,
);
}
revision = 6;
assert.equal(await spawnRequestedNode(root, view, request, "tone_map", allocate), false);
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 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);
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/);
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; }) };
let resolveState,
revision = 2,
alive = true,
adds = 0;
const root = {
getState: () =>
new Promise((resolve) => {
resolveState = resolve;
}),
};
const view = {
getHostSnapshot: () => ({ compositionRevision: revision }),
addNode: async () => { adds++; },
addNode: async () => {
adds++;
},
};
const pendingMutation = spawnRequestedNode(root, view, request, "tone_map", () => "node_a", () => alive);
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);
const pendingDestroy = spawnRequestedNode(
root,
view,
request,
"tone_map",
() => "node_b",
() => alive,
);
alive = false;
resolveState({ version: 1, nodes: [] });
assert.equal(await pendingDestroy, false);
@@ -80,14 +147,27 @@ test("spawn cancels when a pending getState becomes mutated or dead", async () =
});
test("spawn has a final liveness guard after ID allocation", async () => {
let alive = true, adds = 0;
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);
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);
});
@@ -95,19 +175,59 @@ test("spawn has a final liveness guard after ID allocation", async () => {
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);
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);
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"); };
view.addNode = async () => {
throw Error("live add failure");
};
await assert.rejects(
spawnRequestedNode(root, view, request, "tone_map", () => "node_c", () => alive),
spawnRequestedNode(
root,
view,
request,
"tone_map",
() => "node_c",
() => alive,
),
/live add failure/,
);
});
+1 -1
View File
@@ -7,6 +7,6 @@ test("procedural cube and sphere have complete indexed vertex streams",()=>{cons
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("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("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/)});
+28 -6
View File
@@ -8,19 +8,41 @@ 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-"));
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 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 },
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 { 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);
assert.equal(
result.ok,
true,
result.ok ? undefined : JSON.stringify(result.issues, null, 2),
);
assert.equal(fxNodeComposition.schemaVersion, 2);
assert.equal(fxNodeComposition.version, 4);
assert.equal(Object.keys(fxNodeComposition.nodes).length, 13);
assert.ok(
Object.values(fxNodeComposition.nodes).every(
(definition) => definition.migrations.length === 0,
),
);
} finally {
await rm(directory, { recursive: true, force: true });
}
+337 -46
View File
@@ -41,16 +41,23 @@ function fixture() {
]),
),
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.inputs).map(([key, x]) => {
const socket = definition.sockets[key];
return {
key,
id: `${n.id}:${key}`,
direction: "input",
dataType: x.authoringType ?? x.accepted.types[0],
label: socket.title,
accepts:
socketTypes[x.authoringType ?? x.accepted.types[0]].acceptsFrom,
...(socket.value
? { defaultValue: structuredClone(socket.value.default) }
: {}),
visible: socket.visible,
maxIncomingLinks: socket.maxIncomingLinks,
};
}),
...Object.entries(d.outputs).map(([key]) => ({
key,
id: `${n.id}:${key}`,
@@ -87,26 +94,22 @@ function fixture() {
}
test("catalog exhaustively mirrors all current contracts", () => {
assert.deepEqual(
Object.keys(semanticCatalog).sort(),
Object.keys(semanticCatalog),
[
"surface_target",
"texture_spec",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"visibility_flags",
"mesh",
"texture",
"frustum_cull",
"mesh_query",
"depth_stencil_config",
"legacy_forward",
"pipeline_registry",
"pipeline",
"fullscreen_copy",
"tone_map",
"bloom_extract",
"bloom_blur",
"bloom_composite",
"luminance_edge",
"present",
].sort(),
"frame_out",
],
);
for (const c of Object.values(semanticCatalog)) {
assert.ok(c.execution);
@@ -114,6 +117,161 @@ test("catalog exhaustively mirrors all current contracts", () => {
assert.ok(c.outputs);
assert.ok(c.parameters);
}
for (const [key, contract] of Object.entries(semanticCatalog))
assert.deepEqual(
Object.keys(nodeDefinitions[key].parameters).sort(),
Object.keys(contract.parameters).sort(),
key,
);
assert.equal(CATALOG_VERSION, 4);
assert.deepEqual(nodeDefinitions.pipeline.parameters, {
pipeline: {
type: "string",
default: { kind: "string", value: "gltf_standard" },
},
depthCompare: {
type: "string",
default: { kind: "string", value: "less_equal" },
enum: ["never", "less", "equal", "less_equal", "greater", "not_equal", "greater_equal", "always"],
},
depthWriteEnabled: {
type: "boolean",
default: { kind: "boolean", value: true },
},
clearDepth: {
type: "number",
default: { kind: "number", value: 1 },
minimum: 0,
maximum: 1,
},
clearColor: {
type: "color",
default: { kind: "color", value: [0.015, 0.02, 0.03, 1] },
minimum: 0,
maximum: 1,
},
});
assert.deepEqual(nodeDefinitions.bloom_blur.parameters.direction.enum, [
"horizontal",
"vertical",
]);
assert.deepEqual(nodeDefinitions.texture.parameters.residency.enum, [
"transient",
"persistent",
]);
assert.deepEqual(
nodeDefinitions.frustum_cull.parameters.cameraSelection.enum,
["active"],
);
assert.deepEqual(nodeDefinitions.mesh_query.sockets.isVisible.value.default, {
kind: "boolean",
value: true,
});
assert.equal(nodeDefinitions.mesh_query.sockets.isVisible.showValue, true);
});
test("adapter validates and exactly lowers canonical pipeline controls and blur direction", () => {
const x = fixture();
const pipeline = x.nodes.find((node) => node.id === "ground");
assert.equal(pipeline.parameters.clearColor.kind, "color");
assert.deepEqual(
adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "ground").parameters,
{
pipeline: "ground_plane",
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
clearColor: [0.015, 0.02, 0.03, 1],
},
);
const schema = nodeDefinitions.bloom_blur.parameters;
const blur = structuredClone(x.nodes.find((node) => node.id === "frame_out"));
blur.id = "blur";
blur.typeId = "bloom_blur";
blur.parameters = {
direction: { kind: "string", value: "vertical" },
radius: structuredClone(schema.radius.default),
};
blur.sockets = Object.entries(nodeDefinitions.bloom_blur.sockets).map(
([key, socket]) => ({
key,
id: `blur:${key}`,
label: socket.title,
direction: socket.direction,
dataType: socket.type,
accepts:
socket.direction === "input"
? socketTypes[socket.type].acceptsFrom
: [],
maxIncomingLinks: socket.maxIncomingLinks,
visible: socket.visible,
}),
);
x.nodes.push(blur);
assert.deepEqual(
adaptFxNodeSnapshot(x).nodes.find((node) => node.id === "blur").parameters
.direction,
[0, 1],
);
pipeline.parameters.clearColor.value[0] = 2;
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1];
pipeline.parameters.clearColor.value = [0, 0, 0];
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1];
pipeline.parameters.clearColor.value = [0, 0, Number.NaN, 1];
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
pipeline.parameters.clearColor.value = [0.015, 0.02, 0.03, 1];
const texture = x.nodes.find((node) => node.id === "hdr");
texture.parameters.residency.value = "unknown";
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
texture.parameters.residency.value = "transient";
pipeline.parameters.clearDepth.value = -1;
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_PARAMETER",
);
});
test("adapter validates and lowers disconnected query socket defaults", () => {
const x = fixture();
const query = x.nodes.find((node) => node.id === "query");
const visible = query.sockets.find((socket) => socket.key === "isVisible");
visible.defaultValue.value = false;
const ir = adaptFxNodeSnapshot(x);
assert.equal(visible.defaultValue.value, false);
const parameters = ir.nodes.find((node) => node.id === "query").parameters;
assert.equal(parameters.isVisible, undefined);
assert.equal(parameters.visibleDefault, false);
assert.equal(parameters.frustumCulledDefault, false);
visible.defaultValue = { kind: "number", value: 0 };
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_SOCKET",
);
delete visible.defaultValue;
assert.throws(
() => adaptFxNodeSnapshot(x),
(error) => error.code === "AUTHORING_SOCKET",
);
});
test("adapter lowers the authoring-safe camera selector to the Rust wire field", () => {
const ir = adaptFxNodeSnapshot(fixture());
const parameters = ir.nodes.find((node) => node.id === "cull").parameters;
assert.deepEqual(parameters, { camera: "active" });
assert.equal(parameters.cameraSelection, undefined);
});
test("adapter deterministically emits the canonical schema, permits repeated types, omits muted links and maps sources", () => {
const x = fixture(),
@@ -123,16 +281,14 @@ test("adapter deterministically emits the canonical schema, permits repeated typ
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.equal(a.nodes.filter((n) => n.executor.key === "texture").length, 2);
assert.ok(
Object.values(getSourceMap(a)).some((source) => source.input === "source"),
Object.values(getSourceMap(a)).some((source) => source.input === "color"),
);
x.links.find((l) => l.id === "l_forward_color_copy_source").muted = true;
x.links.find((l) => l.id === "l_pbr_double_color_frame_out_color").muted = true;
assert.equal(
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "copy").inputs.source,
adaptFxNodeSnapshot(x, 8).nodes.find((n) => n.id === "frame_out").inputs
.color,
undefined,
);
});
@@ -146,50 +302,165 @@ test("adapter rejects hostile shape, IDs, duplicates, catalog, sockets and type
);
};
reject((x) => (x.graphId = "bad"), "AUTHORING_CATALOG");
reject((x) => (x.catalogVersion = 2), "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";
const link = x.links.find((l) => l.toSocketId === "frame_out:color");
link.fromNodeId = "mesh";
link.fromSocketId = "mesh:mesh";
}, "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 });
const active = x.links.find((link) => link.toSocketId === "frame_out:color");
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",
(error) =>
error.code === "AUTHORING_LINK_INCOMING" &&
error.details.socketId === "frame_out:color",
);
});
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"])
snapshot.links.find((link) => link.toSocketId === "frame_out:color").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)));
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 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.equal(
ir.nodes.find((node) => node.id === "frame_out").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("texture source maps identify every flat authored control", () => {
const snapshot = fixture();
const hdr = snapshot.nodes.find((node) => node.id === "hdr");
hdr.parameters.viewFormat.value = "rgba16_float";
const ir = adaptFxNodeSnapshot(snapshot);
const index = ir.nodes.findIndex((node) => node.id === "hdr");
const root = `nodes[${index}].parameters`;
const map = getSourceMap(ir);
const source = (parameter) => ({
kind: "parameter",
nodeId: "hdr",
parameter,
});
assert.deepEqual(map[`${root}.residency`], source("residency"));
assert.deepEqual(map[`${root}.texture`], { kind: "node", nodeId: "hdr" });
for (const [path, parameter] of [
["dimension", "dimension"],
["format", "format"],
["extent", "extentMode"],
["extent.kind", "extentMode"],
["extent.depthOrArrayLayers", "depthOrArrayLayers"],
["extent.width", "extentMode"],
["extent.width.numerator", "relativeWidthNumerator"],
["extent.width.denominator", "relativeWidthDenominator"],
["extent.height", "extentMode"],
["extent.height.numerator", "relativeHeightNumerator"],
["extent.height.denominator", "relativeHeightDenominator"],
["mipLevelCount", "mipLevelCount"],
["sampleCount", "sampleCount"],
["viewFormats", "viewFormat"],
["viewFormats[0]", "viewFormat"],
])
assert.deepEqual(map[`${root}.texture.${path}`], source(parameter), path);
assert.ok(!Object.values(map).some((value) => value.parameter === "texture"));
const absolute = fixture();
absolute.nodes.find((node) => node.id === "hdr").parameters.extentMode.value =
"absolute";
const absoluteIr = adaptFxNodeSnapshot(absolute);
const absoluteIndex = absoluteIr.nodes.findIndex((node) => node.id === "hdr");
const absoluteMap = getSourceMap(absoluteIr);
assert.deepEqual(
absoluteMap[`nodes[${absoluteIndex}].parameters.texture.extent.width`],
source("absoluteWidth"),
);
assert.deepEqual(
absoluteMap[`nodes[${absoluteIndex}].parameters.texture.extent.height`],
source("absoluteHeight"),
);
});
test("unsupported texture diagnostics map to their exact authored controls", () => {
const ir = adaptFxNodeSnapshot(fixture());
const index = ir.nodes.findIndex((node) => node.id === "hdr");
for (const [suffix, parameter] of [
["dimension", "dimension"],
["mipLevelCount", "mipLevelCount"],
["sampleCount", "sampleCount"],
["extent.depthOrArrayLayers", "depthOrArrayLayers"],
]) {
const path = `nodes[${index}].parameters.texture.${suffix}`;
const mapped = mapAuthoringDiagnostic(
ir,
new RendererError("GRAPH_UNSUPPORTED_FEATURE", {
message: "unsupported",
path,
}),
);
assert.equal(mapped.path, path);
assert.deepEqual(mapped.source, {
kind: "parameter",
nodeId: "hdr",
parameter,
});
}
});
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 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);
@@ -262,24 +533,44 @@ test("controller shares in-flight apply", async () => {
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 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 () => {} },
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; })();
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 () => {} } });
const c = new AuthoringController({
adapt: () => ({}),
renderer: {
compileGraph: async () => {
compiles++;
},
dropCompiledGraph: async () => {},
switchCompiledGraph: async () => {},
},
});
c.markDirty({});
const first = c.destroy();
assert.strictEqual(c.destroy(), first);
+285 -81
View File
@@ -1,91 +1,295 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
culling,
ember,
hdr,
midnight,
renderGraphPresets,
} from "../static/render-graph/presets.js";
test("presets use canonical node graphs", () => {
assert.deepEqual(Object.keys(renderGraphPresets), [
"midnight",
"ember",
"hdr",
"culling",
"tone",
"edges",
"bloom",
"combined",
]);
import * as presets from "../static/render-graph/presets.js";
const order = [
"midnight",
"ember",
"hdr",
"culling",
"tone",
"edges",
"bloom",
"combined",
];
const sequences = {
midnight: [
["ldr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
ember: [
["ldr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
hdr: [
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
culling: [
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["cull", "frustum_cull"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["frame_out", "frame_out"],
],
tone: [
["ldr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
edges: [
["ldr", "texture"],
["edge_hdr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
bloom: [
["ldr", "texture"],
["half_a", "texture"],
["half_b", "texture"],
["half_c", "texture"],
["composite_hdr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["extract", "bloom_extract"],
["blur_h", "bloom_blur"],
["blur_v", "bloom_blur"],
["composite", "bloom_composite"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
combined: [
["ldr", "texture"],
["edge_hdr", "texture"],
["half_a", "texture"],
["half_b", "texture"],
["half_c", "texture"],
["composite_hdr", "texture"],
["hdr", "texture"],
["depth", "texture"],
["mesh", "mesh"],
["query", "mesh_query"],
["registry", "pipeline_registry"],
["ground", "pipeline"],
["pbr", "pipeline"],
["pbr_double", "pipeline"],
["extract", "bloom_extract"],
["blur_h", "bloom_blur"],
["blur_v", "bloom_blur"],
["composite", "bloom_composite"],
["edges", "luminance_edge"],
["tone", "tone_map"],
["frame_out", "frame_out"],
],
};
test("presets have the exact canonical pipeline identities, schemas, and node sequences", () => {
assert.deepEqual(Object.keys(presets.renderGraphPresets), order);
assert.deepEqual(
[midnight.graphId, ember.graphId],
["preset_midnight", "preset_ember"],
);
assert.notDeepEqual(midnight.nodes[6].parameters.clearColor, ember.nodes[6].parameters.clearColor);
for (const graph of [midnight, ember]) {
assert.equal(graph.schemaVersion, 2);
assert.equal(graph.revision, 1);
assert.equal(graph.nodes[6].executor.key, "legacy_forward");
assert.deepEqual(graph.nodes[6].inputs.colorTarget, { node: "surface", socket: "surface" });
assert.equal(graph.nodes.at(-1).executor.key, "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]),
order.map((name) => presets[name].graphId),
[
["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"],
"preset_midnight",
"preset_ember",
"preset_hdr_fullscreen",
"preset_gpu_culling",
"preset_tone",
"preset_edges",
"preset_bloom",
"preset_combined",
],
);
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" },
for (const name of order) {
const graph = presets[name];
assert.deepEqual([graph.schemaVersion, graph.revision], [2, 1]);
assert.equal(
new Set(graph.nodes.map((node) => node.id)).size,
graph.nodes.length,
);
assert.deepEqual(
graph.nodes.map((node) => [node.id, node.executor.key]),
sequences[name],
);
assert.equal(
graph.nodes.filter((node) => node.executor.key === "frame_out").length,
1,
);
assert.ok(
graph.nodes.every(
(node) => !["surface_target", "present"].includes(node.executor.key),
),
);
assert.ok(!graph.nodes.some((node) => node.id === "copy"));
}
});
test("presets preserve common mesh, texture, query, pipeline, culling and post wiring", () => {
const removed = [
"texture_spec",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"visibility_flags",
];
for (const [name, graph] of Object.entries(presets.renderGraphPresets)) {
const byId = Object.fromEntries(graph.nodes.map((node) => [node.id, node]));
assert.deepEqual(byId.query.parameters, {
visiblePredicate: "required_true",
visibleDefault: true,
frustumCulledPredicate: name === "culling" ? "required_false" : "any",
frustumCulledDefault: false,
});
assert.deepEqual(byId.query.inputs.mesh, { node: "mesh", socket: "mesh" });
assert.deepEqual(byId.query.inputs.isVisible, {
node: "mesh",
socket: "isVisible",
});
assert.deepEqual(byId.ground.inputs.mesh, {
node: "mesh",
socket: "mesh",
});
assert.deepEqual(byId.ground.inputs.draws, {
node: "query",
socket: "draws",
});
assert.deepEqual(byId.ground.inputs.depthTarget, {
node: "depth",
socket: "texture",
});
assert.equal(
graph.nodes.filter((node) => node.executor.key === "pipeline_registry")
.length,
1,
);
const pipelines = graph.nodes.filter(
(node) => node.executor.key === "pipeline",
);
assert.deepEqual(
pipelines.map((node) => node.parameters.pipeline),
["ground_plane", "gltf_standard", "gltf_standard_double_sided"],
);
for (const pipeline of pipelines)
assert.deepEqual(pipeline.inputs.activation, {
node: "registry",
socket: "activation",
});
assert.deepEqual(byId.pbr.inputs.colorTarget, {
node: "ground",
socket: "color",
});
assert.deepEqual(byId.pbr.inputs.depthTarget, {
node: "ground",
socket: "depth",
});
assert.deepEqual(byId.pbr_double.inputs.colorTarget, {
node: "pbr",
socket: "color",
});
assert.deepEqual(byId.pbr_double.inputs.depthTarget, {
node: "pbr",
socket: "depth",
});
assert.ok(
graph.nodes
.filter((node) => node.executor.key === "texture")
.every((node) => node.parameters.texture.dimension === "d2"),
);
assert.ok(
graph.nodes.every((node) => !removed.includes(node.executor.key)),
);
}
const cull = Object.fromEntries(
presets.culling.nodes.map((node) => [node.id, node]),
);
assert.deepEqual(cull.cull.parameters, { camera: "active" });
assert.deepEqual(cull.cull.inputs, {
mesh: { node: "mesh", socket: "mesh" },
localAabbs: { node: "mesh", socket: "localAabbs" },
});
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(cull.query.inputs.isFrustumCulled, {
node: "cull",
socket: "isFrustumCulled",
});
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"],
for (const name of ["tone", "edges", "bloom", "combined"])
assert.ok(!presets[name].nodes.some((node) => node.id === "copy"));
const finalSource = {
hdr: "pbr_double",
culling: "pbr_double",
tone: "tone",
edges: "tone",
bloom: "tone",
combined: "tone",
midnight: "pbr_double",
ember: "pbr_double",
};
for (const name of order)
assert.deepEqual(presets[name].nodes.at(-1).inputs.color, {
node: finalSource[name],
socket: "color",
});
assert.equal(
presets.tone.nodes.find((node) => node.id === "tone").inputs.source.node,
"pbr_double",
);
assert.equal(
presets.edges.nodes.find((node) => node.id === "tone").inputs.source.node,
"edges",
);
assert.equal(
presets.bloom.nodes.find((node) => node.id === "tone").inputs.source.node,
"composite",
);
assert.equal(
presets.combined.nodes.find((node) => node.id === "tone").inputs.source
.node,
"edges",
);
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",
});
});