Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
703 lines
27 KiB
TypeScript
703 lines
27 KiB
TypeScript
import type {
|
|
GraphLayoutV2,
|
|
GraphNode,
|
|
GraphSnapshot,
|
|
ParameterValue,
|
|
} from "fxnode";
|
|
import { isPhysicalRenderGraph, reconstructRenderGraph } from "./physical";
|
|
|
|
type PlainObject = Record<string, any>;
|
|
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<T>(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<string, any>();
|
|
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<string>();
|
|
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<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) * 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>,
|
|
${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>>;
|
|
@group(0) @binding(2) var<storage, read> scales: array<vec4<f32>>;
|
|
@group(0) @binding(3) var<storage, read> meshInfo: array<u32>;
|
|
@group(0) @binding(4) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
|
fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
|
|
return point + 2.0 * cross(rotor.xyz, cross(rotor.xyz, point) + rotor.w * point);
|
|
}
|
|
@vertex fn vertex(
|
|
@location(0) point: vec3<f32>,
|
|
${normalBuffer ? "@location(1) localNormal: vec3<f32>," : ""}
|
|
@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<f32>(transformed, 1.0);
|
|
var output: VertexOutput;
|
|
output.position = vec4<f32>(
|
|
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<f32>(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<f32>(0.0, 0.0, 1.0)"}));` : ""}
|
|
${usesObjectId ? "output.objectId = instance;" : ""}
|
|
return output;
|
|
}
|
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
${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<string>();
|
|
const generated = new Map<string, string>();
|
|
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<readonly number[]>(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<f32>(${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<f32>(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<f32>(0.0));
|
|
color = pow(color, vec3<f32>(1.0) / gammaBalance) * gainBalance;`;
|
|
}
|
|
|
|
const fullScreenVertex = `
|
|
struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32> }
|
|
@vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput {
|
|
let points = array<vec2<f32>, 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<f32>(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<f32>(textureDimensions(source));
|
|
let step = 2.0 / size;
|
|
let center = textureSample(source, sourceSampler, input.uv);
|
|
var glow = vec4<f32>(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<f32>(${threshold.toFixed(5)}), vec3<f32>(0.0));
|
|
return vec4<f32>(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<f32>(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<f32>(0.2126, 0.7152, 0.0722));
|
|
let cell = fract(input.uv * vec2<f32>(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<f32>(0.008, 0.045, 0.11), vec3<f32>(0.025, 0.24, 0.46), clamp(value, 0.0, 1.0));
|
|
paper += grid * vec3<f32>(0.012, 0.045, 0.075);
|
|
let line = clamp(smoothstep(0.035, 0.24, edge) * ${strength.toFixed(5)}, 0.0, 1.0);
|
|
return vec4<f32>(mix(paper, vec3<f32>(0.68, 0.94, 1.0), line), 1.0);`;
|
|
return `
|
|
let size = vec2<f32>(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<f32>(mix(center.rgb, vec3<f32>(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<readonly number[]>(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<f32>(${pivot.toFixed(5)})) * ${contrast.toFixed(5)} + vec3<f32>(${pivot.toFixed(5)});
|
|
let luminance = dot(color, vec3<f32>(0.2126, 0.7152, 0.0722));
|
|
color = mix(vec3<f32>(luminance), color, ${saturation.toFixed(5)});
|
|
color = mix(color, color * vec3<f32>(${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<f32>(0.0)), ${mixAmount.toFixed(5)});
|
|
return vec4<f32>(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<f32>(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<f32>(0.2126, 0.7152, 0.0722)); return vec4<f32>(vec3<f32>(luminance), sourceColor.a);`;
|
|
return `let sourceColor = textureSample(source, sourceSampler, input.uv); return vec4<f32>(vec3<f32>(1.0) - sourceColor.rgb, sourceColor.a);`;
|
|
}
|
|
|
|
function effectShader(node: any): string {
|
|
return `${fullScreenVertex}
|
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
|
@group(0) @binding(1) var sourceSampler: sampler;
|
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
|
${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));
|
|
}
|