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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
]),
|
||||
];
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./catalog";
|
||||
export * from "./compiler";
|
||||
export * from "./editor";
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user