Files
yawn/addons/fxnode_rg/src/physical.ts
T

689 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};
}