import type { GraphLayoutNodeV1, GraphLayoutV2, GraphLink, GraphSnapshot, JsonValue, ParameterValue, } from "fxnode"; import { renderGraphComposition } from "./catalog"; type PlainObject = Record; 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(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; 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(); const pipelines = new Map(); const passNodes = new Map(); const passLevels = new Map(); const levelOf = (pass: PlainObject, visiting = new Set()): 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, 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(); 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(); 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(); 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(); 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, }; }