Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
689 lines
22 KiB
TypeScript
689 lines
22 KiB
TypeScript
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,
|
||
};
|
||
}
|