import type { GraphLayoutV2, GraphNode, GraphSnapshot, ParameterValue, } from "fxnode"; import { isPhysicalRenderGraph, reconstructRenderGraph } from "./physical"; type PlainObject = Record; type GraphSource = GraphLayoutV2 | GraphSnapshot; const modeTypes = new Set([ "yawn.rg.mode.beauty", "yawn.rg.mode.normals", "yawn.rg.mode.depth", "yawn.rg.mode.unlit", "yawn.rg.mode.object-id", ]); const effectTypes = new Set([ "yawn.rg.post.fxaa", "yawn.rg.post.bloom", "yawn.rg.post.edges", "yawn.rg.post.color-grade", "yawn.rg.post.vignette", "yawn.rg.post.grayscale", "yawn.rg.post.invert", ]); 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 encodeValue(value: unknown): string { if (value === null || typeof value === "boolean") return String(value); if (typeof value === "number") { if (!Number.isFinite(value)) throw new TypeError("Render graph numbers must be finite"); return String(value); } if (typeof value === "string") return JSON.stringify(value); if (Array.isArray(value)) return `(array${value.map((item) => ` ${encodeValue(item)}`).join("")})`; if (plainRecord(value)) return `(object${Object.keys(value) .sort() .map( (key) => ` (field ${JSON.stringify(key)} ${encodeValue(value[key])})`, ) .join("")})`; throw new TypeError("Render graph values must be plain data"); } /** Encodes a Yawn render graph into the core's versioned S-expression wire format. */ export function encodeRenderGraph(graph: object): string { if (!plainRecord(graph)) throw new TypeError("Render graph must be a plain object"); return `(yawn-graph 1 ${encodeValue(graph)})`; } function value(node: GraphNode | any, key: string, fallback: T): T { const parameter = node.parameters?.[key] as ParameterValue | undefined; return (parameter && "value" in parameter ? parameter.value : fallback) as T; } function activeChain(layout: GraphSource): any[] { if (!layout || !Array.isArray(layout.nodes) || !Array.isArray(layout.links)) throw new TypeError("FxNode loadout is not a graph layout"); const nodes = new Map( layout.nodes.map((node: any) => [String(node.id), node]), ); const outputs = layout.nodes.filter( (node: any) => node.typeId === "yawn.rg.output.present" && !node.muted, ); if (outputs.length !== 1) throw new Error("Loadout must contain exactly one active Present node"); const incoming = new Map(); for (const link of layout.links) { if (link.muted) continue; if (incoming.has(String(link.toSocketId))) throw new Error( `Loadout input ${String(link.toSocketId)} has multiple links`, ); incoming.set(String(link.toSocketId), link); } const chain: any[] = [outputs[0]]; const seen = new Set(); let current = outputs[0]; while (current.typeId !== "yawn.rg.input.scene") { if (seen.has(String(current.id))) throw new Error("Loadout contains a cycle"); seen.add(String(current.id)); const link = incoming.get(`${String(current.id)}:surface`); if (!link) throw new Error( `${String(current.label || current.typeId)} is not connected to Scene`, ); current = nodes.get(String(link.fromNodeId)); if (!current) throw new Error( `Loadout link references missing node ${String(link.fromNodeId)}`, ); if (current.muted) { const bypass = incoming.get(`${String(current.id)}:surface`); current = bypass ? nodes.get(String(bypass.fromNodeId)) : undefined; if (!current) throw new Error("Muted stage has no connected bypass input"); } chain.unshift(current); } return chain; } 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(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(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) * 0.12).toFixed(4)}); return vec4(vec3(depth), 1.0);` : mode === "unlit" ? `let base = vec3(${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(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4(base * light, 1.0);` : "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3(0.0, 2.1, 4.2) + seed * 6.28318); return vec4(color, 1.0);"; return ` struct VertexOutput { @invariant @builtin(position) position: vec4, ${usesNormals ? "@location(0) normal: vec3," : ""} ${usesObjectId ? `@location(${usesNormals ? 1 : 0}) @interpolate(flat) objectId: u32,` : ""} } @group(0) @binding(0) var positions: array>; @group(0) @binding(1) var rotors: array>; @group(0) @binding(2) var scales: array>; @group(0) @binding(3) var meshInfo: array; @group(0) @binding(4) var cameraMatrices: array>; fn rotate(rotor: vec4, point: vec3) -> vec3 { return point + 2.0 * cross(rotor.xyz, cross(rotor.xyz, point) + rotor.w * point); } @vertex fn vertex( @location(0) point: vec3, ${normalBuffer ? "@location(1) localNormal: vec3," : ""} @builtin(instance_index) packed: u32, ) -> VertexOutput { let instance = packed & 65535u; let transformed = rotate(rotors[instance], point * scales[instance].xyz) + positions[instance].xyz; let world = vec4(transformed, 1.0); var output: VertexOutput; output.position = vec4( dot(cameraMatrices[0], world), dot(cameraMatrices[1], world), dot(cameraMatrices[2], world), dot(cameraMatrices[3], world)); if (cameraMatrices[4].w == 0.0) { output.position = world; } output.position = select(vec4(2.0, 2.0, 2.0, 1.0), output.position, meshInfo[instance * 4u + 2u] != 0u); ${usesNormals ? `output.normal = normalize(rotate(rotors[instance], ${normalBuffer ? "localNormal / scales[instance].xyz" : "vec3(0.0, 0.0, 1.0)"}));` : ""} ${usesObjectId ? "output.objectId = instance;" : ""} return output; } @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { ${fragment} }`; } function applyDebugMode(graph: PlainObject, node: any): void { const mode = String(node.typeId).slice("yawn.rg.mode.".length); if (mode === "beauty") return; const renderPipelines = graph.pipelines?.render; if (!Array.isArray(renderPipelines) || !Array.isArray(graph.passes)) throw new Error("Base render graph has no render pipeline declarations"); 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(); const generated = new Map(); for (const pass of graph.passes) { if ( pass.type !== "render" || typeof pass.pipeline !== "string" || !pass.pipeline.startsWith("forward-") || pass.pipeline === "empty-forward" || pass.draw?.vertices === 0 ) continue; const original = byId.get(pass.pipeline); if (!original) throw new Error(`Forward pipeline ${pass.pipeline} is missing`); const detailed = (original.vertex?.buffers?.length ?? 0) >= 2; const format = original.fragment?.targets?.[0]?.format; if (typeof format !== "string") throw new Error(`Forward pipeline ${pass.pipeline} has no color target`); const normalBuffer = (mode === "normals" || mode === "unlit") && detailed; const key = `${mode}:${normalBuffer}:${format}`; let id = generated.get(key); if (!id) { id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}`; let suffix = 1; while (byId.has(id)) id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}-${suffix++}`; const pipeline = { id, code: debugShader(mode, normalBuffer, node), vertex: { entry: "vertex", buffers: original.vertex.buffers.slice(0, normalBuffer ? 2 : 1), }, fragment: { entry: "fragment", targets: [{ format }] }, depthStencil: original.depthStencil ?? null, primitive: original.primitive ?? null, }; renderPipelines.push(pipeline); byId.set(id, pipeline); generated.set(key, id); } pass.pipeline = id; const resources = [ "node-positions", "node-rotors", "node-scales", "mesh-info", "camera-matrices", ]; pass.bindings = resources.map((resource, binding) => ({ group: 0, binding, resource, })); pass.vertexBuffers = (pass.vertexBuffers ?? []).filter( (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]; const luma = ([red, green, blue]: Rgb) => red * 0.2126 + green * 0.7152 + blue * 0.0722; function rgbParameter(node: any, key: string): Rgb { const color = value(node, key, [1, 1, 1, 1]); return [Number(color[0] ?? 1), Number(color[1] ?? 1), Number(color[2] ?? 1)]; } function normalizedBalance(color: Rgb): Rgb { const luminance = Math.max(0.001, luma(color)); return [color[0] / luminance, color[1] / luminance, color[2] / luminance]; } function mapRgb( color: Rgb, transform: (channel: number, index: number) => number, ): Rgb { return [ transform(color[0], 0), transform(color[1], 1), transform(color[2], 2), ]; } function wgslRgb(color: Rgb): string { return `vec3(${color.map((channel) => channel.toFixed(5)).join(", ")})`; } function colorTemperature(kelvin: number): Rgb { const temperature = Math.min(40000, Math.max(1000, kelvin)) / 100; const red = temperature <= 66 ? 255 : 329.698727446 * Math.pow(temperature - 60, -0.1332047592); const green = temperature <= 66 ? 99.4708025861 * Math.log(temperature) - 161.1195681661 : 288.1221695283 * Math.pow(temperature - 60, -0.0755148492); const blue = temperature >= 66 ? 255 : temperature <= 19 ? 0 : 138.5177312231 * Math.log(temperature - 10) - 305.0447927307; const linear = (channel: number) => { const srgb = Math.min(1, Math.max(0, channel / 255)); return srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4); }; return [linear(red), linear(green), linear(blue)]; } function whitePoint(node: any, prefix: "input" | "output"): Rgb { const temperature = colorTemperature( Number(value(node, `${prefix}Temperature`, 6500)), ); const tint = Math.min( 1, Math.max(-1, Number(value(node, `${prefix}Tint`, 0))), ); const color = rgbParameter(node, `${prefix}Color`); return [ temperature[0] * (1 - tint * 0.18) * color[0], temperature[1] * (1 + tint * 0.24) * color[1], temperature[2] * (1 - tint * 0.18) * color[2], ]; } function colorBalanceExpression(node: any): string { const mode = String(value(node, "mode", "Lift/Gamma/Gain")); if (mode === "Offset/Power/Slope") { const offset = Number(value(node, "offset", 0)); const power = Number(value(node, "power", 1)); const slope = Number(value(node, "slope", 1)); const offsetBalance = mapRgb( normalizedBalance(rgbParameter(node, "offsetColor")), (channel) => channel - 1 + offset, ); const powerBalance = mapRgb( normalizedBalance(rgbParameter(node, "powerColor")), (channel) => Math.max(0.01, channel * power), ); const slopeBalance = mapRgb( normalizedBalance(rgbParameter(node, "slopeColor")), (channel) => Math.max(0, channel * slope), ); return ` let offsetBalance = ${wgslRgb(offsetBalance)}; let powerBalance = ${wgslRgb(powerBalance)}; let slopeBalance = ${wgslRgb(slopeBalance)}; color = pow(max(color * slopeBalance + offsetBalance, vec3(0.0)), powerBalance);`; } if (mode === "White Point") { const input = whitePoint(node, "input"); const output = whitePoint(node, "output"); const adaptation = mapRgb( output, (channel, index) => channel / Math.max(0.001, input[index]!), ); return ` let whitePointAdaptation = ${wgslRgb(adaptation)}; color *= whitePointAdaptation;`; } const lift = Number(value(node, "lift", 0)); const gamma = Number(value(node, "gamma", 1)); const gain = Number(value(node, "gain", 1)); const liftBalance = mapRgb( normalizedBalance(rgbParameter(node, "liftColor")), (channel) => channel - 1 + lift, ); const gammaBalance = mapRgb( normalizedBalance(rgbParameter(node, "gammaColor")), (channel) => Math.max(0.01, channel * gamma), ); const gainBalance = mapRgb( normalizedBalance(rgbParameter(node, "gainColor")), (channel) => Math.max(0, channel * gain), ); return ` let liftBalance = ${wgslRgb(liftBalance)}; let gammaBalance = ${wgslRgb(gammaBalance)}; let gainBalance = ${wgslRgb(gainBalance)}; color = max(color + liftBalance, vec3(0.0)); color = pow(color, vec3(1.0) / gammaBalance) * gainBalance;`; } const fullScreenVertex = ` struct VertexOutput { @builtin(position) position: vec4, @location(0) uv: vec2 } @vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput { let points = array, 3>(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0)); var output: VertexOutput; output.position = vec4(points[index], 0.0, 1.0); output.uv = points[index] * vec2(0.5, -0.5) + vec2(0.5); return output; }`; function effectExpression(node: any): string { if (node.typeId === "yawn.rg.post.fxaa") return ` let size = vec2(textureDimensions(source)); let step = 1.0 / size; let center = textureSample(source, sourceSampler, input.uv); let horizontal = textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0)) + textureSample(source, sourceSampler, input.uv - vec2(step.x, 0.0)); let vertical = textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y)) + textureSample(source, sourceSampler, input.uv - vec2(0.0, step.y)); return center * 0.5 + (horizontal + vertical) * 0.125;`; if (node.typeId === "yawn.rg.post.bloom") { const threshold = Number(value(node, "threshold", 0.7)); const strength = Number(value(node, "strength", 0.3)); return ` let size = vec2(textureDimensions(source)); let step = 2.0 / size; let center = textureSample(source, sourceSampler, input.uv); var glow = vec4(0.0); glow += textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0)); glow += textureSample(source, sourceSampler, input.uv - vec2(step.x, 0.0)); glow += textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y)); glow += textureSample(source, sourceSampler, input.uv - vec2(0.0, step.y)); glow *= 0.25; let bright = max(glow.rgb - vec3(${threshold.toFixed(5)}), vec3(0.0)); return vec4(center.rgb + bright * ${strength.toFixed(5)}, center.a);`; } if (node.typeId === "yawn.rg.post.edges") { const strength = Number(value(node, "strength", 0.8)); const style = String(value(node, "style", "ink")); if (style === "blueprint") return ` let size = vec2(textureDimensions(source)); let step = 1.0 / size; let center = textureSample(source, sourceSampler, input.uv); let right = textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0)); let down = textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y)); let edge = length(center.rgb - right.rgb) + length(center.rgb - down.rgb); let value = dot(center.rgb, vec3(0.2126, 0.7152, 0.0722)); let cell = fract(input.uv * vec2(24.0, 16.0)); let gridDistance = min(min(cell.x, 1.0 - cell.x), min(cell.y, 1.0 - cell.y)); let grid = 1.0 - smoothstep(0.0, 0.035, gridDistance); var paper = mix(vec3(0.008, 0.045, 0.11), vec3(0.025, 0.24, 0.46), clamp(value, 0.0, 1.0)); paper += grid * vec3(0.012, 0.045, 0.075); let line = clamp(smoothstep(0.035, 0.24, edge) * ${strength.toFixed(5)}, 0.0, 1.0); return vec4(mix(paper, vec3(0.68, 0.94, 1.0), line), 1.0);`; return ` let size = vec2(textureDimensions(source)); let step = 1.0 / size; let center = textureSample(source, sourceSampler, input.uv); let right = textureSample(source, sourceSampler, input.uv + vec2(step.x, 0.0)); let down = textureSample(source, sourceSampler, input.uv + vec2(0.0, step.y)); let edge = length(center.rgb - right.rgb) + length(center.rgb - down.rgb); let ink = smoothstep(0.05, 0.35, edge) * ${strength.toFixed(5)}; return vec4(mix(center.rgb, vec3(0.015, 0.08, 0.14), ink), center.a);`; } if (node.typeId === "yawn.rg.post.color-grade") { const exposure = Number(value(node, "exposure", 0)); const contrast = Number(value(node, "contrast", 1)); const pivot = Number(value(node, "pivot", 0.18)); const saturation = Number(value(node, "saturation", 1)); const mixAmount = Number(value(node, "mix", 1)); const tint = value(node, "tint", [0.5, 0.62, 0.72, 1]); const tintAmount = Number(value(node, "tintAmount", 0)); return ` let sourceColor = textureSample(source, sourceSampler, input.uv); var color = sourceColor.rgb * ${Math.pow(2, exposure).toFixed(5)}; ${colorBalanceExpression(node)} color = (color - vec3(${pivot.toFixed(5)})) * ${contrast.toFixed(5)} + vec3(${pivot.toFixed(5)}); let luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); color = mix(vec3(luminance), color, ${saturation.toFixed(5)}); color = mix(color, color * vec3(${Number(tint[0] ?? 0.5).toFixed(5)}, ${Number(tint[1] ?? 0.62).toFixed(5)}, ${Number(tint[2] ?? 0.72).toFixed(5)}) * 2.0, ${tintAmount.toFixed(5)}); color = mix(sourceColor.rgb, max(color, vec3(0.0)), ${mixAmount.toFixed(5)}); return vec4(color, sourceColor.a);`; } if (node.typeId === "yawn.rg.post.vignette") { const amount = Number(value(node, "amount", 0.35)); return ` let sourceColor = textureSample(source, sourceSampler, input.uv); let centered = input.uv * 2.0 - 1.0; let vignette = 1.0 - smoothstep(0.35, 1.35, dot(centered, centered)) * ${amount.toFixed(5)}; return vec4(sourceColor.rgb * vignette, sourceColor.a);`; } if (node.typeId === "yawn.rg.post.grayscale") return `let sourceColor = textureSample(source, sourceSampler, input.uv); let luminance = dot(sourceColor.rgb, vec3(0.2126, 0.7152, 0.0722)); return vec4(vec3(luminance), sourceColor.a);`; return `let sourceColor = textureSample(source, sourceSampler, input.uv); return vec4(vec3(1.0) - sourceColor.rgb, sourceColor.a);`; } function effectShader(node: any): string { return `${fullScreenVertex} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { ${effectExpression(node)} }`; } function applyEffects(graph: PlainObject, effects: any[]): void { if (!effects.length) return; const present = graph.passes?.find( (pass: any) => pass.type === "render" && pass.color?.some((attachment: any) => attachment.resource === "canvas"), ); if (!present) throw new Error("Base render graph has no canvas presentation pass"); const sourceBinding = present.bindings?.find( (binding: any) => binding.group === 0 && binding.binding === 0, ); const samplerBinding = present.bindings?.find( (binding: any) => binding.group === 0 && binding.binding === 1, ); if (!sourceBinding || !samplerBinding) throw new Error("Canvas presentation pass has no sampled source"); const textures = graph.resources?.textures; const pipelines = graph.pipelines?.render; if (!Array.isArray(textures) || !Array.isArray(pipelines)) throw new Error("Base render graph cannot host post-processing stages"); const sourceTexture = textures.find( (texture: any) => texture.id === sourceBinding.resource, ); if (!sourceTexture?.format) throw new Error( `Presentation texture ${sourceBinding.resource} is not declared`, ); let input = sourceBinding.resource; 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 final = index === effects.length - 1; const output = final ? "canvas" : `rg-effect-${suffix}`; const pipeline = `rg-effect-pipeline-${suffix}`; const pass = `rg-effect-pass-${suffix}`; 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: final ? "canvas" : sourceTexture.format }], }, }); graph.passes.push({ id: pass, type: "render", pipeline, after, bindings: [ { group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: samplerBinding.resource }, ], color: [{ resource: output, clear: [0, 0, 0, 1] }], draw: { vertices: 3 }, }); input = output; after = [pass]; } 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), ); } /** * Resolves an FxNode loadout into the concrete graph that Yawn will compile. */ export function buildFxNodeRenderGraph( layout: GraphSource, baseGraph: object, ): 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); 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) => modeTypes.has(String(node.typeId)) || effectTypes.has(String(node.typeId)), ) .map((node) => String(node.typeId).split(".").at(-1)) .map((stage) => (stage === "beauty" ? "standard" : stage)) .join("-"); graph.id = `${String(graph.id || "scene")}-fxnode-${suffix || "standard"}`; 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)); }