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
+169 -33
View File
@@ -4,6 +4,7 @@ import type {
GraphSnapshot,
ParameterValue,
} from "fxnode";
import { isPhysicalRenderGraph, reconstructRenderGraph } from "./physical";
type PlainObject = Record<string, any>;
type GraphSource = GraphLayoutV2 | GraphSnapshot;
@@ -113,22 +114,38 @@ function activeChain(layout: GraphSource): any[] {
return chain;
}
function debugShader(mode: string, detailed: boolean, node: any): string {
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<readonly number[]>(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<f32>(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).toFixed(4)}); return vec4<f32>(vec3<f32>(depth), 1.0);`
? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${(Math.max(0.01, contrast) * 0.12).toFixed(4)}); return vec4<f32>(vec3<f32>(depth), 1.0);`
: mode === "unlit"
? `let base = vec3<f32>(${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<f32>(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4<f32>(base * light, 1.0);`
: "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3<f32>(0.0, 2.1, 4.2) + seed * 6.28318); return vec4<f32>(color, 1.0);";
return `
struct VertexOutput {
@invariant @builtin(position) position: vec4<f32>,
@location(0) normal: vec3<f32>,
@location(1) @interpolate(flat) objectId: u32,
${usesNormals ? "@location(0) normal: vec3<f32>," : ""}
${usesObjectId ? `@location(${usesNormals ? 1 : 0}) @interpolate(flat) objectId: u32,` : ""}
}
@group(0) @binding(0) var<storage, read> positions: array<vec4<f32>>;
@group(0) @binding(1) var<storage, read> rotors: array<vec4<f32>>;
@@ -140,7 +157,7 @@ fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
}
@vertex fn vertex(
@location(0) point: vec3<f32>,
${detailed ? "@location(1) localNormal: vec3<f32>," : ""}
${normalBuffer ? "@location(1) localNormal: vec3<f32>," : ""}
@builtin(instance_index) packed: u32,
) -> VertexOutput {
let instance = packed & 65535u;
@@ -152,8 +169,8 @@ fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
dot(cameraMatrices[2], world), dot(cameraMatrices[3], world));
if (cameraMatrices[4].w == 0.0) { output.position = world; }
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), output.position, meshInfo[instance * 4u + 2u] != 0u);
output.normal = normalize(rotate(rotors[instance], ${detailed ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));
output.objectId = instance;
${usesNormals ? `output.normal = normalize(rotate(rotors[instance], ${normalBuffer ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));` : ""}
${usesObjectId ? "output.objectId = instance;" : ""}
return output;
}
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
@@ -170,6 +187,10 @@ function applyDebugMode(graph: PlainObject, node: any): void {
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<string>();
const generated = new Map<string, string>();
for (const pass of graph.passes) {
if (
@@ -187,19 +208,20 @@ function applyDebugMode(graph: PlainObject, node: any): void {
const format = original.fragment?.targets?.[0]?.format;
if (typeof format !== "string")
throw new Error(`Forward pipeline ${pass.pipeline} has no color target`);
const key = `${mode}:${detailed}:${format}`;
const normalBuffer = (mode === "normals" || mode === "unlit") && detailed;
const key = `${mode}:${normalBuffer}:${format}`;
let id = generated.get(key);
if (!id) {
id = `rg-${mode}-${detailed ? "detailed" : "basic"}`;
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}`;
let suffix = 1;
while (byId.has(id))
id = `rg-${mode}-${detailed ? "detailed" : "basic"}-${suffix++}`;
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}-${suffix++}`;
const pipeline = {
id,
code: debugShader(mode, detailed, node),
code: debugShader(mode, normalBuffer, node),
vertex: {
entry: "vertex",
buffers: original.vertex.buffers.slice(0, detailed ? 2 : 1),
buffers: original.vertex.buffers.slice(0, normalBuffer ? 2 : 1),
},
fragment: { entry: "fragment", targets: [{ format }] },
depthStencil: original.depthStencil ?? null,
@@ -223,9 +245,37 @@ function applyDebugMode(graph: PlainObject, node: any): void {
resource,
}));
pass.vertexBuffers = (pass.vertexBuffers ?? []).filter(
(binding: any) => binding.slot === 0 || (detailed && binding.slot === 1),
(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];
@@ -496,23 +546,25 @@ function applyEffects(graph: PlainObject, effects: any[]): void {
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 output = `rg-effect-${suffix}`;
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}`;
textures.push({
id: output,
format: sourceTexture.format,
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
transient: true,
});
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: sourceTexture.format }],
targets: [{ format: final ? "canvas" : sourceTexture.format }],
},
});
graph.passes.push({
@@ -530,28 +582,101 @@ function applyEffects(graph: PlainObject, effects: any[]): void {
input = output;
after = [pass];
}
sourceBinding.resource = input;
present.after = after;
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),
);
}
/**
* Compiles an FxNode loadout over a scene-authored graph and returns the exact
* S-expression consumed by `YawnCore.compileGraph`.
* Resolves an FxNode loadout into the concrete graph that Yawn will compile.
*/
export function compileFxNodeRenderGraph(
export function buildFxNodeRenderGraph(
layout: GraphSource,
baseGraph: object,
): string {
): 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);
applyEffects(
graph,
chain.filter((node) => effectTypes.has(String(node.typeId))),
);
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) =>
@@ -562,5 +687,16 @@ export function compileFxNodeRenderGraph(
.map((stage) => (stage === "beauty" ? "standard" : stage))
.join("-");
graph.id = `${String(graph.id || "scene")}-fxnode-${suffix || "standard"}`;
return encodeRenderGraph(graph);
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));
}