Add FxNode render graph playground

Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup.

Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-24 17:45:48 +00:00
co-authored by heaust
parent 951449db0b
commit 6153223fd5
27 changed files with 4672 additions and 207 deletions
+82 -6
View File
@@ -20,16 +20,18 @@ if (typeof globalThis.ImageBitmap === "undefined")
value: class ImageBitmap {},
});
test("empty scenes clear without drawing placeholder geometry", async () => {
test("empty scenes use neutral authored defaults without placeholder geometry", async () => {
const scene = await createScene();
const emptyPass = latestGraph(scene).passes.find(
(pass: any) => pass.id === "forward-empty",
);
assert.equal(emptyPass.draw.vertices, 0);
assert.deepEqual(emptyPass.color[0].clear, [0, 0, 0, 1]);
assert.deepEqual(Array.from(scene.array("sceneAccent").row(0)), [1, 1, 1, 1]);
});
test("opaque PBR graph fuses the background and compacts sparse lights", async () => {
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene);
await material.ready;
@@ -46,19 +48,35 @@ test("opaque PBR graph fuses the background and compacts sparse lights", async (
assert.equal(pipeline.primitive.frontFace, "ccw");
assert.equal(
graph.passes.some((pass: any) => pass.id === "studio-background"),
graph.passes.some((pass: any) => pass.id.includes("background")),
false,
);
assert.match(present.code, /fn studioBackground\(/u);
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 0]);
assert.doesNotMatch(JSON.stringify(graph), /studio/iu);
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 1]);
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u);
assert.doesNotMatch(pipeline.code, /defaultLight|studioEnvironment/u);
assert.doesNotMatch(pipeline.code, /0\.4, 0\.7, 0\.6/u);
assert.match(
pipeline.code,
/let ambient = vec3<f32>\(bitcast<f32>\(clusters\[1012\]\)/u,
);
assert.match(pipeline.code, /fn directContribution/u);
assert.match(pipeline.code, /fn directionalContribution/u);
assert.equal(
forward.bindings.find((binding: any) => binding.binding === 10)?.resource,
"directional-lights",
);
assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u);
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
assert.match(pipeline.code, /pointCount <= 500u/u);
assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u);
assert.match(
pipeline.code,
/let sourceRoughness = clamp\(roughness \+ max\(state\.z/u,
);
assert.match(pipeline.code, /let spotCount = clusters\[511\]/u);
assert.match(pipeline.code, /spotCount <= 500u/u);
});
@@ -103,9 +121,14 @@ test("transmission renders directly into a composited layer with five taps", asy
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
assert.equal(
graph.passes.some((pass: any) => pass.id === "studio-background"),
graph.passes.some((pass: any) => pass.id === "scene-color-clear"),
true,
);
const sceneColorClear = graph.passes.find(
(pass: any) => pass.id === "scene-color-clear",
);
assert.equal(sceneColorClear.draw.vertices, 0);
assert.deepEqual(sceneColorClear.color[0].clear, [0, 0, 0, 1]);
assert.equal(
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
false,
@@ -124,6 +147,27 @@ test("transmission renders directly into a composited layer with five taps", asy
assert.match(pipeline.code, /transmitted \*= pow/u);
});
test("clear color is explicit scene data used by empty and forward graphs", async () => {
const clearColor = [0.03, 0.06, 0.09, 1] as const;
const scene = await createScene({ clearColor });
const empty = latestGraph(scene).passes.find(
(pass: any) => pass.id === "forward-empty",
);
assert.deepEqual(empty.color[0].clear, clearColor);
const material = new PBRMaterial(scene);
await material.ready;
const mesh = detailedMesh(scene, material);
await mesh.ready;
const graph = latestGraph(scene);
assert.deepEqual(forwardPasses(graph)[0].color[0].clear, clearColor);
assert.deepEqual(
graph.passes.find((pass: any) => pass.id === "present").color[0].clear,
clearColor,
);
});
test("post-processing keeps the conservative transmission copy", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene, { transmission: 1 });
@@ -273,3 +317,35 @@ test("batchGraphUpdates compiles once for multiple mesh registrations", async ()
assert.equal(testCore(scene).graphs.length - before, 1);
assert.equal(forwardPasses(latestGraph(scene)).length, 2);
});
test("render graph snapshots expose isolated scene-owned declarations", async () => {
const scene = await createScene();
const source: any = await scene.getRenderGraph();
assert.equal(source.id, "scene");
assert.ok(
source.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
);
source.id = "mutated";
source.resources.buffers.length = 0;
const next: any = await scene.getRenderGraph();
assert.equal(next.id, "scene");
assert.ok(
next.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
);
});
test("serialized render graphs compile into explicitly selectable loadouts", async () => {
const scene = await createScene();
const core = testCore(scene);
const serialized = core.serializedGraphs.at(-1)!;
const before = core.graphs.length;
const loadoutId = await scene.compileRenderGraph(serialized);
await scene.setLoadout(loadoutId);
assert.equal(core.graphs.length, before + 1);
assert.equal(loadoutId, `loadout-${before + 1}`);
assert.equal(core.loadouts.at(-1), loadoutId);
});