Add FxNode render graph playground

Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup.

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-24 17:45:48 +00:00
co-authored by heaust
parent 951449db0b
commit 6153223fd5
27 changed files with 4672 additions and 207 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"name": "@yawn/fxnode-rg",
"version": "0.1.0",
"description": "FxNode render-graph authoring and Yawn graph compilation",
"type": "module",
"exports": "./src/index.ts",
"scripts": {
"test": "node test/run.mjs"
},
"dependencies": {
"fxnode": "file:../../vendor/fxnode"
}
}
+860
View File
@@ -0,0 +1,860 @@
import type {
FxNodeCompositionData,
FxNodeDefinition,
FxNodeSocketDefinition,
GraphLayoutNodeV1,
GraphLayoutV2,
GraphLink,
JsonValue,
ParameterValue,
} from "fxnode";
const surfaceInput: FxNodeSocketDefinition = {
title: "Surface",
direction: "input",
type: "surface",
maxIncomingLinks: 1,
visible: true,
value: null,
showValue: false,
};
const surfaceOutput: FxNodeSocketDefinition = {
title: "Surface",
direction: "output",
type: "surface",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
};
const dataOutput: FxNodeSocketDefinition = {
title: "Data",
direction: "output",
type: "data",
maxIncomingLinks: 0,
visible: true,
value: null,
showValue: false,
};
function stageNode(
title: string,
style: string,
parameters: FxNodeDefinition["parameters"] = {},
sockets: FxNodeDefinition["sockets"] = {
surface: surfaceInput,
result: surfaceOutput,
},
): FxNodeDefinition {
const ownedSockets = Object.fromEntries(
Object.entries(sockets).map(([key, socket]) => [key, { ...socket }]),
);
return {
version: 1,
title,
behavior: "standard",
style,
parameters,
sockets: ownedSockets,
ui: [
...Object.keys(parameters).map((parameter) => ({
kind: "parameter" as const,
parameter,
})),
...Object.keys(ownedSockets).map((socket) => ({
kind: "socket" as const,
socket,
})),
],
muteBypass:
"surface" in ownedSockets && "result" in ownedSockets
? [["surface", "result"]]
: [],
migrations: [],
};
}
const colorParameter = (value: readonly [number, number, number, number]) => ({
type: "color" as const,
default: { kind: "color" as const, value },
});
const numberParameter = (
value: number,
minimum: number,
maximum: number,
step = 0.01,
) => ({
type: "number" as const,
default: { kind: "number" as const, value },
minimum,
maximum,
step,
});
const stringParameter = (value: string, values?: readonly string[]) => ({
type: "string" as const,
default: { kind: "string" as const, value },
...(values ? { enum: values } : {}),
});
const nodes = {
"yawn.rg.input.scene": stageNode(
"Scene",
"input",
{},
{ scene: surfaceOutput },
),
"yawn.rg.input.texture": stageNode(
"Texture",
"input",
{ resource: stringParameter("hdr") },
{ texture: surfaceOutput },
),
"yawn.rg.input.rows": stageNode(
"Shared Rows",
"input",
{ array: stringParameter("signals") },
{ data: dataOutput },
),
"yawn.rg.input.time": stageNode(
"Frame Time",
"input",
{},
{ data: dataOutput },
),
"yawn.rg.mode.beauty": stageNode("Standard / PBR", "geometry"),
"yawn.rg.mode.normals": stageNode("World Normals", "geometry"),
"yawn.rg.mode.depth": stageNode("Camera Depth", "geometry", {
contrast: numberParameter(1.35, 0.2, 4, 0.05),
}),
"yawn.rg.mode.unlit": stageNode("Clay / Unlit", "geometry", {
color: colorParameter([0.36, 0.68, 0.9, 1]),
}),
"yawn.rg.mode.object-id": stageNode("Object ID", "geometry"),
"yawn.rg.light.clustered": stageNode("Clustered Lights", "lighting"),
"yawn.rg.light.ambient": stageNode("Ambient Light", "lighting", {
color: colorParameter([0.18, 0.42, 0.6, 1]),
intensity: numberParameter(0.35, 0, 4, 0.05),
}),
"yawn.rg.light.environment": stageNode("Environment", "lighting", {
strength: numberParameter(1, 0, 4, 0.05),
}),
"yawn.rg.post.fxaa": stageNode("FXAA", "post"),
"yawn.rg.post.bloom": stageNode("Bloom", "post", {
threshold: numberParameter(0.7, 0, 2, 0.05),
strength: numberParameter(0.3, 0, 1, 0.05),
}),
"yawn.rg.post.edges": stageNode("Edge Detect", "post", {
strength: numberParameter(0.8, 0, 2, 0.05),
style: stringParameter("ink", ["ink", "blueprint"]),
}),
"yawn.rg.post.color-grade": {
...stageNode("Color Grade", "post", {
exposure: numberParameter(0, -6, 6, 0.05),
contrast: numberParameter(1, 0, 2, 0.01),
pivot: numberParameter(0.18, 0, 1, 0.01),
saturation: numberParameter(1, 0, 2, 0.01),
mix: numberParameter(1, 0, 1, 0.01),
mode: stringParameter("Lift/Gamma/Gain", [
"Lift/Gamma/Gain",
"Offset/Power/Slope",
"White Point",
]),
lift: numberParameter(0, -1, 1, 0.01),
liftColor: colorParameter([1, 1, 1, 1]),
gamma: numberParameter(1, 0.1, 4, 0.01),
gammaColor: colorParameter([1, 1, 1, 1]),
gain: numberParameter(1, 0, 4, 0.01),
gainColor: colorParameter([1, 1, 1, 1]),
offset: numberParameter(0, -1, 1, 0.01),
offsetColor: colorParameter([1, 1, 1, 1]),
power: numberParameter(1, 0.1, 4, 0.01),
powerColor: colorParameter([1, 1, 1, 1]),
slope: numberParameter(1, 0, 4, 0.01),
slopeColor: colorParameter([1, 1, 1, 1]),
inputTemperature: numberParameter(6500, 1000, 40000, 50),
inputTint: numberParameter(0, -1, 1, 0.01),
inputColor: colorParameter([1, 1, 1, 1]),
outputTemperature: numberParameter(6500, 1000, 40000, 50),
outputTint: numberParameter(0, -1, 1, 0.01),
outputColor: colorParameter([1, 1, 1, 1]),
tint: colorParameter([0.5, 0.62, 0.72, 1]),
tintAmount: numberParameter(0, 0, 1, 0.05),
}),
version: 2,
ui: [
{ kind: "text", variant: "section", title: "Primary correction" },
{ kind: "parameter", parameter: "exposure", title: "Exposure" },
{ kind: "parameter", parameter: "contrast", title: "Contrast" },
{ kind: "parameter", parameter: "pivot", title: "Pivot" },
{ kind: "parameter", parameter: "saturation", title: "Saturation" },
{ kind: "parameter", parameter: "mix", title: "Mix" },
{ kind: "text", variant: "section", title: "Color balance" },
{ kind: "parameter", parameter: "mode", title: "Mode" },
{
kind: "widget",
widget: "grading-wheels",
bindings: [
{ title: "Lift", scalar: "lift", color: "liftColor" },
{ title: "Gamma", scalar: "gamma", color: "gammaColor" },
{ title: "Gain", scalar: "gain", color: "gainColor" },
],
visibleWhen: { parameter: "mode", equals: "Lift/Gamma/Gain" },
},
{
kind: "widget",
widget: "grading-wheels",
bindings: [
{ title: "Offset", scalar: "offset", color: "offsetColor" },
{ title: "Power", scalar: "power", color: "powerColor" },
{ title: "Slope", scalar: "slope", color: "slopeColor" },
],
visibleWhen: { parameter: "mode", equals: "Offset/Power/Slope" },
},
{
kind: "text",
variant: "section",
title: "Input white point",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{
kind: "parameter",
parameter: "inputTemperature",
title: "Temperature",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{
kind: "parameter",
parameter: "inputTint",
title: "Tint",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{
kind: "parameter",
parameter: "inputColor",
title: "Color",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{
kind: "text",
variant: "section",
title: "Output white point",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{
kind: "parameter",
parameter: "outputTemperature",
title: "Temperature",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{
kind: "parameter",
parameter: "outputTint",
title: "Tint",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{
kind: "parameter",
parameter: "outputColor",
title: "Color",
visibleWhen: { parameter: "mode", equals: "White Point" },
},
{ kind: "hidden", target: "parameter", parameter: "tint" },
{ kind: "hidden", target: "parameter", parameter: "tintAmount" },
{ kind: "socket", socket: "surface" },
{ kind: "socket", socket: "result" },
],
migrations: [
{
fromVersion: 1,
toVersion: 2,
steps: [
{ kind: "materialize-missing", target: "parameter", key: "contrast" },
{ kind: "materialize-missing", target: "parameter", key: "pivot" },
{ kind: "materialize-missing", target: "parameter", key: "mix" },
{ kind: "materialize-missing", target: "parameter", key: "mode" },
{ kind: "materialize-missing", target: "parameter", key: "lift" },
{
kind: "materialize-missing",
target: "parameter",
key: "liftColor",
},
{ kind: "materialize-missing", target: "parameter", key: "gamma" },
{
kind: "materialize-missing",
target: "parameter",
key: "gammaColor",
},
{ kind: "materialize-missing", target: "parameter", key: "gain" },
{
kind: "materialize-missing",
target: "parameter",
key: "gainColor",
},
{ kind: "materialize-missing", target: "parameter", key: "offset" },
{
kind: "materialize-missing",
target: "parameter",
key: "offsetColor",
},
{ kind: "materialize-missing", target: "parameter", key: "power" },
{
kind: "materialize-missing",
target: "parameter",
key: "powerColor",
},
{ kind: "materialize-missing", target: "parameter", key: "slope" },
{
kind: "materialize-missing",
target: "parameter",
key: "slopeColor",
},
{
kind: "materialize-missing",
target: "parameter",
key: "inputTemperature",
},
{
kind: "materialize-missing",
target: "parameter",
key: "inputTint",
},
{
kind: "materialize-missing",
target: "parameter",
key: "inputColor",
},
{
kind: "materialize-missing",
target: "parameter",
key: "outputTemperature",
},
{
kind: "materialize-missing",
target: "parameter",
key: "outputTint",
},
{
kind: "materialize-missing",
target: "parameter",
key: "outputColor",
},
],
},
],
},
"yawn.rg.post.vignette": stageNode("Vignette", "post", {
amount: numberParameter(0.35, 0, 1, 0.05),
}),
"yawn.rg.post.grayscale": stageNode("Grayscale", "post"),
"yawn.rg.post.invert": stageNode("Invert", "post"),
"yawn.rg.post.tonemap": stageNode("Tone Mapping", "post", {
curve: stringParameter("aces", ["aces", "reinhard", "linear"]),
}),
"yawn.rg.utility.copy": stageNode("Copy Pass", "utility"),
"yawn.rg.utility.compute": stageNode("Compute Pass", "utility", {
label: stringParameter("custom-compute"),
workgroups: {
type: "vector",
default: { kind: "vector", value: [1, 1, 1] },
minimum: 1,
maximum: 65535,
step: 1,
},
}),
"yawn.rg.utility.reroute": {
...stageNode("Reroute", "utility"),
behavior: "reroute",
},
"yawn.rg.output.present": stageNode(
"Present",
"output",
{},
{ surface: surfaceInput },
),
"yawn.rg.output.texture": stageNode(
"HDR Texture Output",
"output",
{ name: stringParameter("output") },
{ surface: surfaceInput },
),
} as const satisfies Readonly<Record<string, FxNodeDefinition>>;
export const renderGraphComposition = {
schemaVersion: 2,
id: "yawn.render-graph",
version: 2,
compatibility: { wildcardInputTypes: [] },
socketTypes: {
surface: { title: "Surface", color: "#43b9ed", acceptsFrom: ["surface"] },
data: { title: "Data", color: "#a8b7c5", acceptsFrom: ["data"] },
},
theme: {
background: "#081522",
grid: "#183148",
frame: "#18304780",
frameHeader: "#396680",
body: "#102538",
control: "#091a29",
controlFill: "#258fd2",
controlEditing: "#06111d",
textSelection: "#248dcc",
outline: "#06111d",
text: "#edf8ff",
muted: "#9bb4c5",
shadow: "#00000088",
nodeSelected: "#48baf3",
nodeActive: "#dff5ff",
unknownHeader: "#526476",
unknownSocket: "#8394a2",
linkMuted: "#e77777",
knifeMuted: "#ee7777",
emphasis: "#ffffff",
focus: "#5bc5f7",
editOutline: "#3d6c88",
resize: "#6f8fa3",
muteOverlay: "#07121dcc",
boxSelectionFill: "#32a9e82b",
checkerLight: "#648197",
checkerDark: "#29475e",
widgetBorder: "#06111d",
rampBorder: "#06111d",
resourceBackground: "#091b2a",
},
nodeStyles: {
input: { header: "#2676a5" },
geometry: { header: "#3d7797" },
lighting: { header: "#a27332" },
post: { header: "#7253a1" },
utility: { header: "#4a677b" },
output: { header: "#258b73" },
},
resources: {},
nodes,
} as const satisfies FxNodeCompositionData;
export interface RenderGraphCatalogItem {
readonly typeId: keyof typeof nodes;
readonly title: string;
readonly description: string;
readonly keywords?: string;
}
export interface RenderGraphCatalogGroup {
readonly id: string;
readonly title: string;
readonly icon: string;
readonly items: readonly RenderGraphCatalogItem[];
}
export const renderGraphNodeGroups: readonly RenderGraphCatalogGroup[] = [
{
id: "inputs",
title: "Inputs",
icon: "↳",
items: [
{
typeId: "yawn.rg.input.scene",
title: "Scene",
description: "Current Scene graph and resources",
keywords: "source standard pbr beauty",
},
{
typeId: "yawn.rg.input.texture",
title: "Texture",
description: "Named graph texture input",
keywords: "resource image",
},
{
typeId: "yawn.rg.input.rows",
title: "Shared Rows",
description: "Shared arena row data",
keywords: "sab buffer",
},
{
typeId: "yawn.rg.input.time",
title: "Frame Time",
description: "Frame timing data",
keywords: "clock animation",
},
],
},
{
id: "geometry",
title: "Geometry views",
icon: "◇",
items: [
{
typeId: "yawn.rg.mode.beauty",
title: "Standard / PBR",
description: "Scene-authored materials and lighting",
},
{
typeId: "yawn.rg.mode.normals",
title: "World Normals",
description: "RGB encoded world-space normals",
keywords: "debug",
},
{
typeId: "yawn.rg.mode.depth",
title: "Camera Depth",
description: "Camera depth visualization",
keywords: "debug z",
},
{
typeId: "yawn.rg.mode.unlit",
title: "Clay / Unlit",
description: "Uniform clay material",
keywords: "debug flat",
},
{
typeId: "yawn.rg.mode.object-id",
title: "Object ID",
description: "Stable color per draw object",
keywords: "debug selection",
},
],
},
{
id: "lighting",
title: "Lighting",
icon: "☀",
items: [
{
typeId: "yawn.rg.light.clustered",
title: "Clustered Lights",
description: "Scene light cluster stage",
},
{
typeId: "yawn.rg.light.ambient",
title: "Ambient Light",
description: "Ambient contribution controls",
},
{
typeId: "yawn.rg.light.environment",
title: "Environment",
description: "Studio environment contribution",
keywords: "ibl sky",
},
],
},
{
id: "post",
title: "Post processing",
icon: "✦",
items: [
{
typeId: "yawn.rg.post.fxaa",
title: "FXAA",
description: "Fast approximate anti-aliasing",
},
{
typeId: "yawn.rg.post.bloom",
title: "Bloom",
description: "Soft highlight bloom",
},
{
typeId: "yawn.rg.post.edges",
title: "Edge Detect",
description: "Luminance edge overlay",
keywords: "sobel outline",
},
{
typeId: "yawn.rg.post.color-grade",
title: "Color Grade",
description: "Primary controls, grading wheels, and white balance",
keywords: "lift gamma gain offset power slope temperature tint",
},
{
typeId: "yawn.rg.post.vignette",
title: "Vignette",
description: "Darken the image perimeter",
},
{
typeId: "yawn.rg.post.grayscale",
title: "Grayscale",
description: "Luminance-only output",
},
{
typeId: "yawn.rg.post.invert",
title: "Invert",
description: "Invert the source color",
},
{
typeId: "yawn.rg.post.tonemap",
title: "Tone Mapping",
description: "Select a display tone curve",
},
],
},
{
id: "utilities",
title: "Pass utilities",
icon: "⌘",
items: [
{
typeId: "yawn.rg.utility.copy",
title: "Copy Pass",
description: "Explicit texture copy stage",
},
{
typeId: "yawn.rg.utility.compute",
title: "Compute Pass",
description: "Custom compute dispatch",
},
{
typeId: "yawn.rg.utility.reroute",
title: "Reroute",
description: "Keep complex loadouts tidy",
},
],
},
{
id: "outputs",
title: "Outputs",
icon: "▣",
items: [
{
typeId: "yawn.rg.output.present",
title: "Present",
description: "Present to the WebGPU canvas",
keywords: "screen output",
},
{
typeId: "yawn.rg.output.texture",
title: "HDR Texture Output",
description: "Keep an HDR graph texture",
keywords: "offscreen",
},
],
},
];
const clone = <T>(value: T): T => structuredClone(value);
function layoutNode(
id: string,
typeId: keyof typeof nodes,
x: number,
y: number,
overrides: Readonly<Record<string, ParameterValue>> = {},
): GraphLayoutNodeV1 {
const definition = nodes[typeId];
const hasGradingWheels = definition.ui.some(
(item) => item.kind === "widget" && item.widget === "grading-wheels",
);
const parameters = Object.fromEntries(
Object.entries(definition.parameters).map(([key, schema]) => [
key,
clone(overrides[key] ?? schema.default),
]),
);
const sockets = Object.entries(definition.sockets).map(([key, socket]) => ({
id: `${id}:${key}`,
key,
label: socket.title,
direction: socket.direction,
dataType: socket.type,
accepts:
socket.direction === "input"
? clone(
renderGraphComposition.socketTypes[
socket.type as "surface" | "data"
].acceptsFrom,
)
: [],
maxIncomingLinks: socket.maxIncomingLinks,
...(socket.value ? { defaultValue: clone(socket.value.default) } : {}),
visible: socket.visible,
}));
return {
id,
typeId,
typeVersion: definition.version,
position: { x, y },
size: {
x: definition.behavior === "reroute" ? 20 : hasGradingWheels ? 400 : 190,
y:
definition.behavior === "reroute"
? 20
: hasGradingWheels
? 436
: Math.max(90, 48 + definition.ui.length * 25),
},
label: definition.title,
parameters,
sockets,
muted: false,
collapsed: false,
extensions: {},
};
}
function layoutLink(from: string, to: string, index: number): GraphLink {
const fromDefinition = nodes[from.split("|")[1] as keyof typeof nodes];
const fromKey = Object.entries(fromDefinition.sockets).find(
([, socket]) => socket.direction === "output",
)?.[0];
if (!fromKey) throw new Error(`Preset source ${from} has no output`);
return {
id: `link-${index}` as GraphLink["id"],
fromNodeId: from.split("|")[0] as GraphLink["fromNodeId"],
fromSocketId:
`${from.split("|")[0]}:${fromKey}` as GraphLink["fromSocketId"],
toNodeId: to.split("|")[0] as GraphLink["toNodeId"],
toSocketId: `${to.split("|")[0]}:surface` as GraphLink["toSocketId"],
muted: false,
extensions: {},
};
}
function preset(
id: string,
name: string,
stages: readonly {
readonly id: string;
readonly typeId: keyof typeof nodes;
readonly parameters?: Readonly<Record<string, ParameterValue>>;
}[],
): RenderGraphPreset {
const materialized = stages.map((stage) =>
layoutNode(stage.id, stage.typeId, 0, 0, stage.parameters),
);
const positions = new Map<string, { x: number; y: number }>();
const featured = materialized.find((node) => node.size.x >= 400);
if (featured) {
const side = materialized.filter((node) => node !== featured);
const sideHeight =
side.reduce((sum, node) => sum + node.size.y, 0) +
Math.max(0, side.length - 1) * 15;
const height = Math.max(featured.size.y, sideHeight);
positions.set(featured.id, { x: 0, y: height / 2 });
let y = height / 2;
for (const node of side) {
positions.set(node.id, { x: -node.size.x - 15, y });
y -= node.size.y + 15;
}
} else {
const rows: GraphLayoutNodeV1[][] = [];
for (const node of materialized) {
const row = rows.at(-1);
const width = row?.reduce((sum, item) => sum + item.size.x, 0) ?? 0;
if (row && width + row.length * 15 + node.size.x <= 620) row.push(node);
else rows.push([node]);
}
let y = 105;
rows.forEach((row, rowIndex) => {
const width =
row.reduce((sum, node) => sum + node.size.x, 0) + (row.length - 1) * 15;
let x = -width / 2;
const ordered = rowIndex % 2 ? [...row].reverse() : row;
for (const node of ordered) {
positions.set(node.id, { x, y });
x += node.size.x + 15;
}
y -= Math.max(...row.map((node) => node.size.y)) + 34;
});
}
const positioned = materialized.map((node) => ({
...node,
position: positions.get(node.id)!,
}));
return {
id,
name,
builtIn: true,
layout: {
schemaVersion: 2,
graphId: `yawn-${id}` as GraphLayoutV2["graphId"],
catalogVersion: renderGraphComposition.version,
nodes: positioned,
links: stages
.slice(1)
.map((stage, index) =>
layoutLink(
`${stages[index]!.id}|${stages[index]!.typeId}`,
`${stage.id}|${stage.typeId}`,
index,
),
),
metadata: { preset: id } satisfies Readonly<Record<string, JsonValue>>,
},
};
}
export interface RenderGraphPreset {
readonly id: string;
readonly name: string;
readonly builtIn: true;
readonly layout: GraphLayoutV2;
}
export const renderGraphPresets: readonly RenderGraphPreset[] = [
preset("beauty", "Standard", [
{ id: "scene", typeId: "yawn.rg.input.scene" },
{ id: "standard", typeId: "yawn.rg.mode.beauty" },
{ id: "present", typeId: "yawn.rg.output.present" },
]),
preset("normals", "World normals", [
{ id: "scene", typeId: "yawn.rg.input.scene" },
{ id: "normals", typeId: "yawn.rg.mode.normals" },
{ id: "present", typeId: "yawn.rg.output.present" },
]),
preset("depth", "Camera depth", [
{ id: "scene", typeId: "yawn.rg.input.scene" },
{
id: "depth",
typeId: "yawn.rg.mode.depth",
parameters: { contrast: { kind: "number", value: 1.7 } },
},
{ id: "present", typeId: "yawn.rg.output.present" },
]),
preset("clay", "Clay studio", [
{ id: "scene", typeId: "yawn.rg.input.scene" },
{
id: "clay",
typeId: "yawn.rg.mode.unlit",
parameters: { color: { kind: "color", value: [0.24, 0.62, 0.86, 1] } },
},
{ id: "vignette", typeId: "yawn.rg.post.vignette" },
{ id: "present", typeId: "yawn.rg.output.present" },
]),
preset("cinematic", "Cinematic bloom", [
{ id: "scene", typeId: "yawn.rg.input.scene" },
{ id: "bloom", typeId: "yawn.rg.post.bloom" },
{
id: "grade",
typeId: "yawn.rg.post.color-grade",
parameters: {
exposure: { kind: "number", value: 0.1 },
saturation: { kind: "number", value: 1.18 },
liftColor: { kind: "color", value: [0.98, 0.99, 1, 1] },
gammaColor: { kind: "color", value: [0.96, 0.985, 1, 1] },
gainColor: { kind: "color", value: [0.92, 0.97, 1, 1] },
},
},
{ id: "vignette", typeId: "yawn.rg.post.vignette" },
{ id: "present", typeId: "yawn.rg.output.present" },
]),
preset("blueprint", "Blueprint edges", [
{ id: "scene", typeId: "yawn.rg.input.scene" },
{ id: "normals", typeId: "yawn.rg.mode.normals" },
{
id: "edges",
typeId: "yawn.rg.post.edges",
parameters: {
strength: { kind: "number", value: 1.25 },
style: { kind: "string", value: "blueprint" },
},
},
{ id: "present", typeId: "yawn.rg.output.present" },
]),
];
+566
View File
@@ -0,0 +1,566 @@
import type {
GraphLayoutV2,
GraphNode,
GraphSnapshot,
ParameterValue,
} from "fxnode";
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 debugShader(mode: string, detailed: 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 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);`
: 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,
}
@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>,
${detailed ? "@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);
output.normal = normalize(rotate(rotors[instance], ${detailed ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));
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 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 key = `${mode}:${detailed}:${format}`;
let id = generated.get(key);
if (!id) {
id = `rg-${mode}-${detailed ? "detailed" : "basic"}`;
let suffix = 1;
while (byId.has(id))
id = `rg-${mode}-${detailed ? "detailed" : "basic"}-${suffix++}`;
const pipeline = {
id,
code: debugShader(mode, detailed, node),
vertex: {
entry: "vertex",
buffers: original.vertex.buffers.slice(0, detailed ? 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 || (detailed && binding.slot === 1),
);
}
}
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 output = `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,
});
pipelines.push({
id: pipeline,
code: effectShader(node),
vertex: { entry: "vertex", buffers: [] },
fragment: {
entry: "fragment",
targets: [{ format: 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];
}
sourceBinding.resource = input;
present.after = after;
}
/**
* 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 {
if (!plainRecord(baseGraph))
throw new TypeError("Base render graph must be plain data");
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 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 encodeRenderGraph(graph);
}
+315
View File
@@ -0,0 +1,315 @@
import {
createFxNode,
type FxNode,
type FxNodeView,
type GraphLayoutV2,
type Vec2,
} from "fxnode";
import { renderGraphComposition, renderGraphPresets } from "./catalog";
export interface RenderGraphAddNodeRequest {
readonly viewPosition: Vec2;
add(typeId: string): Promise<void>;
}
export interface RenderGraphEditorOptions {
readonly canvas: HTMLCanvasElement;
readonly initialLayout?: GraphLayoutV2;
readonly workerUrl: string | URL;
readonly onChange?: () => void;
readonly onAddNodeMenu?: (request: RenderGraphAddNodeRequest) => void;
readonly onError?: (error: unknown) => void;
}
export interface RenderGraphEditor {
load(layout: GraphLayoutV2): Promise<void>;
save(): Promise<GraphLayoutV2>;
syncViewport(): Promise<void>;
whenRendered(): Promise<void>;
destroy(): Promise<void>;
}
function viewport(canvas: HTMLCanvasElement) {
const dpr = Math.min(
4,
Math.max(1, canvas.ownerDocument.defaultView?.devicePixelRatio || 1),
);
const maximumDimension = Math.floor(8192 / dpr);
const maximumPixels = Math.floor(16_777_216 / (dpr * dpr));
const width = Math.min(maximumDimension, Math.max(1, canvas.clientWidth));
return {
width,
height: Math.min(
maximumDimension,
Math.floor(maximumPixels / width),
Math.max(1, canvas.clientHeight),
),
dpr,
};
}
function sizeCanvas(
canvas: HTMLCanvasElement,
value: ReturnType<typeof viewport>,
) {
const width = Math.max(1, Math.round(value.width * value.dpr));
const height = Math.max(1, Math.round(value.height * value.dpr));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
}
function position(canvas: HTMLCanvasElement, event: MouseEvent) {
const bounds = canvas.getBoundingClientRect();
return { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
}
function modifiers(event: MouseEvent | KeyboardEvent) {
return {
alt: event.altKey,
control: event.ctrlKey,
meta: event.metaKey,
shift: event.shiftKey,
};
}
/** Creates the worker-owned FxNode render-graph editor used by the playground. */
export async function createRenderGraphEditor({
canvas,
initialLayout = renderGraphPresets[0]!.layout,
workerUrl,
onChange = () => {},
onAddNodeMenu = () => {},
onError = console.error,
}: RenderGraphEditorOptions): Promise<RenderGraphEditor> {
let root: FxNode | undefined;
let view: FxNodeView | undefined;
let destroyed = false;
let menuPending = false;
let resizeWork = Promise.resolve();
const captured = new Set<number>();
const listeners: Array<
readonly [EventTarget, string, EventListener, AddEventListenerOptions?]
> = [];
const subscriptions: (() => void)[] = [];
const listen = (
target: EventTarget,
type: string,
listener: EventListener,
options?: AddEventListenerOptions,
) => {
target.addEventListener(type, listener, options);
listeners.push([target, type, listener, options]);
};
const report = (error: unknown) => {
try {
onError(error);
} catch (nested) {
console.error("Render graph editor error callback failed", nested);
}
};
try {
root = await createFxNode({
applicationId: renderGraphComposition.id,
applicationVersion: renderGraphComposition.version,
resources: {},
workerUrl,
});
await root.loadComposition(renderGraphComposition);
await root.load(initialLayout);
const initialViewport = viewport(canvas);
sizeCanvas(canvas, initialViewport);
view = await root.attachView({
canvas,
viewport: initialViewport,
initialCamera: {
center: { x: 0, y: 10 },
zoom:
(canvas.ownerDocument.defaultView?.innerWidth ?? 1024) <= 800
? 0.9
: 1.1,
},
});
canvas.tabIndex = 0;
canvas.style.touchAction = "none";
const activeView = view;
const fitGraph = async () => {
activeView.feedInput({
kind: "key",
phase: "down",
key: "Home",
code: "Home",
repeat: false,
modifiers: { alt: false, control: false, meta: false, shift: false },
});
await activeView.whenRendered();
};
const pointer = (event: PointerEvent) => {
if (destroyed) return;
const phase =
event.type === "pointerdown"
? "down"
: event.type === "pointermove"
? "move"
: event.type === "pointerup"
? "up"
: "cancel";
if (phase === "down") {
canvas.focus();
menuPending = event.button === 2 && !event.ctrlKey;
try {
canvas.setPointerCapture(event.pointerId);
captured.add(event.pointerId);
} catch {
// Pointer capture is optional on detached/test canvases.
}
}
activeView.feedInput({
kind: "pointer",
phase,
pointerId: event.pointerId,
pointerType: event.pointerType,
position: position(canvas, event),
button: event.button,
buttons: event.buttons,
modifiers: modifiers(event),
});
if (
(phase === "up" || phase === "cancel") &&
captured.delete(event.pointerId)
) {
try {
if (canvas.hasPointerCapture(event.pointerId))
canvas.releasePointerCapture(event.pointerId);
} catch {
// Capture may already have been released by the browser.
}
}
};
for (const type of [
"pointerdown",
"pointermove",
"pointerup",
"pointercancel",
])
listen(canvas, type, pointer as EventListener);
listen(canvas, "contextmenu", ((event: Event) =>
event.preventDefault()) as EventListener);
listen(
canvas,
"wheel",
((event: WheelEvent) => {
event.preventDefault();
menuPending = false;
const scale =
event.deltaMode === WheelEvent.DOM_DELTA_LINE
? 16
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
? Math.max(1, canvas.clientHeight)
: 1;
activeView.feedInput({
kind: "wheel",
position: position(canvas, event),
delta: { x: event.deltaX * scale, y: event.deltaY * scale },
modifiers: modifiers(event),
});
}) as EventListener,
{ passive: false },
);
for (const type of ["keydown", "keyup"])
listen(canvas, type, ((event: KeyboardEvent) => {
menuPending = false;
activeView.feedInput({
kind: "key",
phase: event.type === "keydown" ? "down" : "up",
key: event.key,
code: event.code,
repeat: event.repeat,
modifiers: modifiers(event),
});
}) as EventListener);
for (const type of ["focus", "blur"])
listen(canvas, type, (() =>
activeView.feedInput({
kind: "focus",
phase: type === "focus" ? "focus" : "blur",
})) as EventListener);
const syncViewport = async () => {
if (destroyed) return;
const next = viewport(canvas);
resizeWork = resizeWork.then(async () => {
await activeView.setViewport(next);
sizeCanvas(canvas, next);
});
await resizeWork;
};
const observer = new ResizeObserver(
() => void syncViewport().catch(report),
);
observer.observe(canvas);
subscriptions.push(() => observer.disconnect());
subscriptions.push(
activeView.onHostRequests((request) => {
if (request.kind !== "add-node-menu" || !menuPending) return;
menuPending = false;
onAddNodeMenu({
viewPosition: request.viewPosition,
async add(typeId) {
await activeView.addNode({
typeId,
viewPosition: request.viewPosition,
});
await activeView.whenRendered();
},
});
}),
);
subscriptions.push(root.onMutations(() => onChange()));
await fitGraph();
return {
async load(layout) {
if (destroyed || !root)
throw new Error("Render graph editor is closed");
await root.load(layout);
await fitGraph();
},
async save() {
if (destroyed || !root)
throw new Error("Render graph editor is closed");
return root.save();
},
syncViewport,
whenRendered: () => activeView.whenRendered(),
async destroy() {
if (destroyed) return;
destroyed = true;
for (const unsubscribe of subscriptions) unsubscribe();
for (const [target, type, listener, options] of listeners)
target.removeEventListener(type, listener, options);
for (const pointerId of captured) {
try {
if (canvas.hasPointerCapture(pointerId))
canvas.releasePointerCapture(pointerId);
} catch {
// The canvas may already be detached.
}
}
captured.clear();
await activeView.detach().catch(() => undefined);
root?.destroy();
root = undefined;
view = undefined;
},
};
} catch (error) {
for (const unsubscribe of subscriptions) unsubscribe();
for (const [target, type, listener, options] of listeners)
target.removeEventListener(type, listener, options);
await view?.detach().catch(() => undefined);
root?.destroy();
throw error;
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./catalog";
export * from "./compiler";
export * from "./editor";
+269
View File
@@ -0,0 +1,269 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
renderGraphComposition,
renderGraphNodeGroups,
renderGraphPresets,
} from "../src/catalog";
import { compileFxNodeRenderGraph, encodeRenderGraph } from "../src/compiler";
import { bindFxNodeHeadless } from "../../../vendor/fxnode/src/headless-runtime";
import { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile";
const baseGraph = {
id: "scene",
resources: {
buffers: [
{ id: "node-positions", array: "nodePositions", usage: ["storage"] },
{ id: "node-rotors", array: "nodeRotors", usage: ["storage"] },
{ id: "node-scales", array: "nodeScales", usage: ["storage"] },
{ id: "mesh-info", array: "meshInfo", usage: ["storage"] },
{ id: "camera-matrices", array: "cameraMatrices", usage: ["storage"] },
{ id: "geometry-1-positions", array: "positions", usage: ["vertex"] },
{ id: "geometry-1-normals", array: "normals", usage: ["vertex"] },
],
textures: [
{
id: "hdr",
format: "rgba16float",
size: ["canvas", "canvas", 1],
usage: ["render", "sampled"],
},
{
id: "depth",
format: "depth24plus",
size: ["canvas", "canvas", 1],
usage: ["render"],
},
],
samplers: [{ id: "linear", descriptor: { magFilter: "linear" } }],
},
pipelines: {
compute: [],
render: [
{
id: "forward-pbr-0",
code: "original pbr shader",
vertex: {
entry: "vertex",
buffers: [
{
arrayStride: 16,
attributes: [
{ format: "float32x3", offset: 0, shaderLocation: 0 },
],
},
{
arrayStride: 16,
attributes: [
{ format: "float32x3", offset: 0, shaderLocation: 1 },
],
},
{
arrayStride: 16,
attributes: [
{ format: "float32x2", offset: 0, shaderLocation: 2 },
],
},
],
},
fragment: {
entry: "fragment",
targets: [{ format: "rgba16float" }],
},
depthStencil: {
format: "depth24plus",
depth_write_enabled: true,
depth_compare: "less",
},
},
{
id: "present",
code: "present shader",
vertex: { entry: "vertex", buffers: [] },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
},
],
},
passes: [
{
id: "forward-1--1",
type: "render",
pipeline: "forward-pbr-0",
bindings: [{ group: 0, binding: 0, resource: "unrelated" }],
color: [{ resource: "hdr", clear: [0, 0, 0, 1] }],
depth: { resource: "depth", clear: 1 },
vertexBuffers: [
{ slot: 0, resource: "geometry-1-positions" },
{ slot: 1, resource: "geometry-1-normals" },
{ slot: 2, resource: "geometry-1-uvs" },
],
draw: { vertices: 3, instances: 1 },
},
{
id: "present",
type: "render",
pipeline: "present",
after: ["forward-1--1"],
bindings: [
{ group: 0, binding: 0, resource: "hdr" },
{ group: 0, binding: 1, resource: "linear" },
],
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
draw: { vertices: 3 },
},
],
};
test("catalog exposes grouped, materially rich render graph nodes", () => {
assert.equal(renderGraphNodeGroups.length, 6);
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 25);
for (const item of renderGraphNodeGroups.flatMap((group) => group.items))
assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId);
assert.doesNotThrow(() => compileFxNodeComposition(renderGraphComposition));
});
test("Color Grade exposes Blender-style grading modes and migrates version 1 nodes", () => {
const definition = renderGraphComposition.nodes["yawn.rg.post.color-grade"];
assert.equal(definition.version, 2);
assert.deepEqual(
definition.ui
.filter((row) => row.kind === "widget" && row.widget === "grading-wheels")
.map((row) => row.bindings.map((binding) => binding.title)),
[
["Lift", "Gamma", "Gain"],
["Offset", "Power", "Slope"],
],
);
assert.deepEqual(definition.parameters.mode.enum, [
"Lift/Gamma/Gain",
"Offset/Power/Slope",
"White Point",
]);
const legacy = structuredClone(
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
) as any;
legacy.catalogVersion = 1;
const grade = legacy.nodes.find((node: any) => node.id === "grade");
grade.typeVersion = 1;
grade.parameters = Object.fromEntries(
["exposure", "saturation", "tint", "tintAmount"].map((key) => [
key,
grade.parameters[key],
]),
);
const runtime = bindFxNodeHeadless(
compileFxNodeComposition(renderGraphComposition),
);
const decoded = runtime.decodeGraphDocument(legacy);
assert.equal(decoded.ok, true);
if (!decoded.ok) return;
const migrated = decoded.value.nodes.grade!;
assert.equal(migrated.known, true);
assert.equal(migrated.typeVersion, 2);
assert.equal(migrated.parameters.mode.value, "Lift/Gamma/Gain");
assert.equal(migrated.parameters.contrast.value, 1);
assert.equal(migrated.parameters.outputTemperature.value, 6500);
});
test("prepared loadouts are connected to one Present output", () => {
assert.deepEqual(
renderGraphPresets.map((preset) => preset.id),
["beauty", "normals", "depth", "clay", "cinematic", "blueprint"],
);
assert.equal(renderGraphPresets[0]!.name, "Standard");
for (const preset of renderGraphPresets) {
assert.equal(
preset.layout.nodes.filter(
(node) => node.typeId === "yawn.rg.output.present",
).length,
1,
);
assert.equal(preset.layout.links.length, preset.layout.nodes.length - 1);
const left = Math.min(
...preset.layout.nodes.map((node) => node.position.x),
);
const right = Math.max(
...preset.layout.nodes.map((node) => node.position.x + node.size.x),
);
assert.ok(right - left <= 650, `${preset.name} is too wide for the editor`);
}
});
test("wire encoder is stable and rejects unsupported numbers", () => {
assert.equal(
encodeRenderGraph({ z: [true, "x"], a: 1 }),
'(yawn-graph 1 (object (field "a" 1) (field "z" (array true "x"))))',
);
assert.throws(() => encodeRenderGraph({ value: Number.NaN }), /finite/u);
});
test("normals loadout replaces scene forward shading without mutating the base", () => {
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
const serialized = compileFxNodeRenderGraph(normals.layout, baseGraph);
assert.match(serialized, /scene-fxnode-normals/u);
assert.match(serialized, /rg-normals-detailed/u);
assert.match(serialized, /normalize\(input\.normal\) \* 0\.5 \+ 0\.5/u);
assert.match(serialized, /camera-matrices/u);
assert.equal(baseGraph.passes[0].pipeline, "forward-pbr-0");
assert.equal(baseGraph.pipelines.render.length, 2);
});
test("cinematic loadout inserts ordered sampled post passes", () => {
const cinematic = renderGraphPresets.find(
(preset) => preset.id === "cinematic",
)!;
const serialized = compileFxNodeRenderGraph(cinematic.layout, baseGraph);
assert.match(serialized, /rg-effect-pass-0-bloom/u);
assert.match(serialized, /rg-effect-pass-1-grade/u);
assert.match(serialized, /rg-effect-pass-2-vignette/u);
assert.match(serialized, /rg-effect-2-vignette/u);
assert.match(serialized, /let liftBalance = vec3<f32>/u);
assert.match(serialized, /let gammaBalance = vec3<f32>/u);
assert.match(serialized, /let gainBalance = vec3<f32>/u);
assert.match(serialized, /color = \(color - vec3<f32>\(0\.18000\)\)/u);
});
test("Color Grade compiles offset and white-point modes into pixel math", () => {
const loadout = structuredClone(
renderGraphPresets.find((preset) => preset.id === "cinematic")!.layout,
) as any;
const grade = loadout.nodes.find((node: any) => node.id === "grade");
grade.parameters.mode = { kind: "string", value: "Offset/Power/Slope" };
grade.parameters.offset = { kind: "number", value: 0.08 };
grade.parameters.power = { kind: "number", value: 1.12 };
grade.parameters.slope = { kind: "number", value: 0.94 };
let serialized = compileFxNodeRenderGraph(loadout, baseGraph);
assert.match(serialized, /let offsetBalance = vec3<f32>/u);
assert.match(serialized, /let powerBalance = vec3<f32>/u);
assert.match(serialized, /color \* slopeBalance \+ offsetBalance/u);
grade.parameters.mode = { kind: "string", value: "White Point" };
grade.parameters.inputTemperature = { kind: "number", value: 7200 };
grade.parameters.outputTemperature = { kind: "number", value: 4800 };
grade.parameters.outputTint = { kind: "number", value: -0.12 };
serialized = compileFxNodeRenderGraph(loadout, baseGraph);
assert.match(serialized, /let whitePointAdaptation = vec3<f32>/u);
assert.match(serialized, /color \*= whitePointAdaptation/u);
});
test("blueprint loadout converts normals into blue paper and linework", () => {
const blueprint = renderGraphPresets.find(
(preset) => preset.id === "blueprint",
)!;
const serialized = compileFxNodeRenderGraph(blueprint.layout, baseGraph);
assert.match(serialized, /scene-fxnode-normals-edges/u);
assert.match(serialized, /vec3<f32>\(0\.008, 0\.045, 0\.11\)/u);
assert.match(serialized, /vec3<f32>\(0\.68, 0\.94, 1\.0\)/u);
assert.match(serialized, /fract\(input\.uv \* vec2<f32>\(24\.0, 16\.0\)\)/u);
assert.match(serialized, /, line\), 1\.0\)/u);
});
test("compiler rejects a disconnected output", () => {
const disconnected = structuredClone(renderGraphPresets[0]!.layout);
disconnected.links = [];
assert.throws(
() => compileFxNodeRenderGraph(disconnected, baseGraph),
/not connected to Scene/u,
);
});
+37
View File
@@ -0,0 +1,37 @@
import { mkdtemp, readdir, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import { build } from "esbuild";
const directory = fileURLToPath(new URL(".", import.meta.url));
const output = await mkdtemp(join(tmpdir(), "yawn-fxnode-rg-tests-"));
try {
const entries = (await readdir(directory))
.filter((name) => name.endsWith(".test.ts"))
.sort()
.map((name) => join(directory, name));
await build({
entryPoints: entries,
outdir: output,
entryNames: "[name]",
outExtension: { ".js": ".mjs" },
bundle: true,
format: "esm",
platform: "node",
target: "node20",
sourcemap: "inline",
});
const tests = entries.map((entry) =>
join(output, `${basename(entry, ".ts")}.mjs`),
);
const result = spawnSync(process.execPath, ["--test", ...tests], {
stdio: "inherit",
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
} finally {
await rm(output, { force: true, recursive: true });
}
+121 -108
View File
@@ -56,6 +56,7 @@ const compactLightLimit = 500;
const compactPointStart = 11;
const compactSpotCount = compactPointStart + compactLightLimit;
const compactSpotStart = compactSpotCount + 1;
const compactAmbientStart = compactSpotStart + compactLightLimit;
const clusterShader = /* wgsl */ `
@group(0) @binding(0) var<storage, read> pointLights: array<vec4<f32>>;
@@ -72,6 +73,7 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
var pointCount = 0u;
var spotCount = 0u;
var light = vec3<f32>(0.0);
var ambientLight = vec3<f32>(0.0);
var pointLight = vec3<f32>(0.0);
var spotLight = vec3<f32>(0.0);
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
@@ -90,7 +92,9 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) {
let enabled = rectLights[i * 3u + 1u].z;
count += select(0u, 1u, enabled != 0.0);
light += rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02;
let contribution = rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02;
light += contribution;
ambientLight += contribution;
}
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) {
let enabled = spotLights[i * 2u + 1u].w;
@@ -112,7 +116,9 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
}
for (var i = 0u; i < arrayLength(&ambientLights); i++) {
count += select(0u, 1u, ambientLights[i].a != 0.0);
light += ambientLights[i].rgb * ambientLights[i].a;
let contribution = ambientLights[i].rgb * ambientLights[i].a;
light += contribution;
ambientLight += contribution;
}
clusters[0] = count;
clusters[1] = bitcast<u32>(light.r);
@@ -126,6 +132,9 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
clusters[9] = bitcast<u32>(spotLight.b);
clusters[10] = pointCount;
clusters[${compactSpotCount}] = spotCount;
clusters[${compactAmbientStart}] = bitcast<u32>(ambientLight.r);
clusters[${compactAmbientStart + 1}] = bitcast<u32>(ambientLight.g);
clusters[${compactAmbientStart + 2}] = bitcast<u32>(ambientLight.b);
}`;
const basicForwardShader = /* wgsl */ `
@@ -180,9 +189,7 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let base = materials[material * 3u];
let properties = materials[material * 3u + 1u];
let clusterLight = vec3<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3]));
let light = vec3<f32>(0.12 + max(dot(input.normal, normalize(vec3<f32>(0.4, 0.7, 0.6))), 0.0) * 0.75) + clusterLight;
let clustered = min(f32(clusters[0]) * 0.002, 0.05);
let color = base.rgb * (light + clustered) * accent.color.rgb * mix(1.0, 1.1, properties.x);
let color = base.rgb * clusterLight * accent.color.rgb * mix(1.0, 1.1, properties.x);
return vec4<f32>(color, base.a);
}`;
@@ -272,6 +279,7 @@ struct VertexOutput {
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
@group(0) @binding(8) var<storage, read> pointLights: array<vec4<f32>>;
@group(0) @binding(9) var<storage, read> spotLights: array<vec4<f32>>;
@group(0) @binding(10) var<storage, read> directionalLights: array<vec4<f32>>;
${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""}
${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d<f32>;` : "")).join("\n")}
${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d<f32>;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""}
@@ -315,36 +323,43 @@ fn vertex(
return output;
}
fn studioEnvironment(direction: vec3<f32>) -> vec3<f32> {
let ray = direction;
let horizon = mix(vec3(0.14, 0.15, 0.16), vec3(0.62, 0.59, 0.54), clamp(ray.y * 0.5 + 0.5, 0.0, 1.0));
let key = max(dot(ray, normalize(vec3(-0.65, 0.7, 0.35))), 0.0);
let key2 = key * key;
let key4 = key2 * key2;
let key8 = key4 * key4;
let key16 = key8 * key8;
let key32 = key16 * key16;
let key64 = key32 * key32;
return horizon + vec3(5.0, 4.2, 3.1) * key64 * key32;
}
fn pow5(value: f32) -> f32 {
let squared = value * value;
return squared * squared * value;
}
fn pointContribution(index: u32, normal: vec3<f32>, world: vec3<f32>) -> vec3<f32> {
fn directContribution(normal: vec3<f32>, view: vec3<f32>, lightDirection: vec3<f32>, radiance: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let nDotL = max(dot(normal, lightDirection), 0.0);
if (nDotL <= 0.0) { return vec3(0.0); }
let halfVector = normalize(lightDirection + view);
let nDotV = max(dot(normal, view), 0.0001);
let nDotH = max(dot(normal, halfVector), 0.0);
let vDotH = max(dot(view, halfVector), 0.0);
let fresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - vDotH);
let alpha = roughness * roughness;
let alpha2 = alpha * alpha;
let denominator = nDotH * nDotH * (alpha2 - 1.0) + 1.0;
let distribution = alpha2 / max(3.14159265 * denominator * denominator, 0.0001);
let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5);
let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5);
let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001);
let diffuse = baseColor * (vec3(1.0) - fresnel) * (1.0 - metallic) / 3.14159265;
return (diffuse + specular) * radiance * nDotL;
}
fn pointContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let colorIntensity = pointLights[index * 2u];
let state = pointLights[index * 2u + 1u];
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
let offset = positions[index].xyz - world;
let distance = length(offset);
let falloff = max(1.0 - distance / state.x, 0.0);
let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0);
return colorIntensity.rgb * colorIntensity.a * falloff * falloff * incidence * 0.08;
let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08;
let sourceRoughness = clamp(roughness + max(state.z, 0.0) / max(distance, 0.0001), roughness, 1.0);
return directContribution(normal, view, offset / max(distance, 0.0001), radiance, baseColor, metallic, sourceRoughness, f0);
}
fn spotContribution(index: u32, normal: vec3<f32>, world: vec3<f32>) -> vec3<f32> {
fn spotContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, world: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let colorIntensity = spotLights[index * 2u];
let state = spotLights[index * 2u + 1u];
if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); }
@@ -353,8 +368,16 @@ fn spotContribution(index: u32, normal: vec3<f32>, world: vec3<f32>) -> vec3<f32
let direction = normalize(rotate(rotors[index], vec3<f32>(0.0, 0.0, -1.0)));
let cone = smoothstep(cos(state.z), cos(state.y), dot(direction, fromLight / max(distance, 0.0001)));
let falloff = max(1.0 - distance / state.x, 0.0);
let incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0);
return colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * incidence * 0.08;
let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08;
return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, baseColor, metallic, roughness, f0);
}
fn directionalContribution(index: u32, normal: vec3<f32>, view: vec3<f32>, baseColor: vec3<f32>, metallic: f32, roughness: f32, f0: vec3<f32>) -> vec3<f32> {
let colorIntensity = directionalLights[index * 2u];
let state = directionalLights[index * 2u + 1u];
if (state.x == 0.0) { return vec3(0.0); }
let lightDirection = normalize(rotate(rotors[index], vec3<f32>(0.0, 0.0, 1.0)));
return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, baseColor, metallic, roughness, f0);
}
@fragment
@@ -379,12 +402,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
${doubleSided ? "normal *= select(-1.0, 1.0, frontFacing);" : ""}
${normalTexture ? `let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = ${sample(2)}.xyz * 2.0 - 1.0; normal = normalize(mat3x3<f32>(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""}
let view = normalize(cameraMatrices[4].xyz - input.world);
let lightDirection = normalize(vec3<f32>(0.4, 0.7, 0.6));
let halfVector = normalize(lightDirection + view);
let nDotL = max(dot(normal, lightDirection), 0.0);
let nDotV = max(dot(normal, view), 0.0001);
let nDotH = max(dot(normal, halfVector), 0.0);
let vDotH = max(dot(view, halfVector), 0.0);
let specularWeight = extension2.z * ${sample(11)}.a;
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"};
@@ -398,58 +416,42 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
f0 = mix(f0, iridescenceTint * iridescenceLevel, iridescenceAmount * (1.0 - roughness * 0.5));`
: ""
}
let fresnelPower = pow5(1.0 - vDotH);
let fresnel = f0 + (vec3(1.0) - f0) * fresnelPower;
let alpha = roughness * roughness;
let alpha2 = alpha * alpha;
let denominator = nDotH * nDotH * (alpha2 - 1.0) + 1.0;
let distribution = alpha2 / max(3.14159265 * denominator * denominator, 0.0001);
let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5);
let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5);
let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001);
let clusterLight = vec3<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3]));
let pointAmbient = vec3<f32>(bitcast<f32>(clusters[4]), bitcast<f32>(clusters[5]), bitcast<f32>(clusters[6]));
let spotAmbient = vec3<f32>(bitcast<f32>(clusters[7]), bitcast<f32>(clusters[8]), bitcast<f32>(clusters[9]));
let ambient = vec3(0.14) + max(clusterLight - pointAmbient - spotAmbient, vec3(0.0));
var pointDiffuse = vec3<f32>(0.0);
let fresnelPower = pow5(1.0 - nDotV);
let ambient = vec3<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
var directLight = vec3<f32>(0.0);
let pointCount = clusters[10];
if (pointCount <= ${compactLightLimit}u) {
for (var light = 0u; light < pointCount; light++) {
pointDiffuse += pointContribution(clusters[${compactPointStart}u + light], normal, input.world);
directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0);
}
} else {
for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
pointDiffuse += pointContribution(light, normal, input.world);
directLight += pointContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0);
}
}
var spotDiffuse = vec3<f32>(0.0);
let spotCount = clusters[${compactSpotCount}];
if (spotCount <= ${compactLightLimit}u) {
for (var light = 0u; light < spotCount; light++) {
spotDiffuse += spotContribution(clusters[${compactSpotStart}u + light], normal, input.world);
directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0);
}
} else {
for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
spotDiffuse += spotContribution(light, normal, input.world);
directLight += spotContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0);
}
}
let defaultLight = select(1.0, 0.18, clusters[0] != 0u);
for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) {
directLight += directionalContribution(light, normal, view, base.rgb, metallic, roughness, f0);
}
let occlusion = mix(1.0, ${sample(4)}.r, extension2.w);
let diffuseColor = base.rgb * (vec3(1.0) - fresnel) * (1.0 - metallic) / 3.14159265;
let diffuse = (diffuseColor + specular) * nDotL * defaultLight;
let reflected = studioEnvironment(reflect(-view, normal));
let environmentFresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - nDotV);
let environment = (base.rgb * (1.0 - metallic) * studioEnvironment(normal) * 0.5 + reflected * environmentFresnel * (1.0 - roughness * 0.75)) * occlusion;
let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w;
let localDiffuse = base.rgb * (1.0 - metallic) * (pointDiffuse + spotDiffuse);
${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""}
${
clearcoat
? `let clearcoatFresnel = 0.04 + 0.96 * fresnelPower;
let clearcoat = extension2.x * ${sample(7)}.r * clearcoatFresnel * pow(nDotH, mix(256.0, 4.0, extension2.y));`
let clearcoat = extension2.x * ${sample(7)}.r * clearcoatFresnel;`
: ""
}
var color = base.rgb * ambient * occlusion + diffuse + environment + localDiffuse${sheen ? " + sheen" : ""}${clearcoat ? " + vec3(clearcoat)" : ""} + emissive;
var color = base.rgb * ambient * occlusion + directLight${sheen ? " + directLight * sheen" : ""}${clearcoat ? " + directLight * clearcoat" : ""} + emissive;
${unlit ? "color = base.rgb + emissive;" : ""}
${
transmission
@@ -474,7 +476,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
}
let normalizedTransmissionTint = base.rgb / max(max(base.r, base.g), max(base.b, 0.001));
transmitted *= normalizedTransmissionTint;
color = mix(color, transmitted + reflected * environmentFresnel, transmissionAmount);`
color = mix(color, transmitted, transmissionAmount);`
: ""
}
return vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"});
@@ -509,21 +511,6 @@ const copyShader = `${fullscreenVertex}
return textureSample(source, sourceSampler, input.uv);
}`;
const studioBackground = /* wgsl */ `
fn studioBackground(uv: vec2<f32>) -> vec3<f32> {
let upper = vec3(0.42, 0.43, 0.44);
let lower = vec3(0.16, 0.17, 0.18);
let vertical = smoothstep(0.0, 1.0, uv.y);
let keyBase = max(1.0 - distance(uv, vec2(0.05, 0.05)) / 0.72, 0.0);
return mix(upper, lower, vertical) + vec3(4.0, 3.8, 3.45) * keyBase * keyBase;
}`;
const studioBackgroundShader = `${fullscreenVertex}
${studioBackground}
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4(studioBackground(input.uv), 1.0);
}`;
function effectFragment(kind: string, options: Record<string, unknown>) {
const amount = Number(options.amount ?? options.exposure ?? 1);
const safeAmount = Number.isFinite(amount) ? amount : 1;
@@ -543,10 +530,7 @@ function effectFragment(kind: string, options: Record<string, unknown>) {
}`;
}
function presentShader(
toneMap: string,
composite: "studio" | "scene" | undefined,
) {
function presentShader(toneMap: string, composite: "scene" | undefined) {
const tone =
toneMap === "reinhard"
? "color / (color + vec3(1.0))"
@@ -554,13 +538,12 @@ function presentShader(
? "clamp(color, vec3(0.0), vec3(1.0))"
: "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
return `${fullscreenVertex}
${composite === "studio" ? studioBackground : ""}
@group(0) @binding(0) var source: texture_2d<f32>;
@group(0) @binding(1) var sourceSampler: sampler;
${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d<f32>;" : ""}
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
let value = textureSample(source, sourceSampler, input.uv);
let color = max(${composite === "studio" ? "value.rgb + studioBackground(input.uv) * (1.0 - value.a)" : composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0));
let color = max(${composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0));
return vec4(${tone}, ${composite ? "1.0" : "value.a"});
}`;
}
@@ -599,7 +582,9 @@ export class Scene {
readonly core: YawnCore;
readonly ready: Promise<this>;
readonly hdr: boolean;
readonly clearColor: readonly [number, number, number, number];
#graphUpdates = Promise.resolve();
#renderGraph?: object;
#computePasses = new Map<string, ComputePass>();
#meshes = new Map<number, MeshLike>();
#shaders = new Map<number, ShaderLike>();
@@ -625,9 +610,17 @@ export class Scene {
debug?: boolean;
fps?: number;
hdr?: boolean;
clearColor?: ArrayLike<number>;
} = {},
) {
const clearColor = Array.from(options.clearColor ?? [0, 0, 0, 1]);
if (
clearColor.length !== 4 ||
clearColor.some((value) => !Number.isFinite(value))
)
throw new TypeError("Scene clearColor must contain four finite numbers");
this.hdr = options.hdr ?? true;
this.clearColor = clearColor as [number, number, number, number];
this.core = new YawnCore(canvas, {
arenaBytes: options.arenaBytes,
debug: options.debug,
@@ -648,7 +641,7 @@ export class Scene {
});
this.core.array("nodeRotors").write(0, [0, 0, 0, 1]);
this.core.array("nodeScales").write(0, [1, 1, 1, 0]);
this.core.array("sceneAccent").write(0, [0.28, 0.72, 1, 1]);
this.core.array("sceneAccent").write(0, [1, 1, 1, 1]);
const material = await this.core.allocateObject("materials");
this.core
.array("materials")
@@ -1045,6 +1038,29 @@ export class Scene {
return update;
}
/** Compiles a serialized render graph and returns its reusable loadout ID. */
async compileRenderGraph(serialized: string) {
if (typeof serialized !== "string")
throw new TypeError("Serialized render graph must be a string");
await this.ready;
return this.core.compileGraph(serialized);
}
/** Switches this Scene to a previously compiled render-graph loadout. */
async setLoadout(id: string) {
if (typeof id !== "string")
throw new TypeError("Loadout ID must be a string");
await this.ready;
await this.core.switchLoadout(id);
}
/** Returns an isolated snapshot of the Scene-authored render graph. */
async getRenderGraph() {
await this.ready;
if (!this.#renderGraph) throw new Error("Render graph is not ready");
return structuredClone(this.#renderGraph);
}
async #compileRenderGraph() {
const buffers = new Map<string, GraphBuffer>();
const textures = new Map<string, GraphTexture>();
@@ -1127,11 +1143,6 @@ export class Scene {
(material) => this.array("materialExtensions").row(material)[0] > 0,
);
});
const compositeStudioBackground =
renderedMeshes.length > 0 &&
!hasTransmission &&
this.#effects.size === 0 &&
this.#shaders.size === 0;
const compositeSceneColor =
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
@@ -1190,7 +1201,7 @@ export class Scene {
type: "render",
pipeline: "empty-forward",
after: previous,
color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }],
color: [{ resource: "hdr", clear: [...this.clearColor] }],
draw: { vertices: 0 },
});
previous = ["forward-empty"];
@@ -1204,27 +1215,27 @@ export class Scene {
["camera-matrices", "cameraMatrices"],
])
addBuffer({ id, array, usage: ["storage"] });
if (!compositeStudioBackground) {
if (compositeSceneColor) {
renderPipelines.push({
id: "studio-background",
code: studioBackgroundShader,
id: "scene-color-clear",
code: clearShader,
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
});
passes.push({
id: "studio-background",
id: "scene-color-clear",
type: "render",
pipeline: "studio-background",
pipeline: "scene-color-clear",
after: previous,
color: [
{
resource: compositeSceneColor ? "scene-color" : "hdr",
clear: [0, 0, 0, 1],
resource: "scene-color",
clear: [...this.clearColor],
},
],
draw: { vertices: 3 },
draw: { vertices: 0 },
});
previous = ["studio-background"];
previous = ["scene-color-clear"];
}
const forwardPipelines = new Map<string, string>();
let firstColor = true;
@@ -1363,7 +1374,9 @@ export class Scene {
{ group: 0, binding: 0, resource: "hdr" },
{ group: 0, binding: 1, resource: "linear" },
],
color: [{ resource: "scene-color", clear: [0, 0, 0, 1] }],
color: [
{ resource: "scene-color", clear: [...this.clearColor] },
],
draw: { vertices: 3 },
});
previous = ["copy-scene-color"];
@@ -1543,6 +1556,11 @@ export class Scene {
? [
{ group: 0, binding: 8, resource: "point-lights" },
{ group: 0, binding: 9, resource: "spot-lights" },
{
group: 0,
binding: 10,
resource: "directional-lights",
},
]
: []),
...(detailed && mask
@@ -1577,10 +1595,11 @@ export class Scene {
color: [
{
resource: forwardTarget,
...((firstColor && compositeStudioBackground) ||
clearCompositeLayer
...(clearCompositeLayer
? { clear: [0, 0, 0, 0] }
: { load: "load" }),
: firstColor
? { clear: [...this.clearColor] }
: { load: "load" }),
},
],
depth: {
@@ -1689,14 +1708,7 @@ export class Scene {
);
renderPipelines.push({
id: "present",
code: presentShader(
toneMap,
compositeStudioBackground
? "studio"
: compositeSceneColor
? "scene"
: undefined,
),
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
vertex: { entry: "vertex" },
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
});
@@ -1712,7 +1724,7 @@ export class Scene {
? [{ group: 0, binding: 2, resource: "scene-color" }]
: []),
],
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
color: [{ resource: "canvas", clear: [...this.clearColor] }],
draw: { vertices: 3 },
});
@@ -1729,6 +1741,7 @@ export class Scene {
pipelines: { render: renderPipelines, compute: computePipelines },
passes,
};
this.#renderGraph = structuredClone(graph);
const id = await this.core.compileGraph(serialize(graph));
await this.core.switchLoadout(id);
}
+46 -9
View File
@@ -1,25 +1,62 @@
import { Node, type NodeOptions } from "../Node";
import type { Scene } from "../Scene";
export type PointLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number; range?: number };
export type PointLightOptions = NodeOptions & {
color?: ArrayLike<number>;
intensity?: number;
range?: number;
radius?: number;
};
export class PointLight extends Node {
constructor(scene: Scene, options: PointLightOptions = {}) {
super(scene, options);
const nodeReady = this.ready;
this.ready = nodeReady.then(async () => {
const array = await scene.ensureRows("pointLights", this.id + 1, 32, "f32");
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, 1, 0, 0]);
const array = await scene.ensureRows(
"pointLights",
this.id + 1,
32,
"f32",
);
array
.row(this.id)
.set([
...Array.from(options.color ?? [1, 1, 1]),
options.intensity ?? 1,
options.range ?? 10,
1,
options.radius ?? 0,
0,
]);
return this;
});
}
get color() { return this.scene.array("pointLights").row(this.id).subarray(0, 3); }
set color(value: ArrayLike<number>) { this.scene.array("pointLights").row(this.id).set(value, 0); }
get intensity() { return this.scene.array("pointLights").row(this.id)[3]; }
set intensity(value: number) { this.scene.array("pointLights").row(this.id)[3] = value; }
get range() { return this.scene.array("pointLights").row(this.id)[4]; }
set range(value: number) { this.scene.array("pointLights").row(this.id)[4] = value; }
get color() {
return this.scene.array("pointLights").row(this.id).subarray(0, 3);
}
set color(value: ArrayLike<number>) {
this.scene.array("pointLights").row(this.id).set(value, 0);
}
get intensity() {
return this.scene.array("pointLights").row(this.id)[3];
}
set intensity(value: number) {
this.scene.array("pointLights").row(this.id)[3] = value;
}
get range() {
return this.scene.array("pointLights").row(this.id)[4];
}
set range(value: number) {
this.scene.array("pointLights").row(this.id)[4] = value;
}
get radius() {
return this.scene.array("pointLights").row(this.id)[6];
}
set radius(value: number) {
this.scene.array("pointLights").row(this.id)[6] = value;
}
override async dispose() {
await this.ready;
+4 -1
View File
@@ -193,15 +193,18 @@ test("Node, ArcRotateCamera, and light handles update their shared rows", async
color: [0.2, 0.4, 0.6],
intensity: 7,
range: 8,
radius: 2,
});
await light.ready;
close(
scene.array("pointLights").row(light.id),
[0.2, 0.4, 0.6, 7, 8, 1, 0, 0],
[0.2, 0.4, 0.6, 7, 8, 1, 2, 0],
);
light.intensity = 9;
light.range = 10;
light.radius = 3;
close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]);
assert.equal(scene.array("pointLights").row(light.id)[6], 3);
});
test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => {
+82 -6
View File
@@ -20,16 +20,18 @@ if (typeof globalThis.ImageBitmap === "undefined")
value: class ImageBitmap {},
});
test("empty scenes clear without drawing placeholder geometry", async () => {
test("empty scenes use neutral authored defaults without placeholder geometry", async () => {
const scene = await createScene();
const emptyPass = latestGraph(scene).passes.find(
(pass: any) => pass.id === "forward-empty",
);
assert.equal(emptyPass.draw.vertices, 0);
assert.deepEqual(emptyPass.color[0].clear, [0, 0, 0, 1]);
assert.deepEqual(Array.from(scene.array("sceneAccent").row(0)), [1, 1, 1, 1]);
});
test("opaque PBR graph fuses the background and compacts sparse lights", async () => {
test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene);
await material.ready;
@@ -46,19 +48,35 @@ test("opaque PBR graph fuses the background and compacts sparse lights", async (
assert.equal(pipeline.primitive.frontFace, "ccw");
assert.equal(
graph.passes.some((pass: any) => pass.id === "studio-background"),
graph.passes.some((pass: any) => pass.id.includes("background")),
false,
);
assert.match(present.code, /fn studioBackground\(/u);
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 0]);
assert.doesNotMatch(JSON.stringify(graph), /studio/iu);
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 1]);
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u);
assert.doesNotMatch(pipeline.code, /defaultLight|studioEnvironment/u);
assert.doesNotMatch(pipeline.code, /0\.4, 0\.7, 0\.6/u);
assert.match(
pipeline.code,
/let ambient = vec3<f32>\(bitcast<f32>\(clusters\[1012\]\)/u,
);
assert.match(pipeline.code, /fn directContribution/u);
assert.match(pipeline.code, /fn directionalContribution/u);
assert.equal(
forward.bindings.find((binding: any) => binding.binding === 10)?.resource,
"directional-lights",
);
assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u);
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
assert.match(pipeline.code, /pointCount <= 500u/u);
assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u);
assert.match(
pipeline.code,
/let sourceRoughness = clamp\(roughness \+ max\(state\.z/u,
);
assert.match(pipeline.code, /let spotCount = clusters\[511\]/u);
assert.match(pipeline.code, /spotCount <= 500u/u);
});
@@ -103,9 +121,14 @@ test("transmission renders directly into a composited layer with five taps", asy
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
assert.equal(
graph.passes.some((pass: any) => pass.id === "studio-background"),
graph.passes.some((pass: any) => pass.id === "scene-color-clear"),
true,
);
const sceneColorClear = graph.passes.find(
(pass: any) => pass.id === "scene-color-clear",
);
assert.equal(sceneColorClear.draw.vertices, 0);
assert.deepEqual(sceneColorClear.color[0].clear, [0, 0, 0, 1]);
assert.equal(
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
false,
@@ -124,6 +147,27 @@ test("transmission renders directly into a composited layer with five taps", asy
assert.match(pipeline.code, /transmitted \*= pow/u);
});
test("clear color is explicit scene data used by empty and forward graphs", async () => {
const clearColor = [0.03, 0.06, 0.09, 1] as const;
const scene = await createScene({ clearColor });
const empty = latestGraph(scene).passes.find(
(pass: any) => pass.id === "forward-empty",
);
assert.deepEqual(empty.color[0].clear, clearColor);
const material = new PBRMaterial(scene);
await material.ready;
const mesh = detailedMesh(scene, material);
await mesh.ready;
const graph = latestGraph(scene);
assert.deepEqual(forwardPasses(graph)[0].color[0].clear, clearColor);
assert.deepEqual(
graph.passes.find((pass: any) => pass.id === "present").color[0].clear,
clearColor,
);
});
test("post-processing keeps the conservative transmission copy", async () => {
const scene = await createScene();
const material = new PBRMaterial(scene, { transmission: 1 });
@@ -273,3 +317,35 @@ test("batchGraphUpdates compiles once for multiple mesh registrations", async ()
assert.equal(testCore(scene).graphs.length - before, 1);
assert.equal(forwardPasses(latestGraph(scene)).length, 2);
});
test("render graph snapshots expose isolated scene-owned declarations", async () => {
const scene = await createScene();
const source: any = await scene.getRenderGraph();
assert.equal(source.id, "scene");
assert.ok(
source.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
);
source.id = "mutated";
source.resources.buffers.length = 0;
const next: any = await scene.getRenderGraph();
assert.equal(next.id, "scene");
assert.ok(
next.resources.buffers.some((buffer: any) => buffer.id === "clusters"),
);
});
test("serialized render graphs compile into explicitly selectable loadouts", async () => {
const scene = await createScene();
const core = testCore(scene);
const serialized = core.serializedGraphs.at(-1)!;
const before = core.graphs.length;
const loadoutId = await scene.compileRenderGraph(serialized);
await scene.setLoadout(loadoutId);
assert.equal(core.graphs.length, before + 1);
assert.equal(loadoutId, `loadout-${before + 1}`);
assert.equal(core.loadouts.at(-1), loadoutId);
});
+4 -2
View File
@@ -13,8 +13,10 @@ export const triangle = {
indices: [0, 1, 2],
};
export async function createScene() {
const scene = new Scene({} as HTMLCanvasElement);
export async function createScene(
options: ConstructorParameters<typeof Scene>[1] = {},
) {
const scene = new Scene({} as HTMLCanvasElement, options);
await scene.ready;
return scene;
}