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:
@@ -4,3 +4,4 @@ services:
|
|||||||
portal:
|
portal:
|
||||||
title: Yawn
|
title: Yawn
|
||||||
description: Yawn website, tutorial, package server, and WebGPU playground.
|
description: Yawn website, tutorial, package server, and WebGPU playground.
|
||||||
|
review: false
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
+121
-108
@@ -56,6 +56,7 @@ const compactLightLimit = 500;
|
|||||||
const compactPointStart = 11;
|
const compactPointStart = 11;
|
||||||
const compactSpotCount = compactPointStart + compactLightLimit;
|
const compactSpotCount = compactPointStart + compactLightLimit;
|
||||||
const compactSpotStart = compactSpotCount + 1;
|
const compactSpotStart = compactSpotCount + 1;
|
||||||
|
const compactAmbientStart = compactSpotStart + compactLightLimit;
|
||||||
|
|
||||||
const clusterShader = /* wgsl */ `
|
const clusterShader = /* wgsl */ `
|
||||||
@group(0) @binding(0) var<storage, read> pointLights: array<vec4<f32>>;
|
@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 pointCount = 0u;
|
||||||
var spotCount = 0u;
|
var spotCount = 0u;
|
||||||
var light = vec3<f32>(0.0);
|
var light = vec3<f32>(0.0);
|
||||||
|
var ambientLight = vec3<f32>(0.0);
|
||||||
var pointLight = vec3<f32>(0.0);
|
var pointLight = vec3<f32>(0.0);
|
||||||
var spotLight = vec3<f32>(0.0);
|
var spotLight = vec3<f32>(0.0);
|
||||||
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
|
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++) {
|
for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) {
|
||||||
let enabled = rectLights[i * 3u + 1u].z;
|
let enabled = rectLights[i * 3u + 1u].z;
|
||||||
count += select(0u, 1u, enabled != 0.0);
|
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++) {
|
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) {
|
||||||
let enabled = spotLights[i * 2u + 1u].w;
|
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++) {
|
for (var i = 0u; i < arrayLength(&ambientLights); i++) {
|
||||||
count += select(0u, 1u, ambientLights[i].a != 0.0);
|
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[0] = count;
|
||||||
clusters[1] = bitcast<u32>(light.r);
|
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[9] = bitcast<u32>(spotLight.b);
|
||||||
clusters[10] = pointCount;
|
clusters[10] = pointCount;
|
||||||
clusters[${compactSpotCount}] = spotCount;
|
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 */ `
|
const basicForwardShader = /* wgsl */ `
|
||||||
@@ -180,9 +189,7 @@ fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
|||||||
let base = materials[material * 3u];
|
let base = materials[material * 3u];
|
||||||
let properties = materials[material * 3u + 1u];
|
let properties = materials[material * 3u + 1u];
|
||||||
let clusterLight = vec3<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3]));
|
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 color = base.rgb * clusterLight * accent.color.rgb * mix(1.0, 1.1, properties.x);
|
||||||
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);
|
|
||||||
return vec4<f32>(color, base.a);
|
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(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
|
||||||
@group(0) @binding(8) var<storage, read> pointLights: 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(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;" : ""}
|
${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")}
|
${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;" : ""}
|
${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;
|
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 {
|
fn pow5(value: f32) -> f32 {
|
||||||
let squared = value * value;
|
let squared = value * value;
|
||||||
return squared * squared * 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 colorIntensity = pointLights[index * 2u];
|
||||||
let state = pointLights[index * 2u + 1u];
|
let state = pointLights[index * 2u + 1u];
|
||||||
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
||||||
let offset = positions[index].xyz - world;
|
let offset = positions[index].xyz - world;
|
||||||
let distance = length(offset);
|
let distance = length(offset);
|
||||||
let falloff = max(1.0 - distance / state.x, 0.0);
|
let falloff = max(1.0 - distance / state.x, 0.0);
|
||||||
let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0);
|
let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08;
|
||||||
return colorIntensity.rgb * colorIntensity.a * falloff * falloff * incidence * 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 colorIntensity = spotLights[index * 2u];
|
||||||
let state = spotLights[index * 2u + 1u];
|
let state = spotLights[index * 2u + 1u];
|
||||||
if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); }
|
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 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 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 falloff = max(1.0 - distance / state.x, 0.0);
|
||||||
let incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0);
|
let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08;
|
||||||
return colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * incidence * 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
|
@fragment
|
||||||
@@ -379,12 +402,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l
|
|||||||
${doubleSided ? "normal *= select(-1.0, 1.0, frontFacing);" : ""}
|
${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));` : ""}
|
${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 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 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 specularWeight = extension2.z * ${sample(11)}.a;
|
||||||
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
|
let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb;
|
||||||
var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"};
|
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));`
|
f0 = mix(f0, iridescenceTint * iridescenceLevel, iridescenceAmount * (1.0 - roughness * 0.5));`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
let fresnelPower = pow5(1.0 - vDotH);
|
let fresnelPower = pow5(1.0 - nDotV);
|
||||||
let fresnel = f0 + (vec3(1.0) - f0) * fresnelPower;
|
let ambient = vec3<f32>(bitcast<f32>(clusters[${compactAmbientStart}]), bitcast<f32>(clusters[${compactAmbientStart + 1}]), bitcast<f32>(clusters[${compactAmbientStart + 2}]));
|
||||||
let alpha = roughness * roughness;
|
var directLight = vec3<f32>(0.0);
|
||||||
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 pointCount = clusters[10];
|
let pointCount = clusters[10];
|
||||||
if (pointCount <= ${compactLightLimit}u) {
|
if (pointCount <= ${compactLightLimit}u) {
|
||||||
for (var light = 0u; light < pointCount; light++) {
|
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 {
|
} else {
|
||||||
for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) {
|
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}];
|
let spotCount = clusters[${compactSpotCount}];
|
||||||
if (spotCount <= ${compactLightLimit}u) {
|
if (spotCount <= ${compactLightLimit}u) {
|
||||||
for (var light = 0u; light < spotCount; light++) {
|
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 {
|
} else {
|
||||||
for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) {
|
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 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 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));" : ""}
|
${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""}
|
||||||
${
|
${
|
||||||
clearcoat
|
clearcoat
|
||||||
? `let clearcoatFresnel = 0.04 + 0.96 * fresnelPower;
|
? `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;" : ""}
|
${unlit ? "color = base.rgb + emissive;" : ""}
|
||||||
${
|
${
|
||||||
transmission
|
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));
|
let normalizedTransmissionTint = base.rgb / max(max(base.r, base.g), max(base.b, 0.001));
|
||||||
transmitted *= normalizedTransmissionTint;
|
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"});
|
return vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"});
|
||||||
@@ -509,21 +511,6 @@ const copyShader = `${fullscreenVertex}
|
|||||||
return textureSample(source, sourceSampler, input.uv);
|
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>) {
|
function effectFragment(kind: string, options: Record<string, unknown>) {
|
||||||
const amount = Number(options.amount ?? options.exposure ?? 1);
|
const amount = Number(options.amount ?? options.exposure ?? 1);
|
||||||
const safeAmount = Number.isFinite(amount) ? amount : 1;
|
const safeAmount = Number.isFinite(amount) ? amount : 1;
|
||||||
@@ -543,10 +530,7 @@ function effectFragment(kind: string, options: Record<string, unknown>) {
|
|||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentShader(
|
function presentShader(toneMap: string, composite: "scene" | undefined) {
|
||||||
toneMap: string,
|
|
||||||
composite: "studio" | "scene" | undefined,
|
|
||||||
) {
|
|
||||||
const tone =
|
const tone =
|
||||||
toneMap === "reinhard"
|
toneMap === "reinhard"
|
||||||
? "color / (color + vec3(1.0))"
|
? "color / (color + vec3(1.0))"
|
||||||
@@ -554,13 +538,12 @@ function presentShader(
|
|||||||
? "clamp(color, vec3(0.0), vec3(1.0))"
|
? "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))";
|
: "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}
|
return `${fullscreenVertex}
|
||||||
${composite === "studio" ? studioBackground : ""}
|
|
||||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||||
@group(0) @binding(1) var sourceSampler: sampler;
|
@group(0) @binding(1) var sourceSampler: sampler;
|
||||||
${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d<f32>;" : ""}
|
${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d<f32>;" : ""}
|
||||||
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
let value = textureSample(source, sourceSampler, input.uv);
|
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"});
|
return vec4(${tone}, ${composite ? "1.0" : "value.a"});
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
@@ -599,7 +582,9 @@ export class Scene {
|
|||||||
readonly core: YawnCore;
|
readonly core: YawnCore;
|
||||||
readonly ready: Promise<this>;
|
readonly ready: Promise<this>;
|
||||||
readonly hdr: boolean;
|
readonly hdr: boolean;
|
||||||
|
readonly clearColor: readonly [number, number, number, number];
|
||||||
#graphUpdates = Promise.resolve();
|
#graphUpdates = Promise.resolve();
|
||||||
|
#renderGraph?: object;
|
||||||
#computePasses = new Map<string, ComputePass>();
|
#computePasses = new Map<string, ComputePass>();
|
||||||
#meshes = new Map<number, MeshLike>();
|
#meshes = new Map<number, MeshLike>();
|
||||||
#shaders = new Map<number, ShaderLike>();
|
#shaders = new Map<number, ShaderLike>();
|
||||||
@@ -625,9 +610,17 @@ export class Scene {
|
|||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
hdr?: boolean;
|
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.hdr = options.hdr ?? true;
|
||||||
|
this.clearColor = clearColor as [number, number, number, number];
|
||||||
this.core = new YawnCore(canvas, {
|
this.core = new YawnCore(canvas, {
|
||||||
arenaBytes: options.arenaBytes,
|
arenaBytes: options.arenaBytes,
|
||||||
debug: options.debug,
|
debug: options.debug,
|
||||||
@@ -648,7 +641,7 @@ export class Scene {
|
|||||||
});
|
});
|
||||||
this.core.array("nodeRotors").write(0, [0, 0, 0, 1]);
|
this.core.array("nodeRotors").write(0, [0, 0, 0, 1]);
|
||||||
this.core.array("nodeScales").write(0, [1, 1, 1, 0]);
|
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");
|
const material = await this.core.allocateObject("materials");
|
||||||
this.core
|
this.core
|
||||||
.array("materials")
|
.array("materials")
|
||||||
@@ -1045,6 +1038,29 @@ export class Scene {
|
|||||||
return update;
|
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() {
|
async #compileRenderGraph() {
|
||||||
const buffers = new Map<string, GraphBuffer>();
|
const buffers = new Map<string, GraphBuffer>();
|
||||||
const textures = new Map<string, GraphTexture>();
|
const textures = new Map<string, GraphTexture>();
|
||||||
@@ -1127,11 +1143,6 @@ export class Scene {
|
|||||||
(material) => this.array("materialExtensions").row(material)[0] > 0,
|
(material) => this.array("materialExtensions").row(material)[0] > 0,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const compositeStudioBackground =
|
|
||||||
renderedMeshes.length > 0 &&
|
|
||||||
!hasTransmission &&
|
|
||||||
this.#effects.size === 0 &&
|
|
||||||
this.#shaders.size === 0;
|
|
||||||
const compositeSceneColor =
|
const compositeSceneColor =
|
||||||
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
|
hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0;
|
||||||
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
||||||
@@ -1190,7 +1201,7 @@ export class Scene {
|
|||||||
type: "render",
|
type: "render",
|
||||||
pipeline: "empty-forward",
|
pipeline: "empty-forward",
|
||||||
after: previous,
|
after: previous,
|
||||||
color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }],
|
color: [{ resource: "hdr", clear: [...this.clearColor] }],
|
||||||
draw: { vertices: 0 },
|
draw: { vertices: 0 },
|
||||||
});
|
});
|
||||||
previous = ["forward-empty"];
|
previous = ["forward-empty"];
|
||||||
@@ -1204,27 +1215,27 @@ export class Scene {
|
|||||||
["camera-matrices", "cameraMatrices"],
|
["camera-matrices", "cameraMatrices"],
|
||||||
])
|
])
|
||||||
addBuffer({ id, array, usage: ["storage"] });
|
addBuffer({ id, array, usage: ["storage"] });
|
||||||
if (!compositeStudioBackground) {
|
if (compositeSceneColor) {
|
||||||
renderPipelines.push({
|
renderPipelines.push({
|
||||||
id: "studio-background",
|
id: "scene-color-clear",
|
||||||
code: studioBackgroundShader,
|
code: clearShader,
|
||||||
vertex: { entry: "vertex" },
|
vertex: { entry: "vertex" },
|
||||||
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
|
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
|
||||||
});
|
});
|
||||||
passes.push({
|
passes.push({
|
||||||
id: "studio-background",
|
id: "scene-color-clear",
|
||||||
type: "render",
|
type: "render",
|
||||||
pipeline: "studio-background",
|
pipeline: "scene-color-clear",
|
||||||
after: previous,
|
after: previous,
|
||||||
color: [
|
color: [
|
||||||
{
|
{
|
||||||
resource: compositeSceneColor ? "scene-color" : "hdr",
|
resource: "scene-color",
|
||||||
clear: [0, 0, 0, 1],
|
clear: [...this.clearColor],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
draw: { vertices: 3 },
|
draw: { vertices: 0 },
|
||||||
});
|
});
|
||||||
previous = ["studio-background"];
|
previous = ["scene-color-clear"];
|
||||||
}
|
}
|
||||||
const forwardPipelines = new Map<string, string>();
|
const forwardPipelines = new Map<string, string>();
|
||||||
let firstColor = true;
|
let firstColor = true;
|
||||||
@@ -1363,7 +1374,9 @@ export class Scene {
|
|||||||
{ group: 0, binding: 0, resource: "hdr" },
|
{ group: 0, binding: 0, resource: "hdr" },
|
||||||
{ group: 0, binding: 1, resource: "linear" },
|
{ 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 },
|
draw: { vertices: 3 },
|
||||||
});
|
});
|
||||||
previous = ["copy-scene-color"];
|
previous = ["copy-scene-color"];
|
||||||
@@ -1543,6 +1556,11 @@ export class Scene {
|
|||||||
? [
|
? [
|
||||||
{ group: 0, binding: 8, resource: "point-lights" },
|
{ group: 0, binding: 8, resource: "point-lights" },
|
||||||
{ group: 0, binding: 9, resource: "spot-lights" },
|
{ group: 0, binding: 9, resource: "spot-lights" },
|
||||||
|
{
|
||||||
|
group: 0,
|
||||||
|
binding: 10,
|
||||||
|
resource: "directional-lights",
|
||||||
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(detailed && mask
|
...(detailed && mask
|
||||||
@@ -1577,10 +1595,11 @@ export class Scene {
|
|||||||
color: [
|
color: [
|
||||||
{
|
{
|
||||||
resource: forwardTarget,
|
resource: forwardTarget,
|
||||||
...((firstColor && compositeStudioBackground) ||
|
...(clearCompositeLayer
|
||||||
clearCompositeLayer
|
|
||||||
? { clear: [0, 0, 0, 0] }
|
? { clear: [0, 0, 0, 0] }
|
||||||
: { load: "load" }),
|
: firstColor
|
||||||
|
? { clear: [...this.clearColor] }
|
||||||
|
: { load: "load" }),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
depth: {
|
depth: {
|
||||||
@@ -1689,14 +1708,7 @@ export class Scene {
|
|||||||
);
|
);
|
||||||
renderPipelines.push({
|
renderPipelines.push({
|
||||||
id: "present",
|
id: "present",
|
||||||
code: presentShader(
|
code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined),
|
||||||
toneMap,
|
|
||||||
compositeStudioBackground
|
|
||||||
? "studio"
|
|
||||||
: compositeSceneColor
|
|
||||||
? "scene"
|
|
||||||
: undefined,
|
|
||||||
),
|
|
||||||
vertex: { entry: "vertex" },
|
vertex: { entry: "vertex" },
|
||||||
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||||
});
|
});
|
||||||
@@ -1712,7 +1724,7 @@ export class Scene {
|
|||||||
? [{ group: 0, binding: 2, resource: "scene-color" }]
|
? [{ group: 0, binding: 2, resource: "scene-color" }]
|
||||||
: []),
|
: []),
|
||||||
],
|
],
|
||||||
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }],
|
color: [{ resource: "canvas", clear: [...this.clearColor] }],
|
||||||
draw: { vertices: 3 },
|
draw: { vertices: 3 },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1729,6 +1741,7 @@ export class Scene {
|
|||||||
pipelines: { render: renderPipelines, compute: computePipelines },
|
pipelines: { render: renderPipelines, compute: computePipelines },
|
||||||
passes,
|
passes,
|
||||||
};
|
};
|
||||||
|
this.#renderGraph = structuredClone(graph);
|
||||||
const id = await this.core.compileGraph(serialize(graph));
|
const id = await this.core.compileGraph(serialize(graph));
|
||||||
await this.core.switchLoadout(id);
|
await this.core.switchLoadout(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,62 @@
|
|||||||
import { Node, type NodeOptions } from "../Node";
|
import { Node, type NodeOptions } from "../Node";
|
||||||
import type { Scene } from "../Scene";
|
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 {
|
export class PointLight extends Node {
|
||||||
constructor(scene: Scene, options: PointLightOptions = {}) {
|
constructor(scene: Scene, options: PointLightOptions = {}) {
|
||||||
super(scene, options);
|
super(scene, options);
|
||||||
const nodeReady = this.ready;
|
const nodeReady = this.ready;
|
||||||
this.ready = nodeReady.then(async () => {
|
this.ready = nodeReady.then(async () => {
|
||||||
const array = await scene.ensureRows("pointLights", this.id + 1, 32, "f32");
|
const array = await scene.ensureRows(
|
||||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, 1, 0, 0]);
|
"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;
|
return this;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get color() { return this.scene.array("pointLights").row(this.id).subarray(0, 3); }
|
get color() {
|
||||||
set color(value: ArrayLike<number>) { this.scene.array("pointLights").row(this.id).set(value, 0); }
|
return this.scene.array("pointLights").row(this.id).subarray(0, 3);
|
||||||
get intensity() { return this.scene.array("pointLights").row(this.id)[3]; }
|
}
|
||||||
set intensity(value: number) { this.scene.array("pointLights").row(this.id)[3] = value; }
|
set color(value: ArrayLike<number>) {
|
||||||
get range() { return this.scene.array("pointLights").row(this.id)[4]; }
|
this.scene.array("pointLights").row(this.id).set(value, 0);
|
||||||
set range(value: number) { this.scene.array("pointLights").row(this.id)[4] = value; }
|
}
|
||||||
|
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() {
|
override async dispose() {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
|
|||||||
@@ -193,15 +193,18 @@ test("Node, ArcRotateCamera, and light handles update their shared rows", async
|
|||||||
color: [0.2, 0.4, 0.6],
|
color: [0.2, 0.4, 0.6],
|
||||||
intensity: 7,
|
intensity: 7,
|
||||||
range: 8,
|
range: 8,
|
||||||
|
radius: 2,
|
||||||
});
|
});
|
||||||
await light.ready;
|
await light.ready;
|
||||||
close(
|
close(
|
||||||
scene.array("pointLights").row(light.id),
|
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.intensity = 9;
|
||||||
light.range = 10;
|
light.range = 10;
|
||||||
|
light.radius = 3;
|
||||||
close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]);
|
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 () => {
|
test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => {
|
||||||
|
|||||||
@@ -20,16 +20,18 @@ if (typeof globalThis.ImageBitmap === "undefined")
|
|||||||
value: class ImageBitmap {},
|
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 scene = await createScene();
|
||||||
const emptyPass = latestGraph(scene).passes.find(
|
const emptyPass = latestGraph(scene).passes.find(
|
||||||
(pass: any) => pass.id === "forward-empty",
|
(pass: any) => pass.id === "forward-empty",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(emptyPass.draw.vertices, 0);
|
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 scene = await createScene();
|
||||||
const material = new PBRMaterial(scene);
|
const material = new PBRMaterial(scene);
|
||||||
await material.ready;
|
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(pipeline.primitive.frontFace, "ccw");
|
||||||
assert.equal(
|
assert.equal(
|
||||||
graph.passes.some((pass: any) => pass.id === "studio-background"),
|
graph.passes.some((pass: any) => pass.id.includes("background")),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
assert.match(present.code, /fn studioBackground\(/u);
|
assert.doesNotMatch(JSON.stringify(graph), /studio/iu);
|
||||||
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 0]);
|
assert.deepEqual(forward.color[0].clear, [0, 0, 0, 1]);
|
||||||
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
|
assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u);
|
||||||
assert.match(pipeline.code, /materials\[input\.material \* 3u\]/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\[11u \+ pointCount\] = i/u);
|
||||||
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
|
assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u);
|
||||||
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
|
assert.match(pipeline.code, /let pointCount = clusters\[10\]/u);
|
||||||
assert.match(pipeline.code, /pointCount <= 500u/u);
|
assert.match(pipeline.code, /pointCount <= 500u/u);
|
||||||
assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/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, /let spotCount = clusters\[511\]/u);
|
||||||
assert.match(pipeline.code, /spotCount <= 500u/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");
|
const presentPass = graph.passes.find((pass: any) => pass.id === "present");
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
graph.passes.some((pass: any) => pass.id === "studio-background"),
|
graph.passes.some((pass: any) => pass.id === "scene-color-clear"),
|
||||||
true,
|
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(
|
assert.equal(
|
||||||
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
graph.passes.some((pass: any) => pass.id === "copy-scene-color"),
|
||||||
false,
|
false,
|
||||||
@@ -124,6 +147,27 @@ test("transmission renders directly into a composited layer with five taps", asy
|
|||||||
assert.match(pipeline.code, /transmitted \*= pow/u);
|
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 () => {
|
test("post-processing keeps the conservative transmission copy", async () => {
|
||||||
const scene = await createScene();
|
const scene = await createScene();
|
||||||
const material = new PBRMaterial(scene, { transmission: 1 });
|
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(testCore(scene).graphs.length - before, 1);
|
||||||
assert.equal(forwardPasses(latestGraph(scene)).length, 2);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ export const triangle = {
|
|||||||
indices: [0, 1, 2],
|
indices: [0, 1, 2],
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createScene() {
|
export async function createScene(
|
||||||
const scene = new Scene({} as HTMLCanvasElement);
|
options: ConstructorParameters<typeof Scene>[1] = {},
|
||||||
|
) {
|
||||||
|
const scene = new Scene({} as HTMLCanvasElement, options);
|
||||||
await scene.ready;
|
await scene.ready;
|
||||||
return scene;
|
return scene;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const point = new PointLight(scene, {
|
|||||||
color: [1, 0.25, 0.05],
|
color: [1, 0.25, 0.05],
|
||||||
intensity: 20,
|
intensity: 20,
|
||||||
range: 8,
|
range: 8,
|
||||||
|
radius: 0.5, // Softens the specular footprint like a small spherical emitter.
|
||||||
});
|
});
|
||||||
|
|
||||||
const sun = new DirectionalLight(scene, {
|
const sun = new DirectionalLight(scene, {
|
||||||
@@ -16,7 +17,10 @@ const sun = new DirectionalLight(scene, {
|
|||||||
intensity: 3,
|
intensity: 3,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fill = new AmbientLight(scene, { color: [0.1, 0.2, 0.4], intensity: 0.2 });
|
const fill = new AmbientLight(scene, {
|
||||||
|
color: [0.1, 0.2, 0.4],
|
||||||
|
intensity: 0.2,
|
||||||
|
});
|
||||||
await Promise.all([point.ready, sun.ready, fill.ready]);
|
await Promise.all([point.ready, sun.ready, fill.ready]);
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Generated
+1430
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -12,8 +12,9 @@
|
|||||||
"server": "go run ./server",
|
"server": "go run ./server",
|
||||||
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
"coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}",
|
||||||
"build:coredocs": "vitepress build coredocs",
|
"build:coredocs": "vitepress build coredocs",
|
||||||
"test": "npm run test:handles",
|
"test": "npm run test:handles && npm run test:fxnode-rg",
|
||||||
"test:handles": "npm test --workspace @yawn/handles",
|
"test:handles": "npm test --workspace @yawn/handles",
|
||||||
|
"test:fxnode-rg": "npm test --workspace @yawn/fxnode-rg",
|
||||||
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
|
"test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+16
-5
@@ -163,11 +163,16 @@ func (app *application) savePlayground(response http.ResponseWriter, request *ht
|
|||||||
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
http.Error(response, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
request.Body = http.MaxBytesReader(response, request.Body, maximumCodeBytes+4096)
|
request.Body = http.MaxBytesReader(
|
||||||
|
response,
|
||||||
|
request.Body,
|
||||||
|
maximumCodeBytes+maximumLoadoutsBytes+4096,
|
||||||
|
)
|
||||||
var input struct {
|
var input struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
Loadouts json.RawMessage `json:"loadouts"`
|
||||||
}
|
}
|
||||||
decoder := json.NewDecoder(request.Body)
|
decoder := json.NewDecoder(request.Body)
|
||||||
decoder.DisallowUnknownFields()
|
decoder.DisallowUnknownFields()
|
||||||
@@ -175,7 +180,13 @@ func (app *application) savePlayground(response http.ResponseWriter, request *ht
|
|||||||
writeJSONError(response, "The save request is not valid.", http.StatusBadRequest)
|
writeJSONError(response, "The save request is not valid.", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
result, err := app.store.save(request.Context(), input.ID, strings.TrimSpace(input.Title), input.Code)
|
result, err := app.store.save(
|
||||||
|
request.Context(),
|
||||||
|
input.ID,
|
||||||
|
strings.TrimSpace(input.Title),
|
||||||
|
input.Code,
|
||||||
|
string(input.Loadouts),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSONError(response, err.Error(), http.StatusBadRequest)
|
writeJSONError(response, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
+54
-1
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -66,12 +68,52 @@ func TestHealth(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPlaygroundStoreMigratesLoadouts(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "legacy.db")
|
||||||
|
database, err := sql.Open("sqlite3", path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := database.Exec(`
|
||||||
|
CREATE TABLE playgrounds (
|
||||||
|
id TEXT NOT NULL,
|
||||||
|
revision INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (id, revision)
|
||||||
|
)
|
||||||
|
`); err != nil {
|
||||||
|
database.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := database.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
store, err := openPlaygroundStore(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { store.Close() })
|
||||||
|
saved, err := store.save(context.Background(), "", "Migrated", "const migrated = true;", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(saved.Loadouts) != "[]" {
|
||||||
|
t.Fatalf("loadouts = %s", saved.Loadouts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
|
func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
|
||||||
app := testApplication(t)
|
app := testApplication(t)
|
||||||
handler := app.routes()
|
handler := app.routes()
|
||||||
requestBody, _ := json.Marshal(map[string]string{
|
requestBody, _ := json.Marshal(map[string]any{
|
||||||
"title": "First scene",
|
"title": "First scene",
|
||||||
"code": "const scene = true;",
|
"code": "const scene = true;",
|
||||||
|
"loadouts": []map[string]any{{
|
||||||
|
"id": "custom", "name": "Custom", "layout": map[string]any{},
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
request := httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody))
|
request := httptest.NewRequest(http.MethodPost, "/api/playgrounds", bytes.NewReader(requestBody))
|
||||||
response := httptest.NewRecorder()
|
response := httptest.NewRecorder()
|
||||||
@@ -118,6 +160,17 @@ func TestSaveAndLoadPlaygroundRevision(t *testing.T) {
|
|||||||
if loaded.Code != "const scene = true;" || loaded.Title != "First scene" {
|
if loaded.Code != "const scene = true;" || loaded.Title != "First scene" {
|
||||||
t.Fatalf("loaded = %#v", loaded)
|
t.Fatalf("loaded = %#v", loaded)
|
||||||
}
|
}
|
||||||
|
var loadouts []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Layout map[string]any `json:"layout"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(loaded.Loadouts, &loadouts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(loadouts) != 1 || loadouts[0].ID != "custom" || loadouts[0].Name != "Custom" || len(loadouts[0].Layout) != 0 {
|
||||||
|
t.Fatalf("loadouts = %s", loaded.Loadouts)
|
||||||
|
}
|
||||||
|
|
||||||
request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/2", nil)
|
request = httptest.NewRequest(http.MethodGet, "/api/playgrounds/"+saved.ID+"/2", nil)
|
||||||
response = httptest.NewRecorder()
|
response = httptest.NewRecorder()
|
||||||
|
|||||||
+48
-5
@@ -16,15 +16,39 @@ const browser = {
|
|||||||
target: "es2022",
|
target: "es2022",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fxNodeSource = path.join(root, "vendor", "fxnode", "src");
|
||||||
|
const fxNodeImports = {
|
||||||
|
name: "yawn-fxnode-imports",
|
||||||
|
setup(builder) {
|
||||||
|
builder.onResolve({ filter: /^fxnode$/ }, () => ({
|
||||||
|
path: path.join(fxNodeSource, "index.ts"),
|
||||||
|
}));
|
||||||
|
builder.onResolve({ filter: /\?worker&url$/ }, () => ({
|
||||||
|
path: "fxnode-worker-url",
|
||||||
|
namespace: "yawn-fxnode-worker",
|
||||||
|
}));
|
||||||
|
builder.onLoad({ filter: /.*/, namespace: "yawn-fxnode-worker" }, () => ({
|
||||||
|
contents: 'export default "./fxnode-worker.js";',
|
||||||
|
loader: "js",
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const workerFiles = new Map([
|
const workerFiles = new Map([
|
||||||
[path.join(root, "core", "index.js"), "./worker.js"],
|
[
|
||||||
|
path.join(root, "core", "index.js"),
|
||||||
|
{
|
||||||
|
url: "./worker.js",
|
||||||
|
source: await fs.readFile(path.join(root, "core", "worker.js"), "utf8"),
|
||||||
|
},
|
||||||
|
],
|
||||||
[
|
[
|
||||||
path.join(root, "addons", "handles", "src", "importers", "gltf.ts"),
|
path.join(root, "addons", "handles", "src", "importers", "gltf.ts"),
|
||||||
"./importer-worker.js",
|
{ url: "./importer-worker.js" },
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
path.join(root, "addons", "handles", "src", "bvh", "picking.ts"),
|
path.join(root, "addons", "handles", "src", "bvh", "picking.ts"),
|
||||||
"./bvh-worker.js",
|
{ url: "./bvh-worker.js" },
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -37,12 +61,20 @@ const workerURLs = {
|
|||||||
let contents = await fs.readFile(source, "utf8");
|
let contents = await fs.readFile(source, "utf8");
|
||||||
const transformed = contents.replace(
|
const transformed = contents.replace(
|
||||||
/new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/,
|
/new Worker\(new URL\("\.\/worker\.(?:js|ts)", import\.meta\.url\),/,
|
||||||
`createYawnWorker(new URL(${JSON.stringify(worker)}, import.meta.url),`,
|
`createYawnWorker(new URL(${JSON.stringify(worker.url)}, import.meta.url), ${worker.source ? JSON.stringify(worker.source) : "undefined"},`,
|
||||||
);
|
);
|
||||||
if (transformed === contents)
|
if (transformed === contents)
|
||||||
throw new Error(`worker constructor not found in ${source}`);
|
throw new Error(`worker constructor not found in ${source}`);
|
||||||
contents = `
|
contents = `
|
||||||
function createYawnWorker(url, options) {
|
function createYawnWorker(url, embeddedSource, options) {
|
||||||
|
if (embeddedSource) {
|
||||||
|
const moduleUrl = new URL("./pkg/yawn_core.js", url);
|
||||||
|
const source = embeddedSource.replace("./pkg/yawn_core.js", moduleUrl.href);
|
||||||
|
const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
||||||
|
const worker = new Worker(bootstrap, options);
|
||||||
|
URL.revokeObjectURL(bootstrap);
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
if (url.origin === globalThis.location.origin) return new Worker(url, options);
|
if (url.origin === globalThis.location.origin) return new Worker(url, options);
|
||||||
const source = "import " + JSON.stringify(url.href) + ";";
|
const source = "import " + JSON.stringify(url.href) + ";";
|
||||||
const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
|
||||||
@@ -70,6 +102,17 @@ await Promise.all([
|
|||||||
outfile: path.join(output, "handles.js"),
|
outfile: path.join(output, "handles.js"),
|
||||||
plugins: [workerURLs],
|
plugins: [workerURLs],
|
||||||
}),
|
}),
|
||||||
|
build({
|
||||||
|
...browser,
|
||||||
|
entryPoints: [path.join(root, "addons", "fxnode_rg", "src", "index.ts")],
|
||||||
|
outfile: path.join(output, "fxnode-rg.js"),
|
||||||
|
plugins: [fxNodeImports],
|
||||||
|
}),
|
||||||
|
build({
|
||||||
|
...browser,
|
||||||
|
entryPoints: [path.join(fxNodeSource, "worker", "fxnode.worker.ts")],
|
||||||
|
outfile: path.join(output, "fxnode-worker.js"),
|
||||||
|
}),
|
||||||
build({
|
build({
|
||||||
bundle: false,
|
bundle: false,
|
||||||
entryPoints: [path.join(root, "core", "worker.js")],
|
entryPoints: [path.join(root, "core", "worker.js")],
|
||||||
|
|||||||
+60
-13
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
@@ -17,18 +18,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maximumCodeBytes = 200 * 1024
|
maximumCodeBytes = 200 * 1024
|
||||||
maximumTitleRunes = 80
|
maximumLoadoutsBytes = 512 * 1024
|
||||||
|
maximumTitleRunes = 80
|
||||||
)
|
)
|
||||||
|
|
||||||
var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`)
|
var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`)
|
||||||
|
|
||||||
type playground struct {
|
type playground struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Revision int `json:"revision"`
|
Revision int `json:"revision"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
Loadouts json.RawMessage `json:"loadouts"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type playgroundStore struct {
|
type playgroundStore struct {
|
||||||
@@ -61,16 +64,45 @@ func (store *playgroundStore) initialize() error {
|
|||||||
revision INTEGER NOT NULL CHECK (revision > 0),
|
revision INTEGER NOT NULL CHECK (revision > 0),
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
code TEXT NOT NULL,
|
code TEXT NOT NULL,
|
||||||
|
loadouts TEXT NOT NULL DEFAULT '[]',
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
PRIMARY KEY (id, revision)
|
PRIMARY KEY (id, revision)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS playgrounds_created_at
|
CREATE INDEX IF NOT EXISTS playgrounds_created_at
|
||||||
ON playgrounds (created_at DESC);
|
ON playgrounds (created_at DESC);
|
||||||
`)
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows, err := store.Query(`PRAGMA table_info(playgrounds)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hasLoadouts := false
|
||||||
|
for rows.Next() {
|
||||||
|
var position, notNull, primaryKey int
|
||||||
|
var name, kind string
|
||||||
|
var defaultValue any
|
||||||
|
if err := rows.Scan(&position, &name, &kind, ¬Null, &defaultValue, &primaryKey); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hasLoadouts = hasLoadouts || name == "loadouts"
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !hasLoadouts {
|
||||||
|
_, err = store.Exec(`ALTER TABLE playgrounds ADD COLUMN loadouts TEXT NOT NULL DEFAULT '[]'`)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *playgroundStore) save(ctx context.Context, id, title, code string) (playground, error) {
|
func (store *playgroundStore) save(ctx context.Context, id, title, code, loadouts string) (playground, error) {
|
||||||
if id != "" && !playgroundIDPattern.MatchString(id) {
|
if id != "" && !playgroundIDPattern.MatchString(id) {
|
||||||
return playground{}, errors.New("invalid playground id")
|
return playground{}, errors.New("invalid playground id")
|
||||||
}
|
}
|
||||||
@@ -80,6 +112,16 @@ func (store *playgroundStore) save(ctx context.Context, id, title, code string)
|
|||||||
if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) {
|
if code == "" || len(code) > maximumCodeBytes || !utf8.ValidString(code) {
|
||||||
return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes)
|
return playground{}, fmt.Errorf("code must contain 1 to %d bytes of UTF-8", maximumCodeBytes)
|
||||||
}
|
}
|
||||||
|
if loadouts == "" {
|
||||||
|
loadouts = "[]"
|
||||||
|
}
|
||||||
|
if len(loadouts) > maximumLoadoutsBytes || !utf8.ValidString(loadouts) {
|
||||||
|
return playground{}, fmt.Errorf("loadouts must contain at most %d bytes of UTF-8", maximumLoadoutsBytes)
|
||||||
|
}
|
||||||
|
var loadoutList []json.RawMessage
|
||||||
|
if err := json.Unmarshal([]byte(loadouts), &loadoutList); err != nil || loadoutList == nil {
|
||||||
|
return playground{}, errors.New("loadouts must be a JSON array")
|
||||||
|
}
|
||||||
if id == "" {
|
if id == "" {
|
||||||
var err error
|
var err error
|
||||||
id, err = randomPlaygroundID()
|
id, err = randomPlaygroundID()
|
||||||
@@ -105,11 +147,12 @@ func (store *playgroundStore) save(ctx context.Context, id, title, code string)
|
|||||||
createdAt := time.Now().UTC().Truncate(time.Millisecond)
|
createdAt := time.Now().UTC().Truncate(time.Millisecond)
|
||||||
if _, err := transaction.ExecContext(
|
if _, err := transaction.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
`INSERT INTO playgrounds (id, revision, title, code, created_at) VALUES (?, ?, ?, ?, ?)`,
|
`INSERT INTO playgrounds (id, revision, title, code, loadouts, created_at) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
id,
|
id,
|
||||||
revision,
|
revision,
|
||||||
title,
|
title,
|
||||||
code,
|
code,
|
||||||
|
loadouts,
|
||||||
createdAt.Format(time.RFC3339Nano),
|
createdAt.Format(time.RFC3339Nano),
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return playground{}, err
|
return playground{}, err
|
||||||
@@ -117,7 +160,10 @@ func (store *playgroundStore) save(ctx context.Context, id, title, code string)
|
|||||||
if err := transaction.Commit(); err != nil {
|
if err := transaction.Commit(); err != nil {
|
||||||
return playground{}, err
|
return playground{}, err
|
||||||
}
|
}
|
||||||
return playground{ID: id, Revision: revision, Title: title, Code: code, CreatedAt: createdAt}, nil
|
return playground{
|
||||||
|
ID: id, Revision: revision, Title: title, Code: code,
|
||||||
|
Loadouts: json.RawMessage(loadouts), CreatedAt: createdAt,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) {
|
func (store *playgroundStore) get(ctx context.Context, id string, revision int) (playground, error) {
|
||||||
@@ -125,16 +171,17 @@ func (store *playgroundStore) get(ctx context.Context, id string, revision int)
|
|||||||
return playground{}, sql.ErrNoRows
|
return playground{}, sql.ErrNoRows
|
||||||
}
|
}
|
||||||
var result playground
|
var result playground
|
||||||
var createdAt string
|
var createdAt, loadouts string
|
||||||
err := store.QueryRowContext(
|
err := store.QueryRowContext(
|
||||||
ctx,
|
ctx,
|
||||||
`SELECT id, revision, title, code, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
|
`SELECT id, revision, title, code, loadouts, created_at FROM playgrounds WHERE id = ? AND revision = ?`,
|
||||||
id,
|
id,
|
||||||
revision,
|
revision,
|
||||||
).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &createdAt)
|
).Scan(&result.ID, &result.Revision, &result.Title, &result.Code, &loadouts, &createdAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return playground{}, err
|
return playground{}, err
|
||||||
}
|
}
|
||||||
|
result.Loadouts = json.RawMessage(loadouts)
|
||||||
result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
|
result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt)
|
||||||
return result, err
|
return result, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ func loadPackages(directory string) (map[string]memoryAsset, error) {
|
|||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"core.js",
|
"core.js",
|
||||||
"handles.js",
|
"handles.js",
|
||||||
|
"fxnode-rg.js",
|
||||||
|
"fxnode-worker.js",
|
||||||
"worker.js",
|
"worker.js",
|
||||||
"importer-worker.js",
|
"importer-worker.js",
|
||||||
"bvh-worker.js",
|
"bvh-worker.js",
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ func TestLoadPackages(t *testing.T) {
|
|||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"core.js",
|
"core.js",
|
||||||
"handles.js",
|
"handles.js",
|
||||||
|
"fxnode-rg.js",
|
||||||
|
"fxnode-worker.js",
|
||||||
"worker.js",
|
"worker.js",
|
||||||
"importer-worker.js",
|
"importer-worker.js",
|
||||||
"bvh-worker.js",
|
"bvh-worker.js",
|
||||||
@@ -29,7 +31,7 @@ func TestLoadPackages(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(packages) != 6 {
|
if len(packages) != 8 {
|
||||||
t.Fatalf("package count = %d", len(packages))
|
t.Fatalf("package count = %d", len(packages))
|
||||||
}
|
}
|
||||||
if content := string(packages["pkg/yawn_core_bg.wasm"].content); content != filepath.Join("pkg", "yawn_core_bg.wasm") {
|
if content := string(packages["pkg/yawn_core_bg.wasm"].content); content != filepath.Join("pkg", "yawn_core_bg.wasm") {
|
||||||
|
|||||||
+31
-10
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
AmbientLight,
|
AmbientLight,
|
||||||
ArcRotateCamera,
|
ArcRotateCamera,
|
||||||
|
PointLight,
|
||||||
Scene,
|
Scene,
|
||||||
importGltf,
|
importGltf,
|
||||||
} from "@yawn/handles";
|
} from "@yawn/handles";
|
||||||
@@ -223,24 +224,47 @@ async function openFile(file) {
|
|||||||
scene = new Scene(canvas, {
|
scene = new Scene(canvas, {
|
||||||
arenaBytes: 384 * 1024 * 1024,
|
arenaBytes: 384 * 1024 * 1024,
|
||||||
hdr: true,
|
hdr: true,
|
||||||
|
clearColor: [0.025, 0.035, 0.045, 1],
|
||||||
});
|
});
|
||||||
await scene.ready;
|
await scene.ready;
|
||||||
scene.array("sceneAccent").row(0).set([1, 1, 1, 1]);
|
|
||||||
|
|
||||||
const meshes = await importGltf(scene, url);
|
const meshes = await importGltf(scene, url);
|
||||||
if (!meshes.length)
|
if (!meshes.length)
|
||||||
throw new Error("The file does not contain any triangle meshes.");
|
throw new Error("The file does not contain any triangle meshes.");
|
||||||
|
|
||||||
const bounds = findBounds(scene, meshes);
|
const bounds = findBounds(scene, meshes);
|
||||||
const light = new AmbientLight(scene, {
|
const radius = Math.max(bounds.radius, 0.001);
|
||||||
color: [1, 0.94, 0.86],
|
const lights = [
|
||||||
intensity: 0.28,
|
new AmbientLight(scene, {
|
||||||
});
|
color: [0.82, 0.9, 1],
|
||||||
|
intensity: 0.22,
|
||||||
|
}),
|
||||||
|
new PointLight(scene, {
|
||||||
|
position: [
|
||||||
|
bounds.center[0] + radius * 2.4,
|
||||||
|
bounds.center[1] + radius * 3.1,
|
||||||
|
bounds.center[2] + radius * 2.2,
|
||||||
|
],
|
||||||
|
color: [1, 0.78, 0.56],
|
||||||
|
intensity: 300,
|
||||||
|
range: radius * 8,
|
||||||
|
}),
|
||||||
|
new PointLight(scene, {
|
||||||
|
position: [
|
||||||
|
bounds.center[0] - radius * 2.1,
|
||||||
|
bounds.center[1] + radius * 0.8,
|
||||||
|
bounds.center[2] - radius * 1.8,
|
||||||
|
],
|
||||||
|
color: [0.45, 0.72, 1],
|
||||||
|
intensity: 120,
|
||||||
|
range: radius * 7,
|
||||||
|
}),
|
||||||
|
];
|
||||||
const camera = new ArcRotateCamera(
|
const camera = new ArcRotateCamera(
|
||||||
scene,
|
scene,
|
||||||
cameraOptions(scene, meshes, canvas, bounds),
|
cameraOptions(scene, meshes, canvas, bounds),
|
||||||
);
|
);
|
||||||
await Promise.all([light.ready, camera.ready]);
|
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
|
||||||
|
|
||||||
elements.renderValue.textContent = "— ms";
|
elements.renderValue.textContent = "— ms";
|
||||||
elements.renderTime.hidden = false;
|
elements.renderTime.hidden = false;
|
||||||
@@ -251,10 +275,7 @@ async function openFile(file) {
|
|||||||
if (!profiling)
|
if (!profiling)
|
||||||
elements.renderTime.title =
|
elements.renderTime.title =
|
||||||
"GPU timestamp queries are not supported by this adapter.";
|
"GPU timestamp queries are not supported by this adapter.";
|
||||||
else {
|
else elements.renderTime.removeAttribute("title");
|
||||||
elements.renderTime.removeAttribute("title");
|
|
||||||
scene.array("sceneAccent").row(0)[3] = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
activeViewer = {
|
activeViewer = {
|
||||||
camera,
|
camera,
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ button { color: inherit; }
|
|||||||
.tool-group button { display: inline-flex; height: 29px; align-items: center; gap: 6px; padding: 0 9px; color: #586d80; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 10px; }
|
.tool-group button { display: inline-flex; height: 29px; align-items: center; gap: 6px; padding: 0 9px; color: #586d80; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 10px; }
|
||||||
.tool-group button:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; }
|
.tool-group button:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; }
|
||||||
.braces { color: #2498df; font: 700 10px/1 ui-monospace, monospace; }
|
.braces { color: #2498df; font: 700 10px/1 ui-monospace, monospace; }
|
||||||
|
.graph-tool-hint { display: inline-flex; height: 27px; align-items: center; gap: 5px; padding: 0 9px; color: #6b8193; font-size: 9px; }
|
||||||
|
.graph-tool-hint b { color: #258dcf; font-weight: 700; }
|
||||||
.tool-context { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: center; gap: 7px; color: #748698; font-size: 9px; }
|
.tool-context { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: center; gap: 7px; color: #748698; font-size: 9px; }
|
||||||
.language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; }
|
.language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; }
|
||||||
.context-divider { width: 1px; height: 12px; margin-inline: 4px; background: #cfdae3; }
|
.context-divider { width: 1px; height: 12px; margin-inline: 4px; background: #cfdae3; }
|
||||||
@@ -55,6 +57,10 @@ button { color: inherit; }
|
|||||||
.pane-tab { display: flex; min-width: 145px; align-items: center; gap: 7px; padding: 0 14px; color: #9db1c2; border: 0; border-right: 1px solid #22384b; background: #0d1d2d; font-size: 10px; }
|
.pane-tab { display: flex; min-width: 145px; align-items: center; gap: 7px; padding: 0 14px; color: #9db1c2; border: 0; border-right: 1px solid #22384b; background: #0d1d2d; font-size: 10px; }
|
||||||
.pane-tab.active { color: #d4e2ed; border-top: 2px solid #2ba5ea; background: #0b1927; }
|
.pane-tab.active { color: #d4e2ed; border-top: 2px solid #2ba5ea; background: #0b1927; }
|
||||||
.ts-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #fff; border-radius: 2px; background: #278dcd; font: 700 7px/1 ui-monospace, monospace; }
|
.ts-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #fff; border-radius: 2px; background: #278dcd; font: 700 7px/1 ui-monospace, monospace; }
|
||||||
|
.rg-tab-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #b8e6fb; border: 1px solid #277da9; border-radius: 3px; background: #16425c; font-size: 10px; }
|
||||||
|
#graph-tab-dirty { display: none; width: 6px; height: 6px; border-radius: 50%; background: #4fbceb; }
|
||||||
|
#graph-tab-dirty.visible { display: inline-block; }
|
||||||
|
.coding-panel { min-height: 0; }
|
||||||
.editor { position: relative; min-height: 0; overflow: hidden; background: #0b1927; }
|
.editor { position: relative; min-height: 0; overflow: hidden; background: #0b1927; }
|
||||||
.editor-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; }
|
.editor-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; }
|
||||||
.monaco-editor .margin { background-color: #0b1927 !important; }
|
.monaco-editor .margin { background-color: #0b1927 !important; }
|
||||||
@@ -64,6 +70,57 @@ button { color: inherit; }
|
|||||||
#editor-diagnostics i { width: 5px; height: 5px; border-radius: 50%; background: #4aa975; }
|
#editor-diagnostics i { width: 5px; height: 5px; border-radius: 50%; background: #4aa975; }
|
||||||
#editor-diagnostics.has-errors { color: #e8a4a4; }
|
#editor-diagnostics.has-errors { color: #e8a4a4; }
|
||||||
#editor-diagnostics.has-errors i { background: #e26f6f; }
|
#editor-diagnostics.has-errors i { background: #e26f6f; }
|
||||||
|
.render-graph-editor { display: grid; min-height: 0; grid-template-rows: 46px minmax(0, 1fr); overflow: hidden; background: #081522; }
|
||||||
|
.render-graph-toolbar { position: relative; z-index: 2; display: flex; min-width: 0; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid #1d3548; background: #0d2030; box-shadow: 0 5px 18px rgb(0 0 0 / 10%); }
|
||||||
|
.render-graph-toolbar label { position: relative; display: flex; min-width: 0; height: 31px; align-items: center; }
|
||||||
|
.render-graph-toolbar label > span { position: absolute; z-index: 1; top: 4px; left: 9px; color: #54758c; font: 700 6px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; pointer-events: none; }
|
||||||
|
.render-graph-toolbar select, .render-graph-toolbar input { height: 31px; padding: 11px 28px 3px 9px; color: #c2d7e5; border: 1px solid #29465c; border-radius: 5px; outline: 0; background: #10283a; font-size: 9px; }
|
||||||
|
.render-graph-toolbar select { width: 138px; cursor: pointer; }
|
||||||
|
.render-graph-toolbar input { width: 120px; padding-right: 8px; }
|
||||||
|
.render-graph-toolbar select:focus, .render-graph-toolbar input:focus { border-color: #318fc8; box-shadow: 0 0 0 2px rgb(49 155 215 / 12%); }
|
||||||
|
.render-graph-toolbar button { display: inline-flex; height: 31px; flex: 0 0 auto; align-items: center; gap: 5px; padding: 0 10px; color: #dff4ff; border: 1px solid #238ac8; border-radius: 5px; background: #187cb7; cursor: pointer; font-size: 8px; font-weight: 700; }
|
||||||
|
.render-graph-toolbar button:hover { background: #208dca; }
|
||||||
|
.render-graph-toolbar button.secondary { color: #a9c7d9; border-color: #31536a; background: #132d3f; }
|
||||||
|
.render-graph-toolbar button.secondary:hover { color: #e0f4ff; border-color: #39779c; background: #18394f; }
|
||||||
|
.render-graph-toolbar button:disabled { cursor: wait; opacity: .55; }
|
||||||
|
#loadout-status { display: inline-flex; min-width: 0; align-items: center; gap: 6px; margin-left: auto; overflow: hidden; color: #7590a3; font: 7px/1 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
#loadout-status i { width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; background: #44ad78; box-shadow: 0 0 0 3px rgb(68 173 120 / 10%); }
|
||||||
|
#loadout-status.busy i { background: #3aa9e5; animation: status-pulse 1s infinite; }
|
||||||
|
#loadout-status.failed { color: #df9696; }
|
||||||
|
#loadout-status.failed i { background: #df6c6c; }
|
||||||
|
.render-graph-canvas-wrap { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
|
||||||
|
#render-graph-canvas { display: block; width: 100%; height: 100%; outline: 0; touch-action: none; }
|
||||||
|
#render-graph-canvas:focus-visible { box-shadow: inset 0 0 0 1px #3ba8e4; }
|
||||||
|
.render-graph-empty-hint { position: absolute; right: 12px; bottom: 12px; display: grid; grid-template-columns: 24px auto; gap: 2px 8px; align-items: center; padding: 8px 10px; color: #718b9f; border: 1px solid #203e53; border-radius: 7px; background: rgb(9 27 42 / 86%); box-shadow: 0 8px 25px rgb(0 0 0 / 16%); pointer-events: none; backdrop-filter: blur(6px); }
|
||||||
|
.render-graph-empty-hint > span { display: grid; width: 24px; height: 24px; grid-row: 1 / 3; place-items: center; color: #50b9ed; border: 1px solid #295b77; border-radius: 5px; background: #0f3046; }
|
||||||
|
.render-graph-empty-hint strong { color: #9bb4c6; font-size: 8px; }
|
||||||
|
.render-graph-empty-hint small { color: #577085; font-size: 7px; }
|
||||||
|
|
||||||
|
.rg-node-menu { position: fixed; z-index: 250; width: 226px; padding: 7px; visibility: hidden; color: #bcd0de; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 40%); font-size: 9px; }
|
||||||
|
.rg-node-menu::before { position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 1px rgb(255 255 255 / 4%); content: ""; pointer-events: none; }
|
||||||
|
.rg-menu-search { display: flex; height: 31px; align-items: center; gap: 7px; margin-bottom: 6px; padding: 0 8px; color: #5d8098; border: 1px solid #28465b; border-radius: 5px; background: #091927; }
|
||||||
|
.rg-menu-search input { min-width: 0; flex: 1; color: #cce1ed; border: 0; outline: 0; background: transparent; font-size: 9px; }
|
||||||
|
.rg-menu-search input::placeholder { color: #557186; }
|
||||||
|
.rg-menu-groups { display: grid; gap: 2px; }
|
||||||
|
.rg-menu-group { position: relative; }
|
||||||
|
.rg-menu-group-trigger { display: grid; width: 100%; min-height: 34px; grid-template-columns: 24px 1fr auto; align-items: center; gap: 7px; padding: 0 7px; color: #a6bfce; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: default; }
|
||||||
|
.rg-menu-group-trigger:hover, .rg-menu-group.open > .rg-menu-group-trigger { color: #d9effb; border-color: #294b62; background: #153246; }
|
||||||
|
.rg-menu-group-icon { display: grid; width: 21px; height: 21px; place-items: center; color: #60c3f2; border: 1px solid #2a5a75; border-radius: 4px; background: #0e2a3e; font-size: 10px; }
|
||||||
|
.rg-menu-group-trigger small { color: #557287; font-size: 7px; }
|
||||||
|
.rg-menu-chevron { color: #56788f; }
|
||||||
|
.rg-menu-submenu { position: absolute; top: -7px; left: calc(100% + 7px); display: none; width: 252px; max-height: min(390px, calc(100vh - 20px)); padding: 7px; overflow-y: auto; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 38%); }
|
||||||
|
.rg-node-menu.open-left .rg-menu-submenu { right: calc(100% + 7px); left: auto; }
|
||||||
|
.rg-menu-group.open > .rg-menu-submenu { display: grid; gap: 2px; }
|
||||||
|
.rg-menu-group:nth-child(n + 4) .rg-menu-submenu { top: auto; bottom: -7px; }
|
||||||
|
.rg-menu-submenu::before { position: absolute; top: 0; right: 100%; width: 8px; height: 42px; content: ""; }
|
||||||
|
.rg-menu-submenu > strong { padding: 5px 7px 7px; color: #5f8198; font: 700 7px/1 ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; }
|
||||||
|
.rg-menu-option { display: grid; min-height: 42px; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 6px 8px; color: #a9c0cf; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; }
|
||||||
|
.rg-menu-option:hover, .rg-menu-option.active { color: #def3ff; border-color: #2b526a; background: #15364c; }
|
||||||
|
.rg-menu-option span { font-weight: 650; }
|
||||||
|
.rg-menu-option small { grid-column: 1 / 3; color: #607b8f; font-size: 7px; line-height: 1.3; }
|
||||||
|
.rg-menu-option b { color: #48b6eb; font-size: 11px; font-weight: 400; }
|
||||||
|
.rg-menu-results { display: grid; gap: 2px; max-height: min(390px, calc(100vh - 75px)); overflow-y: auto; }
|
||||||
|
.rg-menu-empty { padding: 17px 9px; color: #657e91; text-align: center; font-size: 8px; }
|
||||||
.resize-handle { position: relative; z-index: 3; cursor: col-resize; background: #172a3b; }
|
.resize-handle { position: relative; z-index: 3; cursor: col-resize; background: #172a3b; }
|
||||||
.resize-handle::after { position: absolute; top: 50%; left: 1px; width: 3px; height: 40px; transform: translateY(-50%); border-radius: 3px; background: #355067; content: ""; opacity: .65; }
|
.resize-handle::after { position: absolute; top: 50%; left: 1px; width: 3px; height: 40px; transform: translateY(-50%); border-radius: 3px; background: #355067; content: ""; opacity: .65; }
|
||||||
.resize-handle:hover, .resize-handle.dragging { background: #218ed2; }
|
.resize-handle:hover, .resize-handle.dragging { background: #218ed2; }
|
||||||
@@ -153,9 +210,13 @@ button { color: inherit; }
|
|||||||
.run-button kbd { display: none; }
|
.run-button kbd { display: none; }
|
||||||
.tool-context { display: none; }
|
.tool-context { display: none; }
|
||||||
.tool-group-right { margin-left: auto; }
|
.tool-group-right { margin-left: auto; }
|
||||||
.workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: minmax(400px, 55vh) minmax(450px, 1fr); }
|
.workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: max(560px, 55vh) minmax(500px, 1fr); }
|
||||||
.resize-handle { display: none; }
|
.resize-handle { display: none; }
|
||||||
.stage-pane { min-height: 500px; }
|
.stage-pane { min-height: 500px; }
|
||||||
|
.render-graph-toolbar { overflow-x: auto; }
|
||||||
|
.loadout-name-field { display: none !important; }
|
||||||
|
.render-graph-empty-hint { display: none; }
|
||||||
|
.rg-menu-submenu { position: fixed; top: auto; right: 8px; bottom: 8px; left: 8px; width: auto; max-height: 42vh; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
+515
-17
@@ -1,3 +1,12 @@
|
|||||||
|
import {
|
||||||
|
compileFxNodeRenderGraph,
|
||||||
|
createRenderGraphEditor,
|
||||||
|
renderGraphNodeGroups,
|
||||||
|
renderGraphPresets,
|
||||||
|
} from "@yawn/fxnode-rg";
|
||||||
|
|
||||||
|
const renderGraphComment = "/* Render graph loadout:";
|
||||||
|
|
||||||
const defaultSource = `import {
|
const defaultSource = `import {
|
||||||
AmbientLight,
|
AmbientLight,
|
||||||
ArcRotateCamera,
|
ArcRotateCamera,
|
||||||
@@ -11,7 +20,11 @@ const defaultSource = `import {
|
|||||||
SpotLight,
|
SpotLight,
|
||||||
} from "@yawn/handles";
|
} from "@yawn/handles";
|
||||||
|
|
||||||
const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
const scene = new Scene(canvas, {
|
||||||
|
hdr: true,
|
||||||
|
fps: 60,
|
||||||
|
clearColor: [0.018, 0.035, 0.055, 1],
|
||||||
|
});
|
||||||
await scene.ready;
|
await scene.ready;
|
||||||
await scene.reserve({ nodes: 6, materials: 2 });
|
await scene.reserve({ nodes: 6, materials: 2 });
|
||||||
|
|
||||||
@@ -62,15 +75,16 @@ const lights = [
|
|||||||
outerAngle: 0.72,
|
outerAngle: 0.72,
|
||||||
}),
|
}),
|
||||||
new PointLight(scene, {
|
new PointLight(scene, {
|
||||||
position: [2.2, 0.9, 1.8],
|
position: [3.4, 1.8, 3.6],
|
||||||
color: [1, 0.02, 0.45],
|
color: [0.9, 0.04, 0.38],
|
||||||
intensity: 16,
|
intensity: 45,
|
||||||
range: 6,
|
range: 14,
|
||||||
|
radius: 1.5,
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
await Promise.all(lights.map((light) => light.ready));
|
await Promise.all(lights.map((light) => light.ready));
|
||||||
|
|
||||||
log("Icosphere ready — teal ambience, orange rim, and magenta accent are active.");
|
log("Icosphere ready — teal ambience, orange rim, and soft magenta fill are active.");
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
scene,
|
scene,
|
||||||
@@ -121,19 +135,27 @@ await scene.batchGraphUpdates(async () => {
|
|||||||
const elements = Object.fromEntries(
|
const elements = Object.fromEntries(
|
||||||
[
|
[
|
||||||
"canvas",
|
"canvas",
|
||||||
|
"apply-loadout-button",
|
||||||
"clear-console",
|
"clear-console",
|
||||||
"console-count",
|
"console-count",
|
||||||
"console-empty",
|
"console-empty",
|
||||||
"console-lines",
|
"console-lines",
|
||||||
|
"copy-loadout-code-button",
|
||||||
"cursor-status",
|
"cursor-status",
|
||||||
"dirty-indicator",
|
"dirty-indicator",
|
||||||
"editor",
|
"editor",
|
||||||
"editor-diagnostics",
|
"editor-diagnostics",
|
||||||
|
"editor-mode-status",
|
||||||
|
"encoding-status",
|
||||||
"format-button",
|
"format-button",
|
||||||
"fps-status",
|
"fps-status",
|
||||||
"fullscreen-button",
|
"fullscreen-button",
|
||||||
|
"graph-tab-dirty",
|
||||||
"inspector",
|
"inspector",
|
||||||
"inspector-button",
|
"inspector-button",
|
||||||
|
"loadout-name",
|
||||||
|
"loadout-select",
|
||||||
|
"loadout-status",
|
||||||
"preview",
|
"preview",
|
||||||
"preview-message",
|
"preview-message",
|
||||||
"profile-adapter",
|
"profile-adapter",
|
||||||
@@ -143,6 +165,9 @@ const elements = Object.fromEntries(
|
|||||||
"profile-results",
|
"profile-results",
|
||||||
"profile-total",
|
"profile-total",
|
||||||
"project-title",
|
"project-title",
|
||||||
|
"render-graph-canvas",
|
||||||
|
"render-graph-editor",
|
||||||
|
"render-graph-hint",
|
||||||
"reset-button",
|
"reset-button",
|
||||||
"resize-handle",
|
"resize-handle",
|
||||||
"resolution-status",
|
"resolution-status",
|
||||||
@@ -156,6 +181,7 @@ const elements = Object.fromEntries(
|
|||||||
"tab-dirty",
|
"tab-dirty",
|
||||||
"toast",
|
"toast",
|
||||||
"type-status",
|
"type-status",
|
||||||
|
"workspace-language",
|
||||||
"workbench",
|
"workbench",
|
||||||
].map((id) => [id, document.getElementById(id)]),
|
].map((id) => [id, document.getElementById(id)]),
|
||||||
);
|
);
|
||||||
@@ -222,14 +248,29 @@ let currentID = "";
|
|||||||
let currentRevision = 0;
|
let currentRevision = 0;
|
||||||
let initialSource = defaultSource;
|
let initialSource = defaultSource;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
|
let lastRunSource = "";
|
||||||
let dirty = false;
|
let dirty = false;
|
||||||
let activePanel = "console";
|
let activePanel = "console";
|
||||||
|
let activeCodingTab = "scene";
|
||||||
let stopProfile;
|
let stopProfile;
|
||||||
let profileCore;
|
let profileCore;
|
||||||
let toastTimer;
|
let toastTimer;
|
||||||
let frameRequest;
|
let frameRequest;
|
||||||
let sampledFrame = 0;
|
let sampledFrame = 0;
|
||||||
let sampledAt = 0;
|
let sampledAt = 0;
|
||||||
|
let renderGraphEditor;
|
||||||
|
let renderGraphMenu;
|
||||||
|
let renderGraphMenuOutsideListener;
|
||||||
|
let graphDirty = false;
|
||||||
|
let loadoutsDirty = false;
|
||||||
|
let suppressGraphChanges = false;
|
||||||
|
let customLoadouts = [];
|
||||||
|
let currentLoadoutID = "beauty";
|
||||||
|
let compiledRenderGraph = "";
|
||||||
|
|
||||||
|
localStorage.removeItem("yawn:playground:loadouts");
|
||||||
|
localStorage.removeItem("yawn:playground:active-loadout");
|
||||||
|
localStorage.removeItem("yawn:playground:draft");
|
||||||
|
|
||||||
addEventListener("yawn-theme-change", (event) => {
|
addEventListener("yawn-theme-change", (event) => {
|
||||||
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
|
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
|
||||||
@@ -242,6 +283,29 @@ function savedLocation() {
|
|||||||
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
|
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validCustomLoadouts(value) {
|
||||||
|
if (!Array.isArray(value)) return [];
|
||||||
|
return value
|
||||||
|
.filter(
|
||||||
|
(loadout) =>
|
||||||
|
loadout &&
|
||||||
|
typeof loadout.id === "string" &&
|
||||||
|
/^custom-[a-z0-9-]+$/u.test(loadout.id) &&
|
||||||
|
typeof loadout.name === "string" &&
|
||||||
|
loadout.name.trim() &&
|
||||||
|
loadout.layout &&
|
||||||
|
Array.isArray(loadout.layout.nodes) &&
|
||||||
|
Array.isArray(loadout.layout.links),
|
||||||
|
)
|
||||||
|
.slice(0, 40)
|
||||||
|
.map((loadout) => ({
|
||||||
|
id: loadout.id,
|
||||||
|
name: loadout.name.trim().slice(0, 64),
|
||||||
|
builtIn: false,
|
||||||
|
layout: loadout.layout,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async function loadInitialPlayground() {
|
async function loadInitialPlayground() {
|
||||||
const saved = savedLocation();
|
const saved = savedLocation();
|
||||||
if (saved) {
|
if (saved) {
|
||||||
@@ -258,6 +322,7 @@ async function loadInitialPlayground() {
|
|||||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||||
elements["share-button"].hidden = false;
|
elements["share-button"].hidden = false;
|
||||||
initialSource = result.code;
|
initialSource = result.code;
|
||||||
|
customLoadouts = validCustomLoadouts(result.loadouts);
|
||||||
document.title = `${result.title} — Yawn Playground`;
|
document.title = `${result.title} — Yawn Playground`;
|
||||||
return result.code;
|
return result.code;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -266,7 +331,8 @@ async function loadInitialPlayground() {
|
|||||||
showToast(error.message);
|
showToast(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return localStorage.getItem("yawn:playground:draft") || defaultSource;
|
customLoadouts = [];
|
||||||
|
return defaultSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadMonaco() {
|
function loadMonaco() {
|
||||||
@@ -367,13 +433,12 @@ async function createEditor(source) {
|
|||||||
run,
|
run,
|
||||||
});
|
});
|
||||||
editor.onDidChangeCursorPosition(({ position }) => {
|
editor.onDidChangeCursorPosition(({ position }) => {
|
||||||
|
if (activeCodingTab !== "scene") return;
|
||||||
elements["cursor-status"].textContent =
|
elements["cursor-status"].textContent =
|
||||||
`Ln ${position.lineNumber}, Col ${position.column}`;
|
`Ln ${position.lineNumber}, Col ${position.column}`;
|
||||||
});
|
});
|
||||||
model.onDidChangeContent(() => {
|
model.onDidChangeContent(() => {
|
||||||
markDirty(true);
|
markDirty(true);
|
||||||
if (!currentID)
|
|
||||||
localStorage.setItem("yawn:playground:draft", model.getValue());
|
|
||||||
});
|
});
|
||||||
monaco.editor.onDidChangeMarkers(([resource]) => {
|
monaco.editor.onDidChangeMarkers(([resource]) => {
|
||||||
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
||||||
@@ -384,6 +449,7 @@ async function createEditor(source) {
|
|||||||
|
|
||||||
function updateDiagnostics() {
|
function updateDiagnostics() {
|
||||||
if (!monaco || !model) return;
|
if (!monaco || !model) return;
|
||||||
|
if (activeCodingTab !== "scene") return;
|
||||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||||
const errors = markers.filter(
|
const errors = markers.filter(
|
||||||
(marker) => marker.severity === monaco.MarkerSeverity.Error,
|
(marker) => marker.severity === monaco.MarkerSeverity.Error,
|
||||||
@@ -401,10 +467,403 @@ function updateDiagnostics() {
|
|||||||
: "TypeScript language service ready";
|
: "TypeScript language service ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
function markDirty(value) {
|
function markDirty(value, target = "code") {
|
||||||
dirty = value;
|
dirty = value;
|
||||||
elements["dirty-indicator"].classList.toggle("visible", value);
|
elements["dirty-indicator"].classList.toggle("visible", value);
|
||||||
elements["tab-dirty"].classList.toggle("visible", value);
|
if (!value) {
|
||||||
|
elements["tab-dirty"].classList.remove("visible");
|
||||||
|
elements["graph-tab-dirty"].classList.remove("visible");
|
||||||
|
graphDirty = false;
|
||||||
|
loadoutsDirty = false;
|
||||||
|
} else if (target === "graph") {
|
||||||
|
elements["graph-tab-dirty"].classList.add("visible");
|
||||||
|
} else {
|
||||||
|
elements["tab-dirty"].classList.add("visible");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function allLoadouts() {
|
||||||
|
return [...renderGraphPresets, ...customLoadouts];
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeLoadout() {
|
||||||
|
return allLoadouts().find((loadout) => loadout.id === currentLoadoutID);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadoutLayout(loadout = activeLoadout()) {
|
||||||
|
if (!loadout) throw new Error("The selected render graph loadout is missing");
|
||||||
|
return loadout.layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoadoutOptions() {
|
||||||
|
const builtIn = document.createElement("optgroup");
|
||||||
|
const saved = document.createElement("optgroup");
|
||||||
|
builtIn.label = "Prepared loadouts";
|
||||||
|
saved.label = "Playground loadouts";
|
||||||
|
for (const loadout of renderGraphPresets) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = loadout.id;
|
||||||
|
option.textContent = loadout.name;
|
||||||
|
builtIn.append(option);
|
||||||
|
}
|
||||||
|
for (const loadout of customLoadouts) {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = loadout.id;
|
||||||
|
option.textContent = loadout.name;
|
||||||
|
saved.append(option);
|
||||||
|
}
|
||||||
|
elements["loadout-select"].replaceChildren(
|
||||||
|
builtIn,
|
||||||
|
...(customLoadouts.length ? [saved] : []),
|
||||||
|
);
|
||||||
|
elements["loadout-select"].value = currentLoadoutID;
|
||||||
|
const currentLoadout = activeLoadout();
|
||||||
|
elements["loadout-name"].value = currentLoadout?.name ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoadoutStatus(message, state = "ready") {
|
||||||
|
elements["loadout-status"].className =
|
||||||
|
state === "busy" ? "busy" : state === "failed" ? "failed" : "";
|
||||||
|
elements["loadout-status"].title = message;
|
||||||
|
elements["loadout-status"].lastChild.textContent = ` ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markGraphChanged() {
|
||||||
|
if (suppressGraphChanges) return;
|
||||||
|
compiledRenderGraph = "";
|
||||||
|
graphDirty = true;
|
||||||
|
loadoutsDirty = true;
|
||||||
|
markDirty(true, "graph");
|
||||||
|
setLoadoutStatus("Unsaved graph changes");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageActiveLoadout(layout) {
|
||||||
|
const currentLoadout = activeLoadout();
|
||||||
|
if (!currentLoadout)
|
||||||
|
throw new Error("The selected render graph loadout is missing");
|
||||||
|
const name =
|
||||||
|
elements["loadout-name"].value.trim() ||
|
||||||
|
`${currentLoadout.name || "Render graph"} custom`;
|
||||||
|
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
|
||||||
|
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
|
const changed =
|
||||||
|
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
|
||||||
|
const staged = changed
|
||||||
|
? {
|
||||||
|
id,
|
||||||
|
name: name.slice(0, 64),
|
||||||
|
builtIn: false,
|
||||||
|
layout,
|
||||||
|
}
|
||||||
|
: currentLoadout;
|
||||||
|
if (changed) {
|
||||||
|
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
|
||||||
|
if (index < 0) customLoadouts.push(staged);
|
||||||
|
else customLoadouts[index] = staged;
|
||||||
|
loadoutsDirty = true;
|
||||||
|
}
|
||||||
|
currentLoadoutID = id;
|
||||||
|
graphDirty = false;
|
||||||
|
renderLoadoutOptions();
|
||||||
|
if (changed) markDirty(true, "graph");
|
||||||
|
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||||||
|
return staged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generatedRenderGraphBlock(name, serialized) {
|
||||||
|
const safeName = name.replace(/[\r\n]+/gu, " ").replaceAll("*/", "* /");
|
||||||
|
return `${renderGraphComment} ${safeName} */ { const renderGraph = ${JSON.stringify(serialized)}; const loadoutId = await scene.compileRenderGraph(renderGraph); await scene.setLoadout(loadoutId); }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withAppendedRenderGraph(source, name, serialized) {
|
||||||
|
const block = generatedRenderGraphBlock(name, serialized);
|
||||||
|
return `${source.trimEnd()}\n\n${block}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function compileLoadout(layout) {
|
||||||
|
const scene = current?.scene;
|
||||||
|
if (!scene?.getRenderGraph)
|
||||||
|
throw new Error("Run a Scene before compiling a render graph loadout");
|
||||||
|
return compileFxNodeRenderGraph(layout, await scene.getRenderGraph());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRenderGraphMenu() {
|
||||||
|
if (renderGraphMenuOutsideListener) {
|
||||||
|
document.removeEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
renderGraphMenuOutsideListener,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
renderGraphMenuOutsideListener = undefined;
|
||||||
|
}
|
||||||
|
renderGraphMenu?.remove();
|
||||||
|
renderGraphMenu = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGraphOption(item, request, groupTitle) {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "rg-menu-option";
|
||||||
|
button.dataset.typeId = item.typeId;
|
||||||
|
button.dataset.search =
|
||||||
|
`${item.title} ${item.description} ${item.keywords || ""} ${groupTitle}`.toLowerCase();
|
||||||
|
const title = document.createElement("span");
|
||||||
|
const add = document.createElement("b");
|
||||||
|
const description = document.createElement("small");
|
||||||
|
title.textContent = item.title;
|
||||||
|
add.textContent = "+";
|
||||||
|
description.textContent = item.description;
|
||||||
|
button.append(title, add, description);
|
||||||
|
button.addEventListener("click", async () => {
|
||||||
|
closeRenderGraphMenu();
|
||||||
|
try {
|
||||||
|
await request.add(item.typeId);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLoadoutStatus(message, "failed");
|
||||||
|
showToast(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRenderGraphMenu(request) {
|
||||||
|
closeRenderGraphMenu();
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "rg-node-menu";
|
||||||
|
menu.dataset.renderGraphMenu = "";
|
||||||
|
menu.setAttribute("role", "menu");
|
||||||
|
menu.setAttribute("aria-label", "Add render graph node");
|
||||||
|
const search = document.createElement("label");
|
||||||
|
search.className = "rg-menu-search";
|
||||||
|
search.innerHTML = '<span aria-hidden="true">⌕</span>';
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "search";
|
||||||
|
input.placeholder = "Search nodes";
|
||||||
|
input.setAttribute("aria-label", "Search render graph nodes");
|
||||||
|
search.append(input);
|
||||||
|
const groups = document.createElement("div");
|
||||||
|
groups.className = "rg-menu-groups";
|
||||||
|
const results = document.createElement("div");
|
||||||
|
results.className = "rg-menu-results";
|
||||||
|
results.hidden = true;
|
||||||
|
const empty = document.createElement("div");
|
||||||
|
empty.className = "rg-menu-empty";
|
||||||
|
empty.textContent = "No render graph nodes match.";
|
||||||
|
empty.hidden = true;
|
||||||
|
|
||||||
|
const openGroup = (target) => {
|
||||||
|
for (const group of groups.children)
|
||||||
|
group.classList.toggle("open", group === target);
|
||||||
|
};
|
||||||
|
for (const group of renderGraphNodeGroups) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.className = "rg-menu-group";
|
||||||
|
host.dataset.group = group.id;
|
||||||
|
const trigger = document.createElement("button");
|
||||||
|
trigger.type = "button";
|
||||||
|
trigger.className = "rg-menu-group-trigger";
|
||||||
|
trigger.setAttribute("aria-haspopup", "menu");
|
||||||
|
trigger.innerHTML = `<span class="rg-menu-group-icon">${group.icon}</span><span>${group.title}<br><small>${group.items.length} nodes</small></span><span class="rg-menu-chevron">›</span>`;
|
||||||
|
const submenu = document.createElement("div");
|
||||||
|
submenu.className = "rg-menu-submenu";
|
||||||
|
submenu.setAttribute("role", "menu");
|
||||||
|
const heading = document.createElement("strong");
|
||||||
|
heading.textContent = group.title;
|
||||||
|
submenu.append(
|
||||||
|
heading,
|
||||||
|
...group.items.map((item) =>
|
||||||
|
renderGraphOption(item, request, group.title),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
trigger.addEventListener("pointerenter", () => openGroup(host));
|
||||||
|
trigger.addEventListener("click", () => openGroup(host));
|
||||||
|
host.append(trigger, submenu);
|
||||||
|
groups.append(host);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
const query = input.value.trim().toLowerCase();
|
||||||
|
groups.hidden = Boolean(query);
|
||||||
|
results.hidden = !query;
|
||||||
|
results.replaceChildren();
|
||||||
|
if (query) {
|
||||||
|
const matches = renderGraphNodeGroups.flatMap((group) =>
|
||||||
|
group.items
|
||||||
|
.map((item) => renderGraphOption(item, request, group.title))
|
||||||
|
.filter((button) => button.dataset.search.includes(query)),
|
||||||
|
);
|
||||||
|
results.append(...matches);
|
||||||
|
empty.hidden = matches.length > 0;
|
||||||
|
} else {
|
||||||
|
empty.hidden = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
input.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
closeRenderGraphMenu();
|
||||||
|
elements["render-graph-canvas"].focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
menu.append(search, groups, results, empty);
|
||||||
|
document.body.append(menu);
|
||||||
|
const canvasBounds = elements["render-graph-canvas"].getBoundingClientRect();
|
||||||
|
const menuBounds = menu.getBoundingClientRect();
|
||||||
|
const margin = 8;
|
||||||
|
const x = canvasBounds.left + request.viewPosition.x;
|
||||||
|
const y = canvasBounds.top + request.viewPosition.y;
|
||||||
|
const needsLeftSubmenu = x + menuBounds.width + 270 > innerWidth - margin;
|
||||||
|
menu.classList.toggle("open-left", needsLeftSubmenu);
|
||||||
|
menu.style.left = `${Math.max(margin, Math.min(x, innerWidth - menuBounds.width - margin))}px`;
|
||||||
|
menu.style.top = `${Math.max(margin, Math.min(y, innerHeight - menuBounds.height - margin))}px`;
|
||||||
|
menu.style.visibility = "visible";
|
||||||
|
renderGraphMenu = menu;
|
||||||
|
input.focus();
|
||||||
|
renderGraphMenuOutsideListener = (event) => {
|
||||||
|
if (!menu.contains(event.target)) closeRenderGraphMenu();
|
||||||
|
};
|
||||||
|
document.addEventListener(
|
||||||
|
"pointerdown",
|
||||||
|
renderGraphMenuOutsideListener,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchLoadout(id) {
|
||||||
|
const next = allLoadouts().find((loadout) => loadout.id === id);
|
||||||
|
if (!next || !renderGraphEditor) return;
|
||||||
|
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
|
||||||
|
suppressGraphChanges = true;
|
||||||
|
try {
|
||||||
|
currentLoadoutID = next.id;
|
||||||
|
await renderGraphEditor.load(loadoutLayout(next));
|
||||||
|
graphDirty = false;
|
||||||
|
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||||||
|
renderLoadoutOptions();
|
||||||
|
setLoadoutStatus(`${next.name} selected · Apply to update scene.ts`);
|
||||||
|
window.__yawnRenderGraph.selected = next.id;
|
||||||
|
window.__yawnRenderGraph.layout = loadoutLayout(next);
|
||||||
|
} finally {
|
||||||
|
suppressGraphChanges = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyLoadout() {
|
||||||
|
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
|
||||||
|
elements["apply-loadout-button"].disabled = true;
|
||||||
|
try {
|
||||||
|
const layout = await renderGraphEditor.save();
|
||||||
|
const staged = stageActiveLoadout(layout);
|
||||||
|
if (model.getValue() !== lastRunSource && !(await run()))
|
||||||
|
throw new Error(
|
||||||
|
"The Scene must run successfully before applying a loadout",
|
||||||
|
);
|
||||||
|
setLoadoutStatus(`Compiling ${staged.name}…`, "busy");
|
||||||
|
compiledRenderGraph = await compileLoadout(layout);
|
||||||
|
const source = withAppendedRenderGraph(
|
||||||
|
model.getValue(),
|
||||||
|
staged.name,
|
||||||
|
compiledRenderGraph,
|
||||||
|
);
|
||||||
|
if (source !== model.getValue()) model.setValue(source);
|
||||||
|
window.__yawnRenderGraph.selected = staged.id;
|
||||||
|
window.__yawnRenderGraph.layout = structuredClone(layout);
|
||||||
|
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||||||
|
if (!(await run()))
|
||||||
|
throw new Error("The generated render graph loadout could not run");
|
||||||
|
setLoadoutStatus(`${staged.name} applied from scene.ts`);
|
||||||
|
showToast(`Appended ${staged.name} to scene.ts and reran the playground`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLoadoutStatus(message, "failed");
|
||||||
|
showToast(message);
|
||||||
|
} finally {
|
||||||
|
elements["apply-loadout-button"].disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLoadoutCode() {
|
||||||
|
const button = elements["copy-loadout-code-button"];
|
||||||
|
if (!renderGraphEditor || button.disabled) return;
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
const layout = await renderGraphEditor.save();
|
||||||
|
compiledRenderGraph = await compileLoadout(layout);
|
||||||
|
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
|
||||||
|
const code = generatedRenderGraphBlock(name, compiledRenderGraph);
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||||||
|
showToast(`${activeLoadout().name} loadout code copied`);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLoadoutStatus(message, "failed");
|
||||||
|
showToast(message);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeRenderGraphWorkspace() {
|
||||||
|
renderLoadoutOptions();
|
||||||
|
renderGraphEditor = await createRenderGraphEditor({
|
||||||
|
canvas: elements["render-graph-canvas"],
|
||||||
|
initialLayout: loadoutLayout(),
|
||||||
|
workerUrl: "/pkg/fxnode-worker.js",
|
||||||
|
onChange: markGraphChanged,
|
||||||
|
onAddNodeMenu: openRenderGraphMenu,
|
||||||
|
onError(error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLoadoutStatus(message, "failed");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
window.__yawnRenderGraph = {
|
||||||
|
editor: renderGraphEditor,
|
||||||
|
layout: loadoutLayout(),
|
||||||
|
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
|
||||||
|
selected: currentLoadoutID,
|
||||||
|
serialized: compiledRenderGraph,
|
||||||
|
switchLoadout,
|
||||||
|
};
|
||||||
|
setLoadoutStatus(`${activeLoadout().name} selected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateCodingTab(tab) {
|
||||||
|
activeCodingTab = tab;
|
||||||
|
closeRenderGraphMenu();
|
||||||
|
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||||||
|
const active = button.dataset.editorTab === tab;
|
||||||
|
button.classList.toggle("active", active);
|
||||||
|
button.setAttribute("aria-selected", String(active));
|
||||||
|
}
|
||||||
|
elements.editor.hidden = tab !== "scene";
|
||||||
|
elements["render-graph-editor"].hidden = tab !== "render-graph";
|
||||||
|
for (const target of document.querySelectorAll("[data-code-tool]"))
|
||||||
|
target.hidden = tab !== "scene";
|
||||||
|
for (const target of document.querySelectorAll("[data-graph-tool]"))
|
||||||
|
target.hidden = tab !== "render-graph";
|
||||||
|
if (tab === "render-graph") {
|
||||||
|
elements["workspace-language"].textContent = "Render graph";
|
||||||
|
elements["type-status"].textContent = "FxNode worker ready";
|
||||||
|
elements["cursor-status"].textContent = "Node canvas";
|
||||||
|
elements["editor-mode-status"].textContent = "FxNode";
|
||||||
|
elements["encoding-status"].textContent = "S-expression v1";
|
||||||
|
elements["editor-diagnostics"].lastChild.textContent =
|
||||||
|
" Graph valid on save";
|
||||||
|
elements["editor-diagnostics"].classList.remove("has-errors");
|
||||||
|
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
|
||||||
|
} else {
|
||||||
|
elements["workspace-language"].textContent = "TypeScript";
|
||||||
|
const position = editor?.getPosition();
|
||||||
|
elements["cursor-status"].textContent = position
|
||||||
|
? `Ln ${position.lineNumber}, Col ${position.column}`
|
||||||
|
: "Ln 1, Col 1";
|
||||||
|
elements["editor-mode-status"].textContent = "Spaces: 2";
|
||||||
|
elements["encoding-status"].textContent = "UTF-8";
|
||||||
|
updateDiagnostics();
|
||||||
|
editor?.layout();
|
||||||
|
editor?.focus();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function transpile() {
|
async function transpile() {
|
||||||
@@ -414,10 +873,11 @@ async function transpile() {
|
|||||||
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
|
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
|
||||||
if (emitted.emitSkipped || !output)
|
if (emitted.emitSkipped || !output)
|
||||||
throw new Error("TypeScript could not emit this scene.");
|
throw new Error("TypeScript could not emit this scene.");
|
||||||
return output.text
|
const code = output.text
|
||||||
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
|
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
|
||||||
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
|
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
|
||||||
.replace(/\bexport\s+default\s+/, "return ");
|
.replace(/\bexport\s+default\s+/, "__yawnPlaygroundDefaultExport = ");
|
||||||
|
return `let __yawnPlaygroundDefaultExport;\n${code}\nreturn __yawnPlaygroundDefaultExport;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disposeCurrent() {
|
async function disposeCurrent() {
|
||||||
@@ -459,7 +919,7 @@ function replaceCanvas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
if (!editor || elements["run-button"].disabled) return;
|
if (!editor || elements["run-button"].disabled) return false;
|
||||||
clearPlaygroundTimers();
|
clearPlaygroundTimers();
|
||||||
const runID = ++generation;
|
const runID = ++generation;
|
||||||
elements["run-button"].disabled = true;
|
elements["run-button"].disabled = true;
|
||||||
@@ -500,16 +960,18 @@ async function run() {
|
|||||||
);
|
);
|
||||||
if (runID !== generation) {
|
if (runID !== generation) {
|
||||||
if (result?.dispose) await result.dispose();
|
if (result?.dispose) await result.dispose();
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
current = result;
|
current = result;
|
||||||
window.__yawnPlayground = result;
|
window.__yawnPlayground = result;
|
||||||
|
lastRunSource = model.getValue();
|
||||||
elements["preview-message"].hidden = true;
|
elements["preview-message"].hidden = true;
|
||||||
setRunStatus(
|
setRunStatus(
|
||||||
`Running · ${Math.round(performance.now() - started)} ms`,
|
`Running · ${Math.round(performance.now() - started)} ms`,
|
||||||
"ready",
|
"ready",
|
||||||
);
|
);
|
||||||
if (activePanel === "profile") await attachProfiler();
|
if (activePanel === "profile") await attachProfiler();
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (runID === generation) {
|
if (runID === generation) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -518,6 +980,7 @@ async function run() {
|
|||||||
showPreviewMessage("Scene could not start", message);
|
showPreviewMessage("Scene could not start", message);
|
||||||
openInspector("console");
|
openInspector("console");
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
if (runID === generation) {
|
if (runID === generation) {
|
||||||
elements["run-button"].disabled = false;
|
elements["run-button"].disabled = false;
|
||||||
@@ -641,6 +1104,10 @@ async function save() {
|
|||||||
if (!editor || elements["save-button"].disabled) return;
|
if (!editor || elements["save-button"].disabled) return;
|
||||||
elements["save-button"].disabled = true;
|
elements["save-button"].disabled = true;
|
||||||
try {
|
try {
|
||||||
|
const hadLoadoutChanges = loadoutsDirty;
|
||||||
|
let stagedLoadout;
|
||||||
|
if (graphDirty && renderGraphEditor)
|
||||||
|
stagedLoadout = stageActiveLoadout(await renderGraphEditor.save());
|
||||||
const response = await fetch("/api/playgrounds", {
|
const response = await fetch("/api/playgrounds", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -648,6 +1115,7 @@ async function save() {
|
|||||||
id: currentID,
|
id: currentID,
|
||||||
title: elements["project-title"].value.trim() || "Untitled playground",
|
title: elements["project-title"].value.trim() || "Untitled playground",
|
||||||
code: model.getValue(),
|
code: model.getValue(),
|
||||||
|
loadouts: customLoadouts,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -656,11 +1124,15 @@ async function save() {
|
|||||||
currentID = result.id;
|
currentID = result.id;
|
||||||
currentRevision = result.revision;
|
currentRevision = result.revision;
|
||||||
initialSource = result.code;
|
initialSource = result.code;
|
||||||
|
customLoadouts = validCustomLoadouts(result.loadouts);
|
||||||
|
renderLoadoutOptions();
|
||||||
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
|
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
|
||||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||||
elements["share-button"].hidden = false;
|
elements["share-button"].hidden = false;
|
||||||
localStorage.removeItem("yawn:playground:draft");
|
|
||||||
markDirty(false);
|
markDirty(false);
|
||||||
|
if (stagedLoadout) setLoadoutStatus("Saved · Apply to update scene.ts");
|
||||||
|
else if (hadLoadoutChanges)
|
||||||
|
setLoadoutStatus("Applied code · saved with playground");
|
||||||
showToast(`Saved revision ${result.revision}`);
|
showToast(`Saved revision ${result.revision}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast(error instanceof Error ? error.message : String(error));
|
showToast(error instanceof Error ? error.message : String(error));
|
||||||
@@ -718,6 +1190,23 @@ function sampleFPS(now = performance.now()) {
|
|||||||
|
|
||||||
elements["run-button"].addEventListener("click", run);
|
elements["run-button"].addEventListener("click", run);
|
||||||
elements["save-button"].addEventListener("click", save);
|
elements["save-button"].addEventListener("click", save);
|
||||||
|
elements["apply-loadout-button"].addEventListener("click", applyLoadout);
|
||||||
|
elements["copy-loadout-code-button"].addEventListener("click", copyLoadoutCode);
|
||||||
|
elements["loadout-name"].addEventListener("input", markGraphChanged);
|
||||||
|
elements["loadout-select"].addEventListener(
|
||||||
|
"change",
|
||||||
|
(event) =>
|
||||||
|
void switchLoadout(event.target.value).catch((error) => {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLoadoutStatus(message, "failed");
|
||||||
|
showToast(message);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||||||
|
button.addEventListener("click", () =>
|
||||||
|
activateCodingTab(button.dataset.editorTab),
|
||||||
|
);
|
||||||
|
}
|
||||||
elements["share-button"].addEventListener("click", async () => {
|
elements["share-button"].addEventListener("click", async () => {
|
||||||
await navigator.clipboard.writeText(location.href);
|
await navigator.clipboard.writeText(location.href);
|
||||||
showToast("Playground URL copied");
|
showToast("Playground URL copied");
|
||||||
@@ -786,14 +1275,23 @@ elements["resize-handle"].addEventListener("pointerup", (event) => {
|
|||||||
elements["resize-handle"].classList.remove("dragging");
|
elements["resize-handle"].classList.remove("dragging");
|
||||||
});
|
});
|
||||||
addEventListener("popstate", () => location.reload());
|
addEventListener("popstate", () => location.reload());
|
||||||
addEventListener("beforeunload", () => {
|
addEventListener("beforeunload", (event) => {
|
||||||
|
if (!dirty) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = "";
|
||||||
|
});
|
||||||
|
addEventListener("pagehide", (event) => {
|
||||||
|
if (event.persisted) return;
|
||||||
cancelAnimationFrame(frameRequest);
|
cancelAnimationFrame(frameRequest);
|
||||||
|
closeRenderGraphMenu();
|
||||||
|
void renderGraphEditor?.destroy();
|
||||||
void disposeCurrent();
|
void disposeCurrent();
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = await loadInitialPlayground();
|
const source = await loadInitialPlayground();
|
||||||
await createEditor(source);
|
await createEditor(source);
|
||||||
|
await initializeRenderGraphWorkspace();
|
||||||
markDirty(false);
|
markDirty(false);
|
||||||
sampleFPS();
|
sampleFPS();
|
||||||
await run();
|
await run();
|
||||||
|
|||||||
Vendored
+27
-15
@@ -69,6 +69,7 @@ declare module "@yawn/handles" {
|
|||||||
|
|
||||||
export interface SceneOptions {
|
export interface SceneOptions {
|
||||||
arenaBytes?: number;
|
arenaBytes?: number;
|
||||||
|
clearColor?: ArrayLike<number>;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
fps?: number;
|
fps?: number;
|
||||||
hdr?: boolean;
|
hdr?: boolean;
|
||||||
@@ -78,6 +79,7 @@ declare module "@yawn/handles" {
|
|||||||
readonly core: YawnCore;
|
readonly core: YawnCore;
|
||||||
readonly ready: Promise<this>;
|
readonly ready: Promise<this>;
|
||||||
readonly hdr: boolean;
|
readonly hdr: boolean;
|
||||||
|
readonly clearColor: readonly [number, number, number, number];
|
||||||
array(name: string): SharedRows;
|
array(name: string): SharedRows;
|
||||||
ensureRows(
|
ensureRows(
|
||||||
name: string,
|
name: string,
|
||||||
@@ -88,6 +90,9 @@ declare module "@yawn/handles" {
|
|||||||
reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
|
reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
|
||||||
batchWrites<T>(operation: () => T): T;
|
batchWrites<T>(operation: () => T): T;
|
||||||
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
|
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
|
||||||
|
compileRenderGraph(serialized: string): Promise<string>;
|
||||||
|
setLoadout(id: string): Promise<void>;
|
||||||
|
getRenderGraph(): Promise<object>;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +145,7 @@ declare module "@yawn/handles" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type VertexKind =
|
export type VertexKind =
|
||||||
| "positions"
|
"positions" | "normals" | "tangents" | "uvs" | "colors" | "indices";
|
||||||
| "normals"
|
|
||||||
| "tangents"
|
|
||||||
| "uvs"
|
|
||||||
| "colors"
|
|
||||||
| "indices";
|
|
||||||
export interface MeshOptions extends NodeOptions {
|
export interface MeshOptions extends NodeOptions {
|
||||||
geometryId?: number;
|
geometryId?: number;
|
||||||
material?: PBRMaterial;
|
material?: PBRMaterial;
|
||||||
@@ -167,8 +167,10 @@ declare module "@yawn/handles" {
|
|||||||
): Promise<this>;
|
): Promise<this>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CubeOptions
|
export interface CubeOptions extends Omit<
|
||||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
MeshOptions,
|
||||||
|
"geometryId" | "vertexData"
|
||||||
|
> {
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
@@ -177,16 +179,20 @@ declare module "@yawn/handles" {
|
|||||||
export class Cube extends Mesh {
|
export class Cube extends Mesh {
|
||||||
constructor(scene: Scene, options?: CubeOptions);
|
constructor(scene: Scene, options?: CubeOptions);
|
||||||
}
|
}
|
||||||
export interface IcosphereOptions
|
export interface IcosphereOptions extends Omit<
|
||||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
MeshOptions,
|
||||||
|
"geometryId" | "vertexData"
|
||||||
|
> {
|
||||||
radius?: number;
|
radius?: number;
|
||||||
subdivisions?: number;
|
subdivisions?: number;
|
||||||
}
|
}
|
||||||
export class Icosphere extends Mesh {
|
export class Icosphere extends Mesh {
|
||||||
constructor(scene: Scene, options?: IcosphereOptions);
|
constructor(scene: Scene, options?: IcosphereOptions);
|
||||||
}
|
}
|
||||||
export interface PlaneOptions
|
export interface PlaneOptions extends Omit<
|
||||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
MeshOptions,
|
||||||
|
"geometryId" | "vertexData"
|
||||||
|
> {
|
||||||
size?: number;
|
size?: number;
|
||||||
width?: number;
|
width?: number;
|
||||||
depth?: number;
|
depth?: number;
|
||||||
@@ -194,8 +200,10 @@ declare module "@yawn/handles" {
|
|||||||
export class Plane extends Mesh {
|
export class Plane extends Mesh {
|
||||||
constructor(scene: Scene, options?: PlaneOptions);
|
constructor(scene: Scene, options?: PlaneOptions);
|
||||||
}
|
}
|
||||||
export interface SphereOptions
|
export interface SphereOptions extends Omit<
|
||||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
MeshOptions,
|
||||||
|
"geometryId" | "vertexData"
|
||||||
|
> {
|
||||||
radius?: number;
|
radius?: number;
|
||||||
segments?: number;
|
segments?: number;
|
||||||
rings?: number;
|
rings?: number;
|
||||||
@@ -267,9 +275,13 @@ declare module "@yawn/handles" {
|
|||||||
intensity?: number;
|
intensity?: number;
|
||||||
}
|
}
|
||||||
export class PointLight extends Node {
|
export class PointLight extends Node {
|
||||||
constructor(scene: Scene, options?: LightOptions & { range?: number });
|
constructor(
|
||||||
|
scene: Scene,
|
||||||
|
options?: LightOptions & { range?: number; radius?: number },
|
||||||
|
);
|
||||||
intensity: number;
|
intensity: number;
|
||||||
range: number;
|
range: number;
|
||||||
|
radius: number;
|
||||||
}
|
}
|
||||||
export class AmbientLight extends Node {
|
export class AmbientLight extends Node {
|
||||||
constructor(scene: Scene, options?: LightOptions);
|
constructor(scene: Scene, options?: LightOptions);
|
||||||
|
|||||||
+96
-11
@@ -16,7 +16,8 @@
|
|||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"@yawn/core": "/pkg/core.js",
|
"@yawn/core": "/pkg/core.js",
|
||||||
"@yawn/handles": "/pkg/handles.js"
|
"@yawn/handles": "/pkg/handles.js",
|
||||||
|
"@yawn/fxnode-rg": "/pkg/fxnode-rg.js"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -64,19 +65,22 @@
|
|||||||
|
|
||||||
<div class="playground-tools">
|
<div class="playground-tools">
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
<button id="snippets-button" type="button">
|
<button id="snippets-button" type="button" data-code-tool>
|
||||||
<span class="braces">{ }</span> Snippets
|
<span class="braces">{ }</span> Snippets
|
||||||
</button>
|
</button>
|
||||||
<button id="reset-button" type="button">
|
<button id="reset-button" type="button" data-code-tool>
|
||||||
<span aria-hidden="true">↶</span> Reset
|
<span aria-hidden="true">↶</span> Reset
|
||||||
</button>
|
</button>
|
||||||
<button id="format-button" type="button">
|
<button id="format-button" type="button" data-code-tool>
|
||||||
<span aria-hidden="true">≡</span> Format
|
<span aria-hidden="true">≡</span> Format
|
||||||
</button>
|
</button>
|
||||||
|
<span class="graph-tool-hint" data-graph-tool hidden
|
||||||
|
><b>Right click</b> the graph to add a node</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool-context">
|
<div class="tool-context">
|
||||||
<span class="language-dot"></span>
|
<span class="language-dot"></span>
|
||||||
<span>TypeScript</span>
|
<span id="workspace-language">TypeScript</span>
|
||||||
<span class="context-divider"></span>
|
<span class="context-divider"></span>
|
||||||
<span id="type-status">Starting language service…</span>
|
<span id="type-status">Starting language service…</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,19 +99,100 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="workbench" id="workbench">
|
<div class="workbench" id="workbench">
|
||||||
<section class="editor-pane" aria-label="TypeScript editor">
|
<section class="editor-pane" aria-label="Coding workspace">
|
||||||
<div class="pane-tabs">
|
<div class="pane-tabs" role="tablist" aria-label="Coding workspace">
|
||||||
<button class="pane-tab active" type="button">
|
<button
|
||||||
|
class="pane-tab active"
|
||||||
|
id="scene-tab"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="true"
|
||||||
|
aria-controls="editor"
|
||||||
|
data-editor-tab="scene"
|
||||||
|
>
|
||||||
<span class="ts-icon">TS</span> scene.ts <i id="tab-dirty"></i>
|
<span class="ts-icon">TS</span> scene.ts <i id="tab-dirty"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="pane-tab"
|
||||||
|
id="render-graph-tab"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="false"
|
||||||
|
aria-controls="render-graph-editor"
|
||||||
|
data-editor-tab="render-graph"
|
||||||
|
>
|
||||||
|
<span class="rg-tab-icon" aria-hidden="true">◇</span>
|
||||||
|
render graph
|
||||||
|
<i id="graph-tab-dirty"></i>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="editor" class="editor">
|
<div
|
||||||
|
id="editor"
|
||||||
|
class="editor coding-panel active"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="scene-tab"
|
||||||
|
>
|
||||||
<div class="editor-loading">Loading TypeScript editor…</div>
|
<div class="editor-loading">Loading TypeScript editor…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section
|
||||||
|
id="render-graph-editor"
|
||||||
|
class="render-graph-editor coding-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="render-graph-tab"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<div class="render-graph-toolbar">
|
||||||
|
<label>
|
||||||
|
<span>Loadout</span>
|
||||||
|
<select
|
||||||
|
id="loadout-select"
|
||||||
|
aria-label="Render graph loadout"
|
||||||
|
></select>
|
||||||
|
</label>
|
||||||
|
<label class="loadout-name-field">
|
||||||
|
<span>Name</span>
|
||||||
|
<input
|
||||||
|
id="loadout-name"
|
||||||
|
maxlength="64"
|
||||||
|
placeholder="My loadout"
|
||||||
|
aria-label="Loadout name"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
id="apply-loadout-button"
|
||||||
|
type="button"
|
||||||
|
title="Apply in memory; use the playground Save button to keep it"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">◇</span> Apply
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="copy-loadout-code-button"
|
||||||
|
class="secondary"
|
||||||
|
type="button"
|
||||||
|
title="Copy this loadout's compiled S-expression as Scene code"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">⧉</span> Copy code
|
||||||
|
</button>
|
||||||
|
<output id="loadout-status" aria-live="polite"
|
||||||
|
><i></i> Standard active</output
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="render-graph-canvas-wrap">
|
||||||
|
<canvas
|
||||||
|
id="render-graph-canvas"
|
||||||
|
aria-label="Render graph node editor"
|
||||||
|
></canvas>
|
||||||
|
<div class="render-graph-empty-hint" id="render-graph-hint">
|
||||||
|
<span>+</span>
|
||||||
|
<strong>Add another pass</strong>
|
||||||
|
<small>Right click anywhere to browse the node catalog</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<footer class="editor-status">
|
<footer class="editor-status">
|
||||||
<span id="cursor-status">Ln 1, Col 1</span>
|
<span id="cursor-status">Ln 1, Col 1</span>
|
||||||
<span>Spaces: 2</span>
|
<span id="editor-mode-status">Spaces: 2</span>
|
||||||
<span>UTF-8</span>
|
<span id="encoding-status">UTF-8</span>
|
||||||
<span class="status-spacer"></span>
|
<span class="status-spacer"></span>
|
||||||
<span id="editor-diagnostics"><i></i> No problems</span>
|
<span id="editor-diagnostics"><i></i> No problems</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
Reference in New Issue
Block a user