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
+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,
};
}