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:
@@ -0,0 +1,269 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
renderGraphComposition,
|
||||
renderGraphNodeGroups,
|
||||
renderGraphPresets,
|
||||
} from "../src/catalog";
|
||||
import { compileFxNodeRenderGraph, encodeRenderGraph } from "../src/compiler";
|
||||
import { bindFxNodeHeadless } from "../../../vendor/fxnode/src/headless-runtime";
|
||||
import { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile";
|
||||
|
||||
const baseGraph = {
|
||||
id: "scene",
|
||||
resources: {
|
||||
buffers: [
|
||||
{ id: "node-positions", array: "nodePositions", usage: ["storage"] },
|
||||
{ id: "node-rotors", array: "nodeRotors", usage: ["storage"] },
|
||||
{ id: "node-scales", array: "nodeScales", usage: ["storage"] },
|
||||
{ id: "mesh-info", array: "meshInfo", usage: ["storage"] },
|
||||
{ id: "camera-matrices", array: "cameraMatrices", usage: ["storage"] },
|
||||
{ id: "geometry-1-positions", array: "positions", usage: ["vertex"] },
|
||||
{ id: "geometry-1-normals", array: "normals", usage: ["vertex"] },
|
||||
],
|
||||
textures: [
|
||||
{
|
||||
id: "hdr",
|
||||
format: "rgba16float",
|
||||
size: ["canvas", "canvas", 1],
|
||||
usage: ["render", "sampled"],
|
||||
},
|
||||
{
|
||||
id: "depth",
|
||||
format: "depth24plus",
|
||||
size: ["canvas", "canvas", 1],
|
||||
usage: ["render"],
|
||||
},
|
||||
],
|
||||
samplers: [{ id: "linear", descriptor: { magFilter: "linear" } }],
|
||||
},
|
||||
pipelines: {
|
||||
compute: [],
|
||||
render: [
|
||||
{
|
||||
id: "forward-pbr-0",
|
||||
code: "original pbr shader",
|
||||
vertex: {
|
||||
entry: "vertex",
|
||||
buffers: [
|
||||
{
|
||||
arrayStride: 16,
|
||||
attributes: [
|
||||
{ format: "float32x3", offset: 0, shaderLocation: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
arrayStride: 16,
|
||||
attributes: [
|
||||
{ format: "float32x3", offset: 0, shaderLocation: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
arrayStride: 16,
|
||||
attributes: [
|
||||
{ format: "float32x2", offset: 0, shaderLocation: 2 },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
fragment: {
|
||||
entry: "fragment",
|
||||
targets: [{ format: "rgba16float" }],
|
||||
},
|
||||
depthStencil: {
|
||||
format: "depth24plus",
|
||||
depth_write_enabled: true,
|
||||
depth_compare: "less",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "present",
|
||||
code: "present shader",
|
||||
vertex: { entry: "vertex", buffers: [] },
|
||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
passes: [
|
||||
{
|
||||
id: "forward-1--1",
|
||||
type: "render",
|
||||
pipeline: "forward-pbr-0",
|
||||
bindings: [{ group: 0, binding: 0, resource: "unrelated" }],
|
||||
color: [{ resource: "hdr", clear: [0, 0, 0, 1] }],
|
||||
depth: { resource: "depth", clear: 1 },
|
||||
vertexBuffers: [
|
||||
{ slot: 0, resource: "geometry-1-positions" },
|
||||
{ slot: 1, resource: "geometry-1-normals" },
|
||||
{ slot: 2, resource: "geometry-1-uvs" },
|
||||
],
|
||||
draw: { vertices: 3, instances: 1 },
|
||||
},
|
||||
{
|
||||
id: "present",
|
||||
type: "render",
|
||||
pipeline: "present",
|
||||
after: ["forward-1--1"],
|
||||
bindings: [
|
||||
{ group: 0, binding: 0, resource: "hdr" },
|
||||
{ group: 0, binding: 1, resource: "linear" },
|
||||
],
|
||||
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
|
||||
draw: { vertices: 3 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test("catalog exposes grouped, materially rich render graph nodes", () => {
|
||||
assert.equal(renderGraphNodeGroups.length, 6);
|
||||
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 25);
|
||||
for (const item of renderGraphNodeGroups.flatMap((group) => group.items))
|
||||
assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId);
|
||||
assert.doesNotThrow(() => compileFxNodeComposition(renderGraphComposition));
|
||||
});
|
||||
|
||||
test("Color Grade exposes Blender-style grading modes and migrates version 1 nodes", () => {
|
||||
const definition = renderGraphComposition.nodes["yawn.rg.post.color-grade"];
|
||||
assert.equal(definition.version, 2);
|
||||
assert.deepEqual(
|
||||
definition.ui
|
||||
.filter((row) => row.kind === "widget" && row.widget === "grading-wheels")
|
||||
.map((row) => row.bindings.map((binding) => binding.title)),
|
||||
[
|
||||
["Lift", "Gamma", "Gain"],
|
||||
["Offset", "Power", "Slope"],
|
||||
],
|
||||
);
|
||||
assert.deepEqual(definition.parameters.mode.enum, [
|
||||
"Lift/Gamma/Gain",
|
||||
"Offset/Power/Slope",
|
||||
"White Point",
|
||||
]);
|
||||
|
||||
const legacy = structuredClone(
|
||||
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
|
||||
) as any;
|
||||
legacy.catalogVersion = 1;
|
||||
const grade = legacy.nodes.find((node: any) => node.id === "grade");
|
||||
grade.typeVersion = 1;
|
||||
grade.parameters = Object.fromEntries(
|
||||
["exposure", "saturation", "tint", "tintAmount"].map((key) => [
|
||||
key,
|
||||
grade.parameters[key],
|
||||
]),
|
||||
);
|
||||
const runtime = bindFxNodeHeadless(
|
||||
compileFxNodeComposition(renderGraphComposition),
|
||||
);
|
||||
const decoded = runtime.decodeGraphDocument(legacy);
|
||||
assert.equal(decoded.ok, true);
|
||||
if (!decoded.ok) return;
|
||||
const migrated = decoded.value.nodes.grade!;
|
||||
assert.equal(migrated.known, true);
|
||||
assert.equal(migrated.typeVersion, 2);
|
||||
assert.equal(migrated.parameters.mode.value, "Lift/Gamma/Gain");
|
||||
assert.equal(migrated.parameters.contrast.value, 1);
|
||||
assert.equal(migrated.parameters.outputTemperature.value, 6500);
|
||||
});
|
||||
|
||||
test("prepared loadouts are connected to one Present output", () => {
|
||||
assert.deepEqual(
|
||||
renderGraphPresets.map((preset) => preset.id),
|
||||
["beauty", "normals", "depth", "clay", "cinematic", "blueprint"],
|
||||
);
|
||||
assert.equal(renderGraphPresets[0]!.name, "Standard");
|
||||
for (const preset of renderGraphPresets) {
|
||||
assert.equal(
|
||||
preset.layout.nodes.filter(
|
||||
(node) => node.typeId === "yawn.rg.output.present",
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assert.equal(preset.layout.links.length, preset.layout.nodes.length - 1);
|
||||
const left = Math.min(
|
||||
...preset.layout.nodes.map((node) => node.position.x),
|
||||
);
|
||||
const right = Math.max(
|
||||
...preset.layout.nodes.map((node) => node.position.x + node.size.x),
|
||||
);
|
||||
assert.ok(right - left <= 650, `${preset.name} is too wide for the editor`);
|
||||
}
|
||||
});
|
||||
|
||||
test("wire encoder is stable and rejects unsupported numbers", () => {
|
||||
assert.equal(
|
||||
encodeRenderGraph({ z: [true, "x"], a: 1 }),
|
||||
'(yawn-graph 1 (object (field "a" 1) (field "z" (array true "x"))))',
|
||||
);
|
||||
assert.throws(() => encodeRenderGraph({ value: Number.NaN }), /finite/u);
|
||||
});
|
||||
|
||||
test("normals loadout replaces scene forward shading without mutating the base", () => {
|
||||
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
|
||||
const serialized = compileFxNodeRenderGraph(normals.layout, baseGraph);
|
||||
assert.match(serialized, /scene-fxnode-normals/u);
|
||||
assert.match(serialized, /rg-normals-detailed/u);
|
||||
assert.match(serialized, /normalize\(input\.normal\) \* 0\.5 \+ 0\.5/u);
|
||||
assert.match(serialized, /camera-matrices/u);
|
||||
assert.equal(baseGraph.passes[0].pipeline, "forward-pbr-0");
|
||||
assert.equal(baseGraph.pipelines.render.length, 2);
|
||||
});
|
||||
|
||||
test("cinematic loadout inserts ordered sampled post passes", () => {
|
||||
const cinematic = renderGraphPresets.find(
|
||||
(preset) => preset.id === "cinematic",
|
||||
)!;
|
||||
const serialized = compileFxNodeRenderGraph(cinematic.layout, baseGraph);
|
||||
assert.match(serialized, /rg-effect-pass-0-bloom/u);
|
||||
assert.match(serialized, /rg-effect-pass-1-grade/u);
|
||||
assert.match(serialized, /rg-effect-pass-2-vignette/u);
|
||||
assert.match(serialized, /rg-effect-2-vignette/u);
|
||||
assert.match(serialized, /let liftBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /let gammaBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /let gainBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /color = \(color - vec3<f32>\(0\.18000\)\)/u);
|
||||
});
|
||||
|
||||
test("Color Grade compiles offset and white-point modes into pixel math", () => {
|
||||
const loadout = structuredClone(
|
||||
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
|
||||
) as any;
|
||||
const grade = loadout.nodes.find((node: any) => node.id === "grade");
|
||||
grade.parameters.mode = { kind: "string", value: "Offset/Power/Slope" };
|
||||
grade.parameters.offset = { kind: "number", value: 0.08 };
|
||||
grade.parameters.power = { kind: "number", value: 1.12 };
|
||||
grade.parameters.slope = { kind: "number", value: 0.94 };
|
||||
let serialized = compileFxNodeRenderGraph(loadout, baseGraph);
|
||||
assert.match(serialized, /let offsetBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /let powerBalance = vec3<f32>/u);
|
||||
assert.match(serialized, /color \* slopeBalance \+ offsetBalance/u);
|
||||
|
||||
grade.parameters.mode = { kind: "string", value: "White Point" };
|
||||
grade.parameters.inputTemperature = { kind: "number", value: 7200 };
|
||||
grade.parameters.outputTemperature = { kind: "number", value: 4800 };
|
||||
grade.parameters.outputTint = { kind: "number", value: -0.12 };
|
||||
serialized = compileFxNodeRenderGraph(loadout, baseGraph);
|
||||
assert.match(serialized, /let whitePointAdaptation = vec3<f32>/u);
|
||||
assert.match(serialized, /color \*= whitePointAdaptation/u);
|
||||
});
|
||||
|
||||
test("blueprint loadout converts normals into blue paper and linework", () => {
|
||||
const blueprint = renderGraphPresets.find(
|
||||
(preset) => preset.id === "blueprint",
|
||||
)!;
|
||||
const serialized = compileFxNodeRenderGraph(blueprint.layout, baseGraph);
|
||||
assert.match(serialized, /scene-fxnode-normals-edges/u);
|
||||
assert.match(serialized, /vec3<f32>\(0\.008, 0\.045, 0\.11\)/u);
|
||||
assert.match(serialized, /vec3<f32>\(0\.68, 0\.94, 1\.0\)/u);
|
||||
assert.match(serialized, /fract\(input\.uv \* vec2<f32>\(24\.0, 16\.0\)\)/u);
|
||||
assert.match(serialized, /, line\), 1\.0\)/u);
|
||||
});
|
||||
|
||||
test("compiler rejects a disconnected output", () => {
|
||||
const disconnected = structuredClone(renderGraphPresets[0]!.layout);
|
||||
disconnected.links = [];
|
||||
assert.throws(
|
||||
() => compileFxNodeRenderGraph(disconnected, baseGraph),
|
||||
/not connected to Scene/u,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { mkdtemp, readdir, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const directory = fileURLToPath(new URL(".", import.meta.url));
|
||||
const output = await mkdtemp(join(tmpdir(), "yawn-fxnode-rg-tests-"));
|
||||
|
||||
try {
|
||||
const entries = (await readdir(directory))
|
||||
.filter((name) => name.endsWith(".test.ts"))
|
||||
.sort()
|
||||
.map((name) => join(directory, name));
|
||||
await build({
|
||||
entryPoints: entries,
|
||||
outdir: output,
|
||||
entryNames: "[name]",
|
||||
outExtension: { ".js": ".mjs" },
|
||||
bundle: true,
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node20",
|
||||
sourcemap: "inline",
|
||||
});
|
||||
const tests = entries.map((entry) =>
|
||||
join(output, `${basename(entry, ".ts")}.mjs`),
|
||||
);
|
||||
const result = spawnSync(process.execPath, ["--test", ...tests], {
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
process.exitCode = result.status ?? 1;
|
||||
} finally {
|
||||
await rm(output, { force: true, recursive: true });
|
||||
}
|
||||
Reference in New Issue
Block a user