Expand render graph playground editing

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-25 05:18:14 +00:00
co-authored by heaust
parent d71da85307
commit e7546e4b78
20 changed files with 3058 additions and 303 deletions
+252 -1
View File
@@ -39,6 +39,33 @@ const dataOutput: FxNodeSocketDefinition = {
showValue: false,
};
const physicalOutput = (
title: string,
type: "resource" | "pipeline" | "pass",
): FxNodeSocketDefinition => ({
title,
direction: "output",
type,
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
});
const physicalInput = (
title: string,
type: "resource" | "pipeline" | "pass",
maxIncomingLinks = 1,
): FxNodeSocketDefinition => ({
title,
direction: "input",
type,
maxIncomingLinks,
visible: true,
value: null,
showValue: false,
});
function stageNode(
title: string,
style: string,
@@ -100,6 +127,45 @@ const stringParameter = (value: string, values?: readonly string[]) => ({
...(values ? { enum: values } : {}),
});
function physicalNode(
title: string,
style: string,
sockets: FxNodeDefinition["sockets"],
declaration: object,
code?: string,
): FxNodeDefinition {
const definition = stageNode(
title,
style,
{
details: stringParameter("Physical render graph declaration"),
declaration: stringParameter(JSON.stringify(declaration)),
...(code === undefined ? {} : { code: stringParameter(code) }),
},
sockets,
);
return {
...definition,
ui: [
{ kind: "parameter", parameter: "details", title: "Details" },
{ kind: "hidden", target: "parameter", parameter: "declaration" },
...(code === undefined
? []
: [
{
kind: "hidden" as const,
target: "parameter" as const,
parameter: "code",
},
]),
...Object.keys(sockets).map((socket) => ({
kind: "socket" as const,
socket,
})),
],
};
}
const nodes = {
"yawn.rg.input.scene": stageNode(
"Scene",
@@ -126,6 +192,92 @@ const nodes = {
{ data: dataOutput },
),
"yawn.rg.physical.frame-in": physicalNode(
"Frame In",
"frame",
{ frame: physicalOutput("Frame", "pass") },
{ kind: "frame-in" },
),
"yawn.rg.physical.frame-out": physicalNode(
"Frame Out",
"frame",
{ after: physicalInput("Final passes", "pass", 64) },
{ kind: "frame-out" },
),
"yawn.rg.physical.buffer": physicalNode(
"Buffer",
"buffer",
{ resource: physicalOutput("Buffer", "resource") },
{ id: "buffer", array: "signals", usage: ["storage"] },
),
"yawn.rg.physical.texture": physicalNode(
"Texture",
"texture",
{ resource: physicalOutput("Texture", "resource") },
{
id: "texture",
size: ["canvas", "canvas", 1],
format: "rgba8unorm",
usage: ["render", "sampled"],
},
),
"yawn.rg.physical.sampler": physicalNode(
"Sampler",
"sampler",
{ resource: physicalOutput("Sampler", "resource") },
{ id: "sampler", magFilter: "linear", minFilter: "linear" },
),
"yawn.rg.physical.external": physicalNode(
"External Resource",
"resource",
{ resource: physicalOutput("Resource", "resource") },
{ id: "external", external: true },
),
"yawn.rg.physical.render-pipeline": physicalNode(
"Render Pipeline",
"pipeline",
{ pipeline: physicalOutput("Render pipeline", "pipeline") },
{
id: "render-pipeline",
vertex: { entry: "vertex", buffers: [] },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
},
"@vertex fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }\n@fragment fn fragment() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }",
),
"yawn.rg.physical.compute-pipeline": physicalNode(
"Compute Pipeline",
"pipeline",
{ pipeline: physicalOutput("Compute pipeline", "pipeline") },
{ id: "compute-pipeline", entry: "main" },
"@compute @workgroup_size(1) fn main() {}",
),
"yawn.rg.physical.render-pass": physicalNode(
"Render Pass",
"pass",
{
after: physicalInput("After", "pass", 64),
pipeline: physicalInput("Pipeline", "pipeline"),
bindings: physicalInput("Bindings", "resource", 128),
color: physicalInput("Color attachments", "resource", 16),
depth: physicalInput("Depth attachment", "resource"),
vertex: physicalInput("Vertex buffers", "resource", 32),
index: physicalInput("Index buffer", "resource"),
pass: physicalOutput("Pass", "pass"),
},
{ id: "render-pass", type: "render", draw: { vertices: 3 } },
),
"yawn.rg.physical.compute-pass": physicalNode(
"Compute Pass",
"pass",
{
after: physicalInput("After", "pass", 64),
pipeline: physicalInput("Pipeline", "pipeline"),
bindings: physicalInput("Bindings", "resource", 128),
pass: physicalOutput("Pass", "pass"),
},
{ id: "compute-pass", type: "compute", dispatch: [1, 1, 1] },
),
"yawn.rg.mode.beauty": stageNode("Standard / PBR", "geometry"),
"yawn.rg.mode.normals": stageNode("World Normals", "geometry"),
"yawn.rg.mode.depth": stageNode("Camera Depth", "geometry", {
@@ -391,11 +543,22 @@ const nodes = {
export const renderGraphComposition = {
schemaVersion: 2,
id: "yawn.render-graph",
version: 2,
version: 3,
compatibility: { wildcardInputTypes: [] },
socketTypes: {
surface: { title: "Surface", color: "#43b9ed", acceptsFrom: ["surface"] },
data: { title: "Data", color: "#a8b7c5", acceptsFrom: ["data"] },
resource: {
title: "Resource",
color: "#67c7ac",
acceptsFrom: ["resource"],
},
pipeline: {
title: "Pipeline",
color: "#d69bea",
acceptsFrom: ["pipeline"],
},
pass: { title: "Pass", color: "#f0aa66", acceptsFrom: ["pass"] },
},
theme: {
background: "#081522",
@@ -431,6 +594,13 @@ export const renderGraphComposition = {
},
nodeStyles: {
input: { header: "#2676a5" },
frame: { header: "#247f91" },
buffer: { header: "#287c68" },
texture: { header: "#277493" },
sampler: { header: "#426f8c" },
resource: { header: "#4c6678" },
pipeline: { header: "#77518b" },
pass: { header: "#a36632" },
geometry: { header: "#3d7797" },
lighting: { header: "#a27332" },
post: { header: "#7253a1" },
@@ -456,6 +626,87 @@ export interface RenderGraphCatalogGroup {
}
export const renderGraphNodeGroups: readonly RenderGraphCatalogGroup[] = [
{
id: "physical-resources",
title: "Physical resources",
icon: "▦",
items: [
{
typeId: "yawn.rg.physical.buffer",
title: "Buffer",
description: "Shared row-backed GPU buffer declaration",
keywords: "storage uniform vertex index",
},
{
typeId: "yawn.rg.physical.texture",
title: "Texture",
description: "Sampled, storage, render, or depth texture",
keywords: "attachment image target",
},
{
typeId: "yawn.rg.physical.sampler",
title: "Sampler",
description: "Texture sampling and addressing state",
keywords: "filter anisotropy",
},
{
typeId: "yawn.rg.physical.external",
title: "External Resource",
description: "Canvas or application-supplied GPU resource",
keywords: "surface swap chain imported",
},
],
},
{
id: "physical-pipelines",
title: "Physical pipelines",
icon: "⌁",
items: [
{
typeId: "yawn.rg.physical.render-pipeline",
title: "Render Pipeline",
description: "Vertex/fragment WGSL and fixed-function state",
keywords: "shader wgsl primitive blend depth",
},
{
typeId: "yawn.rg.physical.compute-pipeline",
title: "Compute Pipeline",
description: "Compute WGSL and entry point",
keywords: "shader wgsl dispatch",
},
],
},
{
id: "physical-passes",
title: "Physical passes",
icon: "▶",
items: [
{
typeId: "yawn.rg.physical.render-pass",
title: "Render Pass",
description: "Bindings, attachments, buffers, and draw call",
keywords: "pipeline color depth vertex index",
},
{
typeId: "yawn.rg.physical.compute-pass",
title: "Compute Pass",
description: "Bindings, dependencies, and dispatch dimensions",
keywords: "pipeline workgroups",
},
{
typeId: "yawn.rg.physical.frame-in",
title: "Frame In",
description: "Start of synchronized work for one frame",
keywords: "boundary begin source",
},
{
typeId: "yawn.rg.physical.frame-out",
title: "Frame Out",
description: "Submission boundary for terminal passes",
keywords: "boundary end present",
},
],
},
{
id: "inputs",
title: "Inputs",
+169 -33
View File
@@ -4,6 +4,7 @@ import type {
GraphSnapshot,
ParameterValue,
} from "fxnode";
import { isPhysicalRenderGraph, reconstructRenderGraph } from "./physical";
type PlainObject = Record<string, any>;
type GraphSource = GraphLayoutV2 | GraphSnapshot;
@@ -113,22 +114,38 @@ function activeChain(layout: GraphSource): any[] {
return chain;
}
function debugShader(mode: string, detailed: boolean, node: any): string {
function passResources(pass: PlainObject): string[] {
return [
...(pass.bindings ?? []),
...(pass.color ?? []),
...(pass.vertexBuffers ?? []),
...(pass.depth ? [pass.depth] : []),
...(pass.indexBuffer ? [pass.indexBuffer] : []),
]
.map((item: PlainObject) => item?.resource)
.filter(
(resource: unknown): resource is string => typeof resource === "string",
);
}
function debugShader(mode: string, normalBuffer: boolean, node: any): string {
const contrast = Number(value(node, "contrast", 1.35));
const clay = value<readonly number[]>(node, "color", [0.36, 0.68, 0.9, 1]);
const usesNormals = mode === "normals" || mode === "unlit";
const usesObjectId = mode === "object-id";
const fragment =
mode === "normals"
? "return vec4<f32>(normalize(input.normal) * 0.5 + 0.5, 1.0);"
: mode === "depth"
? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${Math.max(0.01, contrast).toFixed(4)}); return vec4<f32>(vec3<f32>(depth), 1.0);`
? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${(Math.max(0.01, contrast) * 0.12).toFixed(4)}); return vec4<f32>(vec3<f32>(depth), 1.0);`
: mode === "unlit"
? `let base = vec3<f32>(${Number(clay[0] ?? 0.36).toFixed(5)}, ${Number(clay[1] ?? 0.68).toFixed(5)}, ${Number(clay[2] ?? 0.9).toFixed(5)}); let light = 0.32 + max(dot(normalize(input.normal), normalize(vec3<f32>(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4<f32>(base * light, 1.0);`
: "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3<f32>(0.0, 2.1, 4.2) + seed * 6.28318); return vec4<f32>(color, 1.0);";
return `
struct VertexOutput {
@invariant @builtin(position) position: vec4<f32>,
@location(0) normal: vec3<f32>,
@location(1) @interpolate(flat) objectId: u32,
${usesNormals ? "@location(0) normal: vec3<f32>," : ""}
${usesObjectId ? `@location(${usesNormals ? 1 : 0}) @interpolate(flat) objectId: u32,` : ""}
}
@group(0) @binding(0) var<storage, read> positions: array<vec4<f32>>;
@group(0) @binding(1) var<storage, read> rotors: array<vec4<f32>>;
@@ -140,7 +157,7 @@ fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
}
@vertex fn vertex(
@location(0) point: vec3<f32>,
${detailed ? "@location(1) localNormal: vec3<f32>," : ""}
${normalBuffer ? "@location(1) localNormal: vec3<f32>," : ""}
@builtin(instance_index) packed: u32,
) -> VertexOutput {
let instance = packed & 65535u;
@@ -152,8 +169,8 @@ fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
dot(cameraMatrices[2], world), dot(cameraMatrices[3], world));
if (cameraMatrices[4].w == 0.0) { output.position = world; }
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), output.position, meshInfo[instance * 4u + 2u] != 0u);
output.normal = normalize(rotate(rotors[instance], ${detailed ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));
output.objectId = instance;
${usesNormals ? `output.normal = normalize(rotate(rotors[instance], ${normalBuffer ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));` : ""}
${usesObjectId ? "output.objectId = instance;" : ""}
return output;
}
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
@@ -170,6 +187,10 @@ function applyDebugMode(graph: PlainObject, node: any): void {
const byId = new Map(
renderPipelines.map((pipeline: any) => [pipeline.id, pipeline]),
);
const passesById = new Map(
graph.passes.map((pass: PlainObject) => [pass.id, pass]),
);
const detachedComputePasses = new Set<string>();
const generated = new Map<string, string>();
for (const pass of graph.passes) {
if (
@@ -187,19 +208,20 @@ function applyDebugMode(graph: PlainObject, node: any): void {
const format = original.fragment?.targets?.[0]?.format;
if (typeof format !== "string")
throw new Error(`Forward pipeline ${pass.pipeline} has no color target`);
const key = `${mode}:${detailed}:${format}`;
const normalBuffer = (mode === "normals" || mode === "unlit") && detailed;
const key = `${mode}:${normalBuffer}:${format}`;
let id = generated.get(key);
if (!id) {
id = `rg-${mode}-${detailed ? "detailed" : "basic"}`;
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}`;
let suffix = 1;
while (byId.has(id))
id = `rg-${mode}-${detailed ? "detailed" : "basic"}-${suffix++}`;
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}-${suffix++}`;
const pipeline = {
id,
code: debugShader(mode, detailed, node),
code: debugShader(mode, normalBuffer, node),
vertex: {
entry: "vertex",
buffers: original.vertex.buffers.slice(0, detailed ? 2 : 1),
buffers: original.vertex.buffers.slice(0, normalBuffer ? 2 : 1),
},
fragment: { entry: "fragment", targets: [{ format }] },
depthStencil: original.depthStencil ?? null,
@@ -223,9 +245,37 @@ function applyDebugMode(graph: PlainObject, node: any): void {
resource,
}));
pass.vertexBuffers = (pass.vertexBuffers ?? []).filter(
(binding: any) => binding.slot === 0 || (detailed && binding.slot === 1),
(binding: any) =>
binding.slot === 0 || (normalBuffer && binding.slot === 1),
);
const used = new Set(passResources(pass));
pass.after = (pass.after ?? []).filter((dependency: string) => {
const candidate = passesById.get(dependency);
if (
candidate?.type !== "compute" ||
passResources(candidate).some((resource) => used.has(resource))
)
return true;
detachedComputePasses.add(dependency);
return false;
});
}
const retainedDependencies = new Set(
graph.passes.flatMap((pass: PlainObject) => pass.after ?? []),
);
graph.passes = graph.passes.filter((pass: PlainObject) => {
if (
!detachedComputePasses.has(pass.id) ||
retainedDependencies.has(pass.id)
)
return true;
const resources = new Set(passResources(pass));
return graph.passes.some(
(other: PlainObject) =>
other !== pass &&
passResources(other).some((resource) => resources.has(resource)),
);
});
}
type Rgb = readonly [number, number, number];
@@ -496,23 +546,25 @@ function applyEffects(graph: PlainObject, effects: any[]): void {
let after = Array.isArray(present.after) ? [...present.after] : [];
for (const [index, node] of effects.entries()) {
const suffix = `${index}-${String(node.id).replace(/[^a-z0-9-]/gi, "-")}`;
const output = `rg-effect-${suffix}`;
const final = index === effects.length - 1;
const output = final ? "canvas" : `rg-effect-${suffix}`;
const pipeline = `rg-effect-pipeline-${suffix}`;
const pass = `rg-effect-pass-${suffix}`;
textures.push({
id: output,
format: sourceTexture.format,
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
transient: true,
});
if (!final)
textures.push({
id: output,
format: sourceTexture.format,
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
transient: true,
});
pipelines.push({
id: pipeline,
code: effectShader(node),
vertex: { entry: "vertex", buffers: [] },
fragment: {
entry: "fragment",
targets: [{ format: sourceTexture.format }],
targets: [{ format: final ? "canvas" : sourceTexture.format }],
},
});
graph.passes.push({
@@ -530,28 +582,101 @@ function applyEffects(graph: PlainObject, effects: any[]): void {
input = output;
after = [pass];
}
sourceBinding.resource = input;
present.after = after;
graph.passes = graph.passes.filter((pass: PlainObject) => pass !== present);
}
function routeDebugModeToCanvas(graph: PlainObject, mode: string): void {
const present = graph.passes?.find(
(pass: PlainObject) =>
pass.type === "render" &&
pass.color?.some(
(attachment: PlainObject) => attachment.resource === "canvas",
),
);
const source = present?.bindings?.find(
(binding: PlainObject) => binding.group === 0 && binding.binding === 0,
)?.resource;
if (!present || typeof source !== "string")
throw new Error("Base render graph has no sampled presentation pass");
const producers = graph.passes.filter(
(pass: PlainObject) =>
pass.type === "render" &&
pass.color?.some(
(attachment: PlainObject) => attachment.resource === source,
) &&
String(pass.pipeline).startsWith(`rg-${mode}-`),
);
if (!producers.length)
throw new Error(`Debug mode ${mode} has no render passes`);
const pipelines = new Map(
(graph.pipelines?.render ?? []).map((pipeline: PlainObject) => [
pipeline.id,
pipeline,
]),
);
for (const pass of producers) {
for (const attachment of pass.color)
if (attachment.resource === source) attachment.resource = "canvas";
const pipeline = pipelines.get(pass.pipeline);
const targets = pipeline?.fragment?.targets;
if (!Array.isArray(targets) || !targets.length)
throw new Error(`Debug pipeline ${String(pass.pipeline)} has no target`);
targets[0].format = "canvas";
}
graph.passes = graph.passes.filter((pass: PlainObject) => pass !== present);
}
function pruneDeclarations(graph: PlainObject): void {
const usedResources = new Set(
(graph.passes ?? []).flatMap((pass: PlainObject) => passResources(pass)),
);
for (const kind of ["buffers", "textures", "samplers"])
if (Array.isArray(graph.resources?.[kind]))
graph.resources[kind] = graph.resources[kind].filter(
(resource: PlainObject) => usedResources.has(resource.id),
);
const renderPipelines = new Set(
(graph.passes ?? [])
.filter((pass: PlainObject) => pass.type === "render")
.map((pass: PlainObject) => pass.pipeline),
);
const computePipelines = new Set(
(graph.passes ?? [])
.filter((pass: PlainObject) => pass.type === "compute")
.map((pass: PlainObject) => pass.pipeline),
);
if (Array.isArray(graph.pipelines?.render))
graph.pipelines.render = graph.pipelines.render.filter(
(pipeline: PlainObject) => renderPipelines.has(pipeline.id),
);
if (Array.isArray(graph.pipelines?.compute))
graph.pipelines.compute = graph.pipelines.compute.filter(
(pipeline: PlainObject) => computePipelines.has(pipeline.id),
);
}
/**
* Compiles an FxNode loadout over a scene-authored graph and returns the exact
* S-expression consumed by `YawnCore.compileGraph`.
* Resolves an FxNode loadout into the concrete graph that Yawn will compile.
*/
export function compileFxNodeRenderGraph(
export function buildFxNodeRenderGraph(
layout: GraphSource,
baseGraph: object,
): string {
): object {
if (!plainRecord(baseGraph))
throw new TypeError("Base render graph must be plain data");
if (isPhysicalRenderGraph(layout)) return reconstructRenderGraph(layout);
const graph = structuredClone(baseGraph) as PlainObject;
const chain = activeChain(layout);
const mode = chain.findLast((node) => modeTypes.has(String(node.typeId)));
if (mode) applyDebugMode(graph, mode);
applyEffects(
graph,
chain.filter((node) => effectTypes.has(String(node.typeId))),
);
const effects = chain.filter((node) => effectTypes.has(String(node.typeId)));
if (mode && mode.typeId !== "yawn.rg.mode.beauty" && effects.length === 0)
routeDebugModeToCanvas(
graph,
String(mode.typeId).slice("yawn.rg.mode.".length),
);
else applyEffects(graph, effects);
pruneDeclarations(graph);
const suffix = chain
.filter(
(node) =>
@@ -562,5 +687,16 @@ export function compileFxNodeRenderGraph(
.map((stage) => (stage === "beauty" ? "standard" : stage))
.join("-");
graph.id = `${String(graph.id || "scene")}-fxnode-${suffix || "standard"}`;
return encodeRenderGraph(graph);
return graph;
}
/**
* Compiles an FxNode loadout over a scene-authored graph and returns the exact
* S-expression consumed by `YawnCore.compileGraph`.
*/
export function compileFxNodeRenderGraph(
layout: GraphSource,
baseGraph: object,
): string {
return encodeRenderGraph(buildFxNodeRenderGraph(layout, baseGraph));
}
+42 -5
View File
@@ -3,6 +3,7 @@ import {
type FxNode,
type FxNodeView,
type GraphLayoutV2,
type ParameterValue,
type Vec2,
} from "fxnode";
import { renderGraphComposition, renderGraphPresets } from "./catalog";
@@ -17,6 +18,7 @@ export interface RenderGraphEditorOptions {
readonly initialLayout?: GraphLayoutV2;
readonly workerUrl: string | URL;
readonly onChange?: () => void;
readonly onSelectionChange?: (nodeIds: readonly string[]) => void;
readonly onAddNodeMenu?: (request: RenderGraphAddNodeRequest) => void;
readonly onError?: (error: unknown) => void;
}
@@ -24,6 +26,11 @@ export interface RenderGraphEditorOptions {
export interface RenderGraphEditor {
load(layout: GraphLayoutV2): Promise<void>;
save(): Promise<GraphLayoutV2>;
setParameter(
nodeId: string,
key: string,
value: ParameterValue,
): Promise<void>;
syncViewport(): Promise<void>;
whenRendered(): Promise<void>;
destroy(): Promise<void>;
@@ -78,6 +85,7 @@ export async function createRenderGraphEditor({
initialLayout = renderGraphPresets[0]!.layout,
workerUrl,
onChange = () => {},
onSelectionChange = () => {},
onAddNodeMenu = () => {},
onError = console.error,
}: RenderGraphEditorOptions): Promise<RenderGraphEditor> {
@@ -119,15 +127,21 @@ export async function createRenderGraphEditor({
await root.load(initialLayout);
const initialViewport = viewport(canvas);
sizeCanvas(canvas, initialViewport);
const initialPhysical =
initialLayout.metadata.schema === "yawn.render-graph/v1";
view = await root.attachView({
canvas,
viewport: initialViewport,
initialCamera: {
center: { x: 0, y: 10 },
center: initialPhysical ? { x: 320, y: 300 } : { x: 0, y: 10 },
zoom:
(canvas.ownerDocument.defaultView?.innerWidth ?? 1024) <= 800
? 0.9
: 1.1,
? initialPhysical
? 0.56
: 0.9
: initialPhysical
? 0.72
: 1.1,
},
});
canvas.tabIndex = 0;
@@ -267,20 +281,43 @@ export async function createRenderGraphEditor({
}),
);
subscriptions.push(root.onMutations(() => onChange()));
await fitGraph();
let selectionSignature = "";
const publishSelection = () => {
const nodeIds = activeView.getHostSnapshot().selection.nodeIds;
const signature = JSON.stringify(nodeIds);
if (signature === selectionSignature) return;
selectionSignature = signature;
onSelectionChange([...nodeIds]);
};
subscriptions.push(activeView.subscribeHost(publishSelection));
publishSelection();
if (initialPhysical) await activeView.whenRendered();
else await fitGraph();
return {
async load(layout) {
if (destroyed || !root)
throw new Error("Render graph editor is closed");
await root.load(layout);
await fitGraph();
if (layout.metadata.schema === "yawn.render-graph/v1")
await activeView.whenRendered();
else await fitGraph();
},
async save() {
if (destroyed || !root)
throw new Error("Render graph editor is closed");
return root.save();
},
async setParameter(nodeId, key, value) {
if (destroyed || !root)
throw new Error("Render graph editor is closed");
await root.dispatch({
type: "node.parameter",
id: nodeId as never,
key,
value,
});
},
syncViewport,
whenRendered: () => activeView.whenRendered(),
async destroy() {
+1
View File
@@ -1,3 +1,4 @@
export * from "./catalog";
export * from "./compiler";
export * from "./editor";
export * from "./physical";
+688
View File
@@ -0,0 +1,688 @@
import type {
GraphLayoutNodeV1,
GraphLayoutV2,
GraphLink,
GraphSnapshot,
JsonValue,
ParameterValue,
} from "fxnode";
import { renderGraphComposition } from "./catalog";
type PlainObject = Record<string, any>;
type GraphSource = GraphLayoutV2 | GraphSnapshot;
export const physicalRenderGraphSchema = "yawn.render-graph/v1";
const physicalTypes = {
frameIn: "yawn.rg.physical.frame-in",
frameOut: "yawn.rg.physical.frame-out",
buffer: "yawn.rg.physical.buffer",
texture: "yawn.rg.physical.texture",
sampler: "yawn.rg.physical.sampler",
external: "yawn.rg.physical.external",
renderPipeline: "yawn.rg.physical.render-pipeline",
computePipeline: "yawn.rg.physical.compute-pipeline",
renderPass: "yawn.rg.physical.render-pass",
computePass: "yawn.rg.physical.compute-pass",
} as const;
function plainRecord(value: unknown): value is PlainObject {
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function parameterString(node: any, key: string): string {
const parameter = node.parameters?.[key] as ParameterValue | undefined;
if (parameter?.kind !== "string")
throw new Error(
`${String(node.label || node.id)} has no ${key} declaration`,
);
return parameter.value;
}
function declaration(node: any): PlainObject {
let parsed: unknown;
try {
parsed = JSON.parse(parameterString(node, "declaration"));
} catch (error) {
throw new Error(
`${String(node.label || node.id)} has an invalid physical declaration`,
{ cause: error },
);
}
if (!plainRecord(parsed))
throw new Error(
`${String(node.label || node.id)} declaration must be an object`,
);
return parsed;
}
function cloned<T>(value: T): T {
return structuredClone(value);
}
function withoutResource(value: PlainObject): PlainObject {
const result = cloned(value);
delete result.resource;
return result;
}
function summarizeSize(value: unknown): string {
return Array.isArray(value) ? value.join(" × ") : "automatic size";
}
function drawSummary(pass: PlainObject): string {
if (pass.type === "compute") {
const dispatch = Array.isArray(pass.dispatch)
? pass.dispatch.join(" × ")
: "1 × 1 × 1";
return `${dispatch} workgroups · ${(pass.bindings ?? []).length} bindings`;
}
const draw = plainRecord(pass.draw) ? pass.draw : {};
const count = Number(draw.indices || draw.vertices || 3);
const primitive = draw.indices ? "indices" : "vertices";
const instances = Number(draw.instances ?? 1);
return `${count} ${primitive} × ${instances} · ${(pass.bindings ?? []).length} bindings · ${(pass.color ?? []).length} color`;
}
function layoutNode(
id: string,
typeId: keyof typeof renderGraphComposition.nodes,
label: string,
details: string,
value: PlainObject,
position: { readonly x: number; readonly y: number },
code?: string,
): GraphLayoutNodeV1 {
const definition = renderGraphComposition.nodes[typeId];
const parameters = Object.fromEntries(
Object.entries(definition.parameters).map(([key, schema]) => [
key,
cloned(schema.default),
]),
) as Record<string, ParameterValue>;
parameters.details = { kind: "string", value: details };
parameters.declaration = { kind: "string", value: JSON.stringify(value) };
if (code !== undefined) parameters.code = { kind: "string", value: code };
const sockets = Object.entries(definition.sockets).map(([key, socket]) => ({
id: `${id}:${key}` as GraphLayoutNodeV1["sockets"][number]["id"],
key,
label: socket.title,
direction: socket.direction,
dataType: socket.type,
accepts:
socket.direction === "input"
? cloned(
renderGraphComposition.socketTypes[
socket.type as keyof typeof renderGraphComposition.socketTypes
].acceptsFrom,
)
: [],
maxIncomingLinks: socket.maxIncomingLinks,
...(socket.value ? { defaultValue: cloned(socket.value.default) } : {}),
visible: socket.visible,
}));
return {
id: id as GraphLayoutNodeV1["id"],
typeId,
typeVersion: definition.version,
position,
size: {
x: typeId.includes("pass") ? 228 : 210,
y: Math.max(98, 47 + definition.ui.length * 24),
},
label,
parameters,
sockets,
muted: false,
collapsed: false,
extensions: { physical: true },
};
}
function layoutLink(
index: number,
from: GraphLayoutNodeV1,
fromSocket: string,
to: GraphLayoutNodeV1,
toSocket: string,
relation: string,
relationIndex = 0,
descriptor: PlainObject = {},
): GraphLink {
return {
id: `physical-link-${index}` as GraphLink["id"],
fromNodeId: from.id,
fromSocketId: `${from.id}:${fromSocket}` as GraphLink["fromSocketId"],
toNodeId: to.id,
toSocketId: `${to.id}:${toSocket}` as GraphLink["toSocketId"],
muted: false,
extensions: {
relation,
index: relationIndex,
descriptor: descriptor as JsonValue,
},
};
}
function resetPassRelations(pass: PlainObject): PlainObject {
const result = cloned(pass);
result.pipeline = "";
if (Object.hasOwn(result, "after")) result.after = [];
if (Object.hasOwn(result, "bindings")) result.bindings = [];
if (Object.hasOwn(result, "color")) result.color = [];
if (Object.hasOwn(result, "depth")) result.depth = null;
if (Object.hasOwn(result, "vertexBuffers")) result.vertexBuffers = [];
if (Object.hasOwn(result, "indexBuffer")) result.indexBuffer = null;
return result;
}
/** Expands a concrete Yawn graph into editable physical FxNode declarations. */
export function materializeRenderGraph(
graphValue: object,
preset = "custom",
): GraphLayoutV2 {
if (!plainRecord(graphValue))
throw new TypeError("Render graph must be plain data");
const graph = cloned(graphValue);
const buffers = Array.isArray(graph.resources?.buffers)
? graph.resources.buffers
: [];
const textures = Array.isArray(graph.resources?.textures)
? graph.resources.textures
: [];
const samplers = Array.isArray(graph.resources?.samplers)
? graph.resources.samplers
: [];
const renderPipelines = Array.isArray(graph.pipelines?.render)
? graph.pipelines.render
: [];
const computePipelines = Array.isArray(graph.pipelines?.compute)
? graph.pipelines.compute
: [];
const passes = Array.isArray(graph.passes) ? graph.passes : [];
const nodes: GraphLayoutNodeV1[] = [];
const links: GraphLink[] = [];
const resources = new Map<string, GraphLayoutNodeV1>();
const pipelines = new Map<string, GraphLayoutNodeV1>();
const passNodes = new Map<string, GraphLayoutNodeV1>();
const passLevels = new Map<string, number>();
const levelOf = (pass: PlainObject, visiting = new Set<string>()): number => {
const id = String(pass.id);
const known = passLevels.get(id);
if (known !== undefined) return known;
if (visiting.has(id)) return 0;
visiting.add(id);
const dependencies = (pass.after ?? [])
.map((dependency: string) =>
passes.find((candidate: PlainObject) => candidate.id === dependency),
)
.filter(Boolean)
.map((dependency: PlainObject) => levelOf(dependency, visiting));
visiting.delete(id);
const level = dependencies.length ? Math.max(...dependencies) + 1 : 0;
passLevels.set(id, level);
return level;
};
passes.forEach((pass: PlainObject) => levelOf(pass));
const addUnique = (
map: Map<string, GraphLayoutNodeV1>,
id: unknown,
node: GraphLayoutNodeV1,
kind: string,
) => {
if (typeof id !== "string" || !id)
throw new Error(`${kind} requires an ID`);
if (map.has(id)) throw new Error(`Duplicate ${kind} ID ${id}`);
map.set(id, node);
nodes.push(node);
};
const resourceEntries = [
...buffers.map((value: PlainObject, index: number) => ({
kind: "buffer",
value,
index,
})),
...textures.map((value: PlainObject, index: number) => ({
kind: "texture",
value,
index,
})),
...samplers.map((value: PlainObject, index: number) => ({
kind: "sampler",
value,
index,
})),
];
const resourceLevels = new Map<number, number>();
const resourceLevel = (id: string): number => {
const levels = passes
.filter((pass: PlainObject) =>
[
...(pass.bindings ?? []),
...(pass.color ?? []),
...(pass.vertexBuffers ?? []),
...(pass.depth ? [pass.depth] : []),
...(pass.indexBuffer ? [pass.indexBuffer] : []),
].some((item: PlainObject) => item?.resource === id),
)
.map((pass: PlainObject) => levelOf(pass));
return levels.length ? Math.min(...levels) : 0;
};
const resourcePosition = (id: string) => {
const level = resourceLevel(id);
const row = resourceLevels.get(level) ?? 0;
resourceLevels.set(level, row + 1);
return { x: 40 + level * 280, y: -420 - row * 142 };
};
for (const entry of resourceEntries) {
const typeId =
physicalTypes[entry.kind as "buffer" | "texture" | "sampler"];
const details =
entry.kind === "buffer"
? `${String(entry.value.array || "rows")} · ${(entry.value.usage ?? []).join(" + ") || "copy"}${entry.value.sync ? ` · ${entry.value.sync} sync` : ""}`
: entry.kind === "texture"
? `${String(entry.value.format || "format")} · ${summarizeSize(entry.value.size)} · ${(entry.value.usage ?? []).join(" + ") || "texture"}`
: `${Object.keys(entry.value.descriptor ?? {}).join(" · ") || "default filtering"}`;
const node = layoutNode(
`physical-${entry.kind}-${entry.index}`,
typeId,
`${entry.kind[0]!.toUpperCase()}${entry.kind.slice(1)} · ${String(entry.value.id)}`,
details,
entry.value,
resourcePosition(entry.value.id),
);
addUnique(resources, entry.value.id, node, "resource");
}
const referencedResources = new Set<string>();
for (const pass of passes) {
for (const item of [
...(pass.bindings ?? []),
...(pass.color ?? []),
...(pass.vertexBuffers ?? []),
...(pass.depth ? [pass.depth] : []),
...(pass.indexBuffer ? [pass.indexBuffer] : []),
]) {
if (typeof item?.resource === "string")
referencedResources.add(item.resource);
}
}
let externalIndex = 0;
for (const id of referencedResources) {
if (resources.has(id)) continue;
const node = layoutNode(
`physical-external-${externalIndex}`,
physicalTypes.external,
id === "canvas" ? "Canvas surface" : `External · ${id}`,
id === "canvas"
? "Current WebGPU swap-chain texture"
: "Externally supplied resource",
{ id, external: true },
resourcePosition(id),
);
externalIndex++;
addUnique(resources, id, node, "resource");
}
const pipelineEntries = [
...renderPipelines.map((value: PlainObject, index: number) => ({
kind: "render",
value,
index,
})),
...computePipelines.map((value: PlainObject, index: number) => ({
kind: "compute",
value,
index,
})),
];
for (const [layoutIndex, entry] of pipelineEntries.entries()) {
const value = cloned(entry.value);
const code = typeof value.code === "string" ? value.code : "";
delete value.code;
const details =
entry.kind === "render"
? `${String(value.vertex?.entry || "vertex")} → ${String(value.fragment?.entry || "fragment")} · ${value.fragment?.targets?.length ?? 0} targets · ${code.split("\n").length} WGSL lines`
: `${String(value.entry || "main")} · ${code.split("\n").length} WGSL lines`;
const consumerLevels = passes
.filter((pass: PlainObject) => pass.pipeline === value.id)
.map((pass: PlainObject) => levelOf(pass));
const pipelineLevel = consumerLevels.length
? Math.min(...consumerLevels)
: layoutIndex;
const pipelineRow = pipelineEntries
.slice(0, layoutIndex)
.filter((candidate) =>
passes.some(
(pass: PlainObject) =>
pass.pipeline === candidate.value.id &&
levelOf(pass) === pipelineLevel,
),
).length;
const node = layoutNode(
`physical-${entry.kind}-pipeline-${entry.index}`,
entry.kind === "render"
? physicalTypes.renderPipeline
: physicalTypes.computePipeline,
`${entry.kind === "render" ? "Render" : "Compute"} pipeline · ${String(value.id)}`,
details,
value,
{
x: 280 + pipelineLevel * 280,
y: 590 + pipelineRow * 142,
},
code,
);
addUnique(pipelines, value.id, node, "pipeline");
}
const frameIn = layoutNode(
"physical-frame-in",
physicalTypes.frameIn,
"Frame In",
"Resources ready",
{ kind: "frame-in" },
{ x: 30, y: 280 },
);
nodes.push(frameIn);
const passRows = new Map<number, number>();
for (const [index, pass] of passes.entries()) {
const level = levelOf(pass);
const row = passRows.get(level) ?? 0;
passRows.set(level, row + 1);
const node = layoutNode(
`physical-pass-${index}`,
pass.type === "compute"
? physicalTypes.computePass
: physicalTypes.renderPass,
`${pass.type === "compute" ? "Compute" : "Render"} pass · ${String(pass.id)}`,
drawSummary(pass),
resetPassRelations(pass),
{ x: 280 + level * 280, y: 280 - row * 300 },
);
addUnique(passNodes, pass.id, node, "pass");
}
const terminalDependencies = new Set(
passes.flatMap((pass: PlainObject) =>
Array.isArray(pass.after) ? pass.after : [],
),
);
const maximumLevel = Math.max(0, ...passLevels.values());
const frameOut = layoutNode(
"physical-frame-out",
physicalTypes.frameOut,
"Frame Out",
"GPU work submitted",
{ kind: "frame-out" },
{ x: 280 + (maximumLevel + 1) * 280, y: 280 },
);
nodes.push(frameOut);
const connect = (
from: GraphLayoutNodeV1 | undefined,
fromSocket: string,
to: GraphLayoutNodeV1,
toSocket: string,
relation: string,
index = 0,
descriptor: PlainObject = {},
) => {
if (!from)
throw new Error(
`${to.label} references a missing ${relation} declaration`,
);
links.push(
layoutLink(
links.length,
from,
fromSocket,
to,
toSocket,
relation,
index,
descriptor,
),
);
};
for (const pass of passes) {
const target = passNodes.get(pass.id)!;
const after = Array.isArray(pass.after) ? pass.after : [];
if (after.length) {
after.forEach((id: string, index: number) =>
connect(passNodes.get(id), "pass", target, "after", "after", index),
);
} else {
connect(frameIn, "frame", target, "after", "frame", 0);
}
connect(
pipelines.get(pass.pipeline),
"pipeline",
target,
"pipeline",
"pipeline",
0,
);
(pass.bindings ?? []).forEach((item: PlainObject, index: number) =>
connect(
resources.get(item.resource),
"resource",
target,
"bindings",
"binding",
index,
withoutResource(item),
),
);
(pass.color ?? []).forEach((item: PlainObject, index: number) =>
connect(
resources.get(item.resource),
"resource",
target,
"color",
"color",
index,
withoutResource(item),
),
);
if (pass.depth)
connect(
resources.get(pass.depth.resource),
"resource",
target,
"depth",
"depth",
0,
withoutResource(pass.depth),
);
(pass.vertexBuffers ?? []).forEach((item: PlainObject, index: number) =>
connect(
resources.get(item.resource),
"resource",
target,
"vertex",
"vertex",
index,
withoutResource(item),
),
);
if (pass.indexBuffer)
connect(
resources.get(pass.indexBuffer.resource),
"resource",
target,
"index",
"index",
0,
withoutResource(pass.indexBuffer),
);
if (!terminalDependencies.has(pass.id))
connect(target, "pass", frameOut, "after", "frame-out", 0);
}
return {
schemaVersion: 2,
graphId: `yawn-physical-${preset}` as GraphLayoutV2["graphId"],
catalogVersion: renderGraphComposition.version,
nodes,
links,
metadata: {
schema: physicalRenderGraphSchema,
preset,
renderGraphId: String(graph.id || "scene"),
},
};
}
export function isPhysicalRenderGraph(layout: GraphSource): boolean {
return layout?.metadata?.schema === physicalRenderGraphSchema;
}
function relationDescriptor(link: GraphLink): PlainObject {
return plainRecord(link.extensions?.descriptor)
? cloned(link.extensions.descriptor)
: {};
}
/** Reconstructs a Yawn graph from physical nodes and their typed relationships. */
export function reconstructRenderGraph(layout: GraphSource): PlainObject {
if (!isPhysicalRenderGraph(layout))
throw new TypeError("FxNode layout is not a physical render graph");
const activeNodes = layout.nodes.filter((node: any) => !node.muted);
const byNodeId = new Map(
activeNodes.map((node: any) => [String(node.id), node]),
);
const frameIn = activeNodes.filter(
(node: any) => node.typeId === physicalTypes.frameIn,
);
const frameOut = activeNodes.filter(
(node: any) => node.typeId === physicalTypes.frameOut,
);
if (frameIn.length !== 1 || frameOut.length !== 1)
throw new Error(
"Physical loadout requires exactly one Frame In and Frame Out",
);
const resources = {
buffers: [] as PlainObject[],
textures: [] as PlainObject[],
samplers: [] as PlainObject[],
};
const pipelines = {
render: [] as PlainObject[],
compute: [] as PlainObject[],
};
const passes: PlainObject[] = [];
const passByNodeId = new Map<string, PlainObject>();
for (const node of activeNodes as any[]) {
if (node.typeId === physicalTypes.buffer)
resources.buffers.push(declaration(node));
else if (node.typeId === physicalTypes.texture)
resources.textures.push(declaration(node));
else if (node.typeId === physicalTypes.sampler)
resources.samplers.push(declaration(node));
else if (
node.typeId === physicalTypes.renderPipeline ||
node.typeId === physicalTypes.computePipeline
) {
const pipeline = declaration(node);
pipeline.code = parameterString(node, "code");
pipelines[
node.typeId === physicalTypes.renderPipeline ? "render" : "compute"
].push(pipeline);
} else if (
node.typeId === physicalTypes.renderPass ||
node.typeId === physicalTypes.computePass
) {
const pass = declaration(node);
if (Array.isArray(pass.after)) pass.after = [];
if (Array.isArray(pass.bindings)) pass.bindings = [];
if (Array.isArray(pass.color)) pass.color = [];
if (Array.isArray(pass.vertexBuffers)) pass.vertexBuffers = [];
if (Object.hasOwn(pass, "depth")) pass.depth = null;
if (Object.hasOwn(pass, "indexBuffer")) pass.indexBuffer = null;
pass.pipeline = "";
passes.push(pass);
passByNodeId.set(String(node.id), pass);
}
}
const sourceId = (node: any): string => {
const value = declaration(node).id;
if (typeof value !== "string" || !value)
throw new Error(`${String(node.label || node.id)} requires an ID`);
return value;
};
const incoming = layout.links
.filter(
(link) =>
!link.muted &&
byNodeId.has(String(link.fromNodeId)) &&
byNodeId.has(String(link.toNodeId)),
)
.sort(
(left, right) =>
Number(left.extensions?.index ?? 0) -
Number(right.extensions?.index ?? 0),
);
for (const link of incoming) {
const source = byNodeId.get(String(link.fromNodeId));
const target = byNodeId.get(String(link.toNodeId));
const pass = passByNodeId.get(String(link.toNodeId));
if (!source || !target || !pass) continue;
const socket = target.sockets.find(
(candidate: any) => String(candidate.id) === String(link.toSocketId),
)?.key;
if (socket === "after") {
if (
source.typeId === physicalTypes.renderPass ||
source.typeId === physicalTypes.computePass
) {
if (!Array.isArray(pass.after)) pass.after = [];
pass.after.push(sourceId(source));
}
} else if (socket === "pipeline") {
pass.pipeline = sourceId(source);
} else if (socket === "bindings") {
if (!Array.isArray(pass.bindings)) pass.bindings = [];
const descriptor = relationDescriptor(link);
if (!Object.hasOwn(descriptor, "group")) descriptor.group = 0;
if (!Object.hasOwn(descriptor, "binding"))
descriptor.binding = pass.bindings.length;
pass.bindings.push({ ...descriptor, resource: sourceId(source) });
} else if (socket === "color") {
if (!Array.isArray(pass.color)) pass.color = [];
pass.color.push({
...relationDescriptor(link),
resource: sourceId(source),
});
} else if (socket === "depth") {
pass.depth = { ...relationDescriptor(link), resource: sourceId(source) };
} else if (socket === "vertex") {
if (!Array.isArray(pass.vertexBuffers)) pass.vertexBuffers = [];
const descriptor = relationDescriptor(link);
if (!Object.hasOwn(descriptor, "slot"))
descriptor.slot = pass.vertexBuffers.length;
pass.vertexBuffers.push({ ...descriptor, resource: sourceId(source) });
} else if (socket === "index") {
pass.indexBuffer = {
...relationDescriptor(link),
resource: sourceId(source),
};
}
}
for (const pass of passes) {
if (!pass.pipeline)
throw new Error(`Pass ${String(pass.id)} has no pipeline`);
}
return {
id: String(layout.metadata.renderGraphId || "scene"),
resources,
pipelines,
passes,
};
}
+152 -6
View File
@@ -5,7 +5,16 @@ import {
renderGraphNodeGroups,
renderGraphPresets,
} from "../src/catalog";
import { compileFxNodeRenderGraph, encodeRenderGraph } from "../src/compiler";
import {
buildFxNodeRenderGraph,
compileFxNodeRenderGraph,
encodeRenderGraph,
} from "../src/compiler";
import {
isPhysicalRenderGraph,
materializeRenderGraph,
reconstructRenderGraph,
} from "../src/physical";
import { bindFxNodeHeadless } from "../../../vendor/fxnode/src/headless-runtime";
import { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile";
@@ -115,13 +124,66 @@ const baseGraph = {
};
test("catalog exposes grouped, materially rich render graph nodes", () => {
assert.equal(renderGraphNodeGroups.length, 6);
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 25);
assert.equal(renderGraphNodeGroups.length, 9);
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 32);
for (const item of renderGraphNodeGroups.flatMap((group) => group.items))
assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId);
assert.doesNotThrow(() => compileFxNodeComposition(renderGraphComposition));
});
test("prepared intent expands into the complete physical render graph", () => {
const standard = renderGraphPresets.find((preset) => preset.id === "beauty")!;
const concrete = buildFxNodeRenderGraph(standard.layout, baseGraph);
const layout = materializeRenderGraph(concrete, "beauty");
assert.equal(isPhysicalRenderGraph(layout), true);
assert.ok(layout.nodes.length >= 14);
for (const typeId of [
"yawn.rg.physical.frame-in",
"yawn.rg.physical.frame-out",
"yawn.rg.physical.buffer",
"yawn.rg.physical.texture",
"yawn.rg.physical.sampler",
"yawn.rg.physical.render-pipeline",
"yawn.rg.physical.render-pass",
]) {
assert.ok(
layout.nodes.some((node) => node.typeId === typeId),
typeId,
);
}
assert.ok(
layout.links.some((link) => link.extensions.relation === "pipeline"),
);
assert.ok(
layout.links.some((link) => link.extensions.relation === "binding"),
);
assert.ok(layout.links.some((link) => link.extensions.relation === "color"));
assert.ok(layout.links.some((link) => link.extensions.relation === "depth"));
assert.ok(layout.links.some((link) => link.extensions.relation === "vertex"));
assert.deepEqual(reconstructRenderGraph(layout), concrete);
});
test("physical pipeline shader edits compile into the S-expression", () => {
const concrete = buildFxNodeRenderGraph(
renderGraphPresets[0]!.layout,
baseGraph,
);
const layout = structuredClone(materializeRenderGraph(concrete, "beauty"));
const pipeline = layout.nodes.find(
(node) =>
node.typeId === "yawn.rg.physical.render-pipeline" &&
node.label.includes("forward-pbr-0"),
)!;
pipeline.parameters.code = {
kind: "string",
value:
"@fragment fn edited_shader() -> @location(0) vec4<f32> { return vec4<f32>(0.25); }",
};
const serialized = compileFxNodeRenderGraph(layout, baseGraph);
assert.match(serialized, /edited_shader/u);
assert.doesNotMatch(serialized, /original pbr shader/u);
});
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);
@@ -200,24 +262,108 @@ test("wire encoder is stable and rejects unsupported numbers", () => {
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);
const graph = buildFxNodeRenderGraph(normals.layout, baseGraph) as any;
const serialized = encodeRenderGraph(graph);
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.deepEqual(
graph.passes.map((pass: any) => pass.id),
["forward-1--1"],
);
assert.deepEqual(graph.passes[0].color, [
{ resource: "canvas", clear: [0, 0, 0, 1] },
]);
assert.deepEqual(
graph.pipelines.render.map((pipeline: any) => pipeline.id),
["rg-normals-detailed"],
);
assert.deepEqual(
graph.resources.textures.map((texture: any) => texture.id),
["depth"],
);
assert.deepEqual(graph.resources.samplers, []);
assert.equal(baseGraph.passes[0].pipeline, "forward-pbr-0");
assert.equal(baseGraph.pipelines.render.length, 2);
});
test("debug loadouts remove detached clustered lighting work", () => {
const graph = structuredClone(baseGraph) as any;
graph.resources.buffers.push(
{ id: "point-lights", array: "pointLights", usage: ["storage"] },
{ id: "clusters", array: "clusters", usage: ["storage"] },
);
graph.pipelines.compute.push({
id: "cluster-lights",
code: "@compute @workgroup_size(1) fn main() {}",
entry: "main",
});
graph.passes.unshift({
id: "cluster-lights",
type: "compute",
pipeline: "cluster-lights",
bindings: [
{ group: 0, binding: 0, resource: "point-lights" },
{ group: 0, binding: 1, resource: "clusters" },
],
dispatch: [1, 1, 1],
});
graph.passes[1].after = ["cluster-lights"];
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
const result = buildFxNodeRenderGraph(normals.layout, graph) as any;
assert.equal(
result.passes.some((pass: any) => pass.type === "compute"),
false,
);
assert.deepEqual(result.pipelines.compute, []);
assert.equal(
result.resources.buffers.some((buffer: any) =>
["point-lights", "clusters"].includes(buffer.id),
),
false,
);
});
test("depth loadout uses position-only geometry and renders to canvas", () => {
const depth = renderGraphPresets.find((preset) => preset.id === "depth")!;
const graph = buildFxNodeRenderGraph(depth.layout, baseGraph) as any;
assert.deepEqual(
graph.pipelines.render.map((pipeline: any) => pipeline.id),
["rg-depth-basic"],
);
assert.match(graph.pipelines.render[0].code, /0\.2040/u);
assert.deepEqual(graph.pipelines.render[0].vertex.buffers.length, 1);
assert.deepEqual(graph.passes[0].vertexBuffers, [
{ slot: 0, resource: "geometry-1-positions" },
]);
assert.deepEqual(graph.passes[0].color, [
{ resource: "canvas", clear: [0, 0, 0, 1] },
]);
assert.equal(
graph.resources.buffers.some(
(buffer: any) => buffer.id === "geometry-1-normals",
),
false,
);
});
test("cinematic loadout inserts ordered sampled post passes", () => {
const cinematic = renderGraphPresets.find(
(preset) => preset.id === "cinematic",
)!;
const serialized = compileFxNodeRenderGraph(cinematic.layout, baseGraph);
const graph = buildFxNodeRenderGraph(cinematic.layout, baseGraph) as any;
const serialized = encodeRenderGraph(graph);
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.doesNotMatch(serialized, /\(field "id" "rg-effect-2-vignette"\)/u);
assert.doesNotMatch(serialized, /\(field "id" "present"\)/u);
assert.deepEqual(
graph.passes.find((pass: any) => pass.id === "rg-effect-pass-2-vignette")
.color,
[{ resource: "canvas", clear: [0, 0, 0, 1] }],
);
assert.match(serialized, /let liftBalance = vec3<f32>/u);
assert.match(serialized, /let gammaBalance = vec3<f32>/u);
assert.match(serialized, /let gainBalance = vec3<f32>/u);