diff --git a/.amp/services.yaml b/.amp/services.yaml index 7aba382..83e7c30 100644 --- a/.amp/services.yaml +++ b/.amp/services.yaml @@ -4,3 +4,4 @@ services: portal: title: Yawn description: Yawn website, tutorial, package server, and WebGPU playground. + review: false diff --git a/addons/fxnode_rg/package.json b/addons/fxnode_rg/package.json new file mode 100644 index 0000000..5d1c68e --- /dev/null +++ b/addons/fxnode_rg/package.json @@ -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" + } +} diff --git a/addons/fxnode_rg/src/catalog.ts b/addons/fxnode_rg/src/catalog.ts new file mode 100644 index 0000000..804f9a4 --- /dev/null +++ b/addons/fxnode_rg/src/catalog.ts @@ -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>; + +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 = (value: T): T => structuredClone(value); + +function layoutNode( + id: string, + typeId: keyof typeof nodes, + x: number, + y: number, + overrides: Readonly> = {}, +): 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>; + }[], +): RenderGraphPreset { + const materialized = stages.map((stage) => + layoutNode(stage.id, stage.typeId, 0, 0, stage.parameters), + ); + const positions = new Map(); + 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>, + }, + }; +} + +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" }, + ]), +]; diff --git a/addons/fxnode_rg/src/compiler.ts b/addons/fxnode_rg/src/compiler.ts new file mode 100644 index 0000000..d16b7b3 --- /dev/null +++ b/addons/fxnode_rg/src/compiler.ts @@ -0,0 +1,566 @@ +import type { + GraphLayoutV2, + GraphNode, + GraphSnapshot, + ParameterValue, +} from "fxnode"; + +type PlainObject = Record; +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(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(); + 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(); + 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(node, "color", [0.36, 0.68, 0.9, 1]); + const fragment = + mode === "normals" + ? "return vec4(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(vec3(depth), 1.0);` + : mode === "unlit" + ? `let base = vec3(${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(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4(base * light, 1.0);` + : "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3(0.0, 2.1, 4.2) + seed * 6.28318); return vec4(color, 1.0);"; + return ` +struct VertexOutput { + @invariant @builtin(position) position: vec4, + @location(0) normal: vec3, + @location(1) @interpolate(flat) objectId: u32, +} +@group(0) @binding(0) var positions: array>; +@group(0) @binding(1) var rotors: array>; +@group(0) @binding(2) var scales: array>; +@group(0) @binding(3) var meshInfo: array; +@group(0) @binding(4) var cameraMatrices: array>; +fn rotate(rotor: vec4, point: vec3) -> vec3 { + return point + 2.0 * cross(rotor.xyz, cross(rotor.xyz, point) + rotor.w * point); +} +@vertex fn vertex( + @location(0) point: vec3, + ${detailed ? "@location(1) localNormal: vec3," : ""} + @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(transformed, 1.0); + var output: VertexOutput; + output.position = vec4( + 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(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(0.0, 0.0, 1.0)"})); + output.objectId = instance; + return output; +} +@fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { + ${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(); + 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(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(${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(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(0.0)); + color = pow(color, vec3(1.0) / gammaBalance) * gainBalance;`; +} + +const fullScreenVertex = ` +struct VertexOutput { @builtin(position) position: vec4, @location(0) uv: vec2 } +@vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput { + let points = array, 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(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(textureDimensions(source)); + let step = 2.0 / size; + let center = textureSample(source, sourceSampler, input.uv); + var glow = vec4(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(${threshold.toFixed(5)}), vec3(0.0)); + return vec4(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(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(0.2126, 0.7152, 0.0722)); + let cell = fract(input.uv * vec2(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(0.008, 0.045, 0.11), vec3(0.025, 0.24, 0.46), clamp(value, 0.0, 1.0)); + paper += grid * vec3(0.012, 0.045, 0.075); + let line = clamp(smoothstep(0.035, 0.24, edge) * ${strength.toFixed(5)}, 0.0, 1.0); + return vec4(mix(paper, vec3(0.68, 0.94, 1.0), line), 1.0);`; + return ` + let size = vec2(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(mix(center.rgb, vec3(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(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(${pivot.toFixed(5)})) * ${contrast.toFixed(5)} + vec3(${pivot.toFixed(5)}); + let luminance = dot(color, vec3(0.2126, 0.7152, 0.0722)); + color = mix(vec3(luminance), color, ${saturation.toFixed(5)}); + color = mix(color, color * vec3(${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(0.0)), ${mixAmount.toFixed(5)}); + return vec4(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(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(0.2126, 0.7152, 0.0722)); return vec4(vec3(luminance), sourceColor.a);`; + return `let sourceColor = textureSample(source, sourceSampler, input.uv); return vec4(vec3(1.0) - sourceColor.rgb, sourceColor.a);`; +} + +function effectShader(node: any): string { + return `${fullScreenVertex} +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var sourceSampler: sampler; +@fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { + ${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); +} diff --git a/addons/fxnode_rg/src/editor.ts b/addons/fxnode_rg/src/editor.ts new file mode 100644 index 0000000..07e0b3a --- /dev/null +++ b/addons/fxnode_rg/src/editor.ts @@ -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; +} + +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; + save(): Promise; + syncViewport(): Promise; + whenRendered(): Promise; + destroy(): Promise; +} + +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, +) { + 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 { + let root: FxNode | undefined; + let view: FxNodeView | undefined; + let destroyed = false; + let menuPending = false; + let resizeWork = Promise.resolve(); + const captured = new Set(); + 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; + } +} diff --git a/addons/fxnode_rg/src/index.ts b/addons/fxnode_rg/src/index.ts new file mode 100644 index 0000000..0a69912 --- /dev/null +++ b/addons/fxnode_rg/src/index.ts @@ -0,0 +1,3 @@ +export * from "./catalog"; +export * from "./compiler"; +export * from "./editor"; diff --git a/addons/fxnode_rg/test/compiler.test.ts b/addons/fxnode_rg/test/compiler.test.ts new file mode 100644 index 0000000..913d636 --- /dev/null +++ b/addons/fxnode_rg/test/compiler.test.ts @@ -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/u); + assert.match(serialized, /let gammaBalance = vec3/u); + assert.match(serialized, /let gainBalance = vec3/u); + assert.match(serialized, /color = \(color - vec3\(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/u); + assert.match(serialized, /let powerBalance = vec3/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/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\(0\.008, 0\.045, 0\.11\)/u); + assert.match(serialized, /vec3\(0\.68, 0\.94, 1\.0\)/u); + assert.match(serialized, /fract\(input\.uv \* vec2\(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, + ); +}); diff --git a/addons/fxnode_rg/test/run.mjs b/addons/fxnode_rg/test/run.mjs new file mode 100644 index 0000000..e5eabfa --- /dev/null +++ b/addons/fxnode_rg/test/run.mjs @@ -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 }); +} diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index fa5650c..10083db 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -56,6 +56,7 @@ const compactLightLimit = 500; const compactPointStart = 11; const compactSpotCount = compactPointStart + compactLightLimit; const compactSpotStart = compactSpotCount + 1; +const compactAmbientStart = compactSpotStart + compactLightLimit; const clusterShader = /* wgsl */ ` @group(0) @binding(0) var pointLights: array>; @@ -72,6 +73,7 @@ fn main(@builtin(global_invocation_id) id: vec3) { var pointCount = 0u; var spotCount = 0u; var light = vec3(0.0); + var ambientLight = vec3(0.0); var pointLight = vec3(0.0); var spotLight = vec3(0.0); for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) { @@ -90,7 +92,9 @@ fn main(@builtin(global_invocation_id) id: vec3) { for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) { let enabled = rectLights[i * 3u + 1u].z; count += select(0u, 1u, enabled != 0.0); - light += rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02; + let contribution = rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02; + light += contribution; + ambientLight += contribution; } for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) { let enabled = spotLights[i * 2u + 1u].w; @@ -112,7 +116,9 @@ fn main(@builtin(global_invocation_id) id: vec3) { } for (var i = 0u; i < arrayLength(&ambientLights); i++) { count += select(0u, 1u, ambientLights[i].a != 0.0); - light += ambientLights[i].rgb * ambientLights[i].a; + let contribution = ambientLights[i].rgb * ambientLights[i].a; + light += contribution; + ambientLight += contribution; } clusters[0] = count; clusters[1] = bitcast(light.r); @@ -126,6 +132,9 @@ fn main(@builtin(global_invocation_id) id: vec3) { clusters[9] = bitcast(spotLight.b); clusters[10] = pointCount; clusters[${compactSpotCount}] = spotCount; + clusters[${compactAmbientStart}] = bitcast(ambientLight.r); + clusters[${compactAmbientStart + 1}] = bitcast(ambientLight.g); + clusters[${compactAmbientStart + 2}] = bitcast(ambientLight.b); }`; const basicForwardShader = /* wgsl */ ` @@ -180,9 +189,7 @@ fn fragment(input: VertexOutput) -> @location(0) vec4 { let base = materials[material * 3u]; let properties = materials[material * 3u + 1u]; let clusterLight = vec3(bitcast(clusters[1]), bitcast(clusters[2]), bitcast(clusters[3])); - let light = vec3(0.12 + max(dot(input.normal, normalize(vec3(0.4, 0.7, 0.6))), 0.0) * 0.75) + clusterLight; - let clustered = min(f32(clusters[0]) * 0.002, 0.05); - let color = base.rgb * (light + clustered) * accent.color.rgb * mix(1.0, 1.1, properties.x); + let color = base.rgb * clusterLight * accent.color.rgb * mix(1.0, 1.1, properties.x); return vec4(color, base.a); }`; @@ -272,6 +279,7 @@ struct VertexOutput { @group(0) @binding(7) var cameraMatrices: array>; @group(0) @binding(8) var pointLights: array>; @group(0) @binding(9) var spotLights: array>; +@group(0) @binding(10) var directionalLights: array>; ${mask ? "@group(1) @binding(0) var materialSampler: sampler;" : ""} ${names.map((name, slot) => (texture(slot) ? `@group(1) @binding(${slot + 1}) var ${name}: texture_2d;` : "")).join("\n")} ${transmission ? "@group(2) @binding(0) var sceneColor: texture_2d;\n@group(2) @binding(1) var sceneSampler: sampler;" : ""} @@ -315,36 +323,43 @@ fn vertex( return output; } -fn studioEnvironment(direction: vec3) -> vec3 { - let ray = direction; - let horizon = mix(vec3(0.14, 0.15, 0.16), vec3(0.62, 0.59, 0.54), clamp(ray.y * 0.5 + 0.5, 0.0, 1.0)); - let key = max(dot(ray, normalize(vec3(-0.65, 0.7, 0.35))), 0.0); - let key2 = key * key; - let key4 = key2 * key2; - let key8 = key4 * key4; - let key16 = key8 * key8; - let key32 = key16 * key16; - let key64 = key32 * key32; - return horizon + vec3(5.0, 4.2, 3.1) * key64 * key32; -} - fn pow5(value: f32) -> f32 { let squared = value * value; return squared * squared * value; } -fn pointContribution(index: u32, normal: vec3, world: vec3) -> vec3 { +fn directContribution(normal: vec3, view: vec3, lightDirection: vec3, radiance: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { + 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, view: vec3, world: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { let colorIntensity = pointLights[index * 2u]; let state = pointLights[index * 2u + 1u]; if (state.y == 0.0 || state.x <= 0.0) { return vec3(0.0); } let offset = positions[index].xyz - world; let distance = length(offset); let falloff = max(1.0 - distance / state.x, 0.0); - let incidence = max(dot(normal, offset / max(distance, 0.0001)), 0.0); - return colorIntensity.rgb * colorIntensity.a * falloff * falloff * incidence * 0.08; + let radiance = colorIntensity.rgb * colorIntensity.a * falloff * falloff * 0.08; + let sourceRoughness = clamp(roughness + max(state.z, 0.0) / max(distance, 0.0001), roughness, 1.0); + return directContribution(normal, view, offset / max(distance, 0.0001), radiance, baseColor, metallic, sourceRoughness, f0); } -fn spotContribution(index: u32, normal: vec3, world: vec3) -> vec3 { +fn spotContribution(index: u32, normal: vec3, view: vec3, world: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { let colorIntensity = spotLights[index * 2u]; let state = spotLights[index * 2u + 1u]; if (state.w == 0.0 || state.x <= 0.0) { return vec3(0.0); } @@ -353,8 +368,16 @@ fn spotContribution(index: u32, normal: vec3, world: vec3) -> vec3(0.0, 0.0, -1.0))); let cone = smoothstep(cos(state.z), cos(state.y), dot(direction, fromLight / max(distance, 0.0001))); let falloff = max(1.0 - distance / state.x, 0.0); - let incidence = max(dot(normal, -fromLight / max(distance, 0.0001)), 0.0); - return colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * incidence * 0.08; + let radiance = colorIntensity.rgb * colorIntensity.a * cone * falloff * falloff * 0.08; + return directContribution(normal, view, -fromLight / max(distance, 0.0001), radiance, baseColor, metallic, roughness, f0); +} + +fn directionalContribution(index: u32, normal: vec3, view: vec3, baseColor: vec3, metallic: f32, roughness: f32, f0: vec3) -> vec3 { + 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(0.0, 0.0, 1.0))); + return directContribution(normal, view, lightDirection, colorIntensity.rgb * colorIntensity.a * 0.1, baseColor, metallic, roughness, f0); } @fragment @@ -379,12 +402,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l ${doubleSided ? "normal *= select(-1.0, 1.0, frontFacing);" : ""} ${normalTexture ? `let tangent = normalize(input.tangent.xyz - normal * dot(input.tangent.xyz, normal)); let bitangent = cross(normal, tangent) * input.tangent.w; let mapped = ${sample(2)}.xyz * 2.0 - 1.0; normal = normalize(mat3x3(tangent, bitangent, normal) * vec3(mapped.xy * extra.y, mapped.z));` : ""} let view = normalize(cameraMatrices[4].xyz - input.world); - let lightDirection = normalize(vec3(0.4, 0.7, 0.6)); - let halfVector = normalize(lightDirection + view); - let nDotL = max(dot(normal, lightDirection), 0.0); let nDotV = max(dot(normal, view), 0.0001); - let nDotH = max(dot(normal, halfVector), 0.0); - let vDotH = max(dot(view, halfVector), 0.0); let specularWeight = extension2.z * ${sample(11)}.a; let dielectricColor = vec3(${number(dielectric)} * specularWeight) * extension6.rgb * ${sample(12)}.rgb; var f0 = ${specularGlossiness ? "packedMaterial.rgb * extension3.rgb" : "mix(dielectricColor, base.rgb, metallic)"}; @@ -398,58 +416,42 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l f0 = mix(f0, iridescenceTint * iridescenceLevel, iridescenceAmount * (1.0 - roughness * 0.5));` : "" } - let fresnelPower = pow5(1.0 - vDotH); - let fresnel = f0 + (vec3(1.0) - f0) * fresnelPower; - let alpha = roughness * roughness; - let alpha2 = alpha * alpha; - let denominator = nDotH * nDotH * (alpha2 - 1.0) + 1.0; - let distribution = alpha2 / max(3.14159265 * denominator * denominator, 0.0001); - let geometryV = nDotV / mix(nDotV, 1.0, alpha * 0.5); - let geometryL = nDotL / mix(nDotL, 1.0, alpha * 0.5); - let specular = distribution * geometryV * geometryL * fresnel / max(4.0 * nDotV * nDotL, 0.0001); - let clusterLight = vec3(bitcast(clusters[1]), bitcast(clusters[2]), bitcast(clusters[3])); - let pointAmbient = vec3(bitcast(clusters[4]), bitcast(clusters[5]), bitcast(clusters[6])); - let spotAmbient = vec3(bitcast(clusters[7]), bitcast(clusters[8]), bitcast(clusters[9])); - let ambient = vec3(0.14) + max(clusterLight - pointAmbient - spotAmbient, vec3(0.0)); - var pointDiffuse = vec3(0.0); + let fresnelPower = pow5(1.0 - nDotV); + let ambient = vec3(bitcast(clusters[${compactAmbientStart}]), bitcast(clusters[${compactAmbientStart + 1}]), bitcast(clusters[${compactAmbientStart + 2}])); + var directLight = vec3(0.0); let pointCount = clusters[10]; if (pointCount <= ${compactLightLimit}u) { for (var light = 0u; light < pointCount; light++) { - pointDiffuse += pointContribution(clusters[${compactPointStart}u + light], normal, input.world); + directLight += pointContribution(clusters[${compactPointStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0); } } else { for (var light = 0u; light < arrayLength(&pointLights) / 2u; light++) { - pointDiffuse += pointContribution(light, normal, input.world); + directLight += pointContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0); } } - var spotDiffuse = vec3(0.0); let spotCount = clusters[${compactSpotCount}]; if (spotCount <= ${compactLightLimit}u) { for (var light = 0u; light < spotCount; light++) { - spotDiffuse += spotContribution(clusters[${compactSpotStart}u + light], normal, input.world); + directLight += spotContribution(clusters[${compactSpotStart}u + light], normal, view, input.world, base.rgb, metallic, roughness, f0); } } else { for (var light = 0u; light < arrayLength(&spotLights) / 2u; light++) { - spotDiffuse += spotContribution(light, normal, input.world); + directLight += spotContribution(light, normal, view, input.world, base.rgb, metallic, roughness, f0); } } - let defaultLight = select(1.0, 0.18, clusters[0] != 0u); + for (var light = 0u; light < arrayLength(&directionalLights) / 2u; light++) { + directLight += directionalContribution(light, normal, view, base.rgb, metallic, roughness, f0); + } let occlusion = mix(1.0, ${sample(4)}.r, extension2.w); - let diffuseColor = base.rgb * (vec3(1.0) - fresnel) * (1.0 - metallic) / 3.14159265; - let diffuse = (diffuseColor + specular) * nDotL * defaultLight; - let reflected = studioEnvironment(reflect(-view, normal)); - let environmentFresnel = f0 + (vec3(1.0) - f0) * pow5(1.0 - nDotV); - let environment = (base.rgb * (1.0 - metallic) * studioEnvironment(normal) * 0.5 + reflected * environmentFresnel * (1.0 - roughness * 0.75)) * occlusion; let emissive = vec3(properties.z, properties.w, extra.x) * ${sample(3, "vec4(1.0)")}.rgb * extension1.w; - let localDiffuse = base.rgb * (1.0 - metallic) * (pointDiffuse + spotDiffuse); ${sheen ? "let sheen = extension3.rgb * pow(1.0 - nDotV, mix(2.0, 8.0, extension3.w));" : ""} ${ clearcoat ? `let clearcoatFresnel = 0.04 + 0.96 * fresnelPower; - let clearcoat = extension2.x * ${sample(7)}.r * clearcoatFresnel * pow(nDotH, mix(256.0, 4.0, extension2.y));` + let clearcoat = extension2.x * ${sample(7)}.r * clearcoatFresnel;` : "" } - var color = base.rgb * ambient * occlusion + diffuse + environment + localDiffuse${sheen ? " + sheen" : ""}${clearcoat ? " + vec3(clearcoat)" : ""} + emissive; + var color = base.rgb * ambient * occlusion + directLight${sheen ? " + directLight * sheen" : ""}${clearcoat ? " + directLight * clearcoat" : ""} + emissive; ${unlit ? "color = base.rgb + emissive;" : ""} ${ transmission @@ -474,7 +476,7 @@ fn fragment(input: VertexOutput, @builtin(front_facing) frontFacing: bool) -> @l } let normalizedTransmissionTint = base.rgb / max(max(base.r, base.g), max(base.b, 0.001)); transmitted *= normalizedTransmissionTint; - color = mix(color, transmitted + reflected * environmentFresnel, transmissionAmount);` + color = mix(color, transmitted, transmissionAmount);` : "" } return vec4(color * accent.a, ${alphaMode === 2 ? "base.a" : "1.0"}); @@ -509,21 +511,6 @@ const copyShader = `${fullscreenVertex} return textureSample(source, sourceSampler, input.uv); }`; -const studioBackground = /* wgsl */ ` -fn studioBackground(uv: vec2) -> vec3 { - 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 { - return vec4(studioBackground(input.uv), 1.0); -}`; - function effectFragment(kind: string, options: Record) { const amount = Number(options.amount ?? options.exposure ?? 1); const safeAmount = Number.isFinite(amount) ? amount : 1; @@ -543,10 +530,7 @@ function effectFragment(kind: string, options: Record) { }`; } -function presentShader( - toneMap: string, - composite: "studio" | "scene" | undefined, -) { +function presentShader(toneMap: string, composite: "scene" | undefined) { const tone = toneMap === "reinhard" ? "color / (color + vec3(1.0))" @@ -554,13 +538,12 @@ function presentShader( ? "clamp(color, vec3(0.0), vec3(1.0))" : "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))"; return `${fullscreenVertex} -${composite === "studio" ? studioBackground : ""} @group(0) @binding(0) var source: texture_2d; @group(0) @binding(1) var sourceSampler: sampler; ${composite === "scene" ? "@group(0) @binding(2) var scene: texture_2d;" : ""} @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { let value = textureSample(source, sourceSampler, input.uv); - let color = max(${composite === "studio" ? "value.rgb + studioBackground(input.uv) * (1.0 - value.a)" : composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0)); + let color = max(${composite === "scene" ? "value.rgb + textureSample(scene, sourceSampler, input.uv).rgb * (1.0 - value.a)" : "value.rgb"}, vec3(0.0)); return vec4(${tone}, ${composite ? "1.0" : "value.a"}); }`; } @@ -599,7 +582,9 @@ export class Scene { readonly core: YawnCore; readonly ready: Promise; readonly hdr: boolean; + readonly clearColor: readonly [number, number, number, number]; #graphUpdates = Promise.resolve(); + #renderGraph?: object; #computePasses = new Map(); #meshes = new Map(); #shaders = new Map(); @@ -625,9 +610,17 @@ export class Scene { debug?: boolean; fps?: number; hdr?: boolean; + clearColor?: ArrayLike; } = {}, ) { + const clearColor = Array.from(options.clearColor ?? [0, 0, 0, 1]); + if ( + clearColor.length !== 4 || + clearColor.some((value) => !Number.isFinite(value)) + ) + throw new TypeError("Scene clearColor must contain four finite numbers"); this.hdr = options.hdr ?? true; + this.clearColor = clearColor as [number, number, number, number]; this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes, debug: options.debug, @@ -648,7 +641,7 @@ export class Scene { }); this.core.array("nodeRotors").write(0, [0, 0, 0, 1]); this.core.array("nodeScales").write(0, [1, 1, 1, 0]); - this.core.array("sceneAccent").write(0, [0.28, 0.72, 1, 1]); + this.core.array("sceneAccent").write(0, [1, 1, 1, 1]); const material = await this.core.allocateObject("materials"); this.core .array("materials") @@ -1045,6 +1038,29 @@ export class Scene { return update; } + /** Compiles a serialized render graph and returns its reusable loadout ID. */ + async compileRenderGraph(serialized: string) { + if (typeof serialized !== "string") + throw new TypeError("Serialized render graph must be a string"); + await this.ready; + return this.core.compileGraph(serialized); + } + + /** Switches this Scene to a previously compiled render-graph loadout. */ + async setLoadout(id: string) { + if (typeof id !== "string") + throw new TypeError("Loadout ID must be a string"); + await this.ready; + await this.core.switchLoadout(id); + } + + /** Returns an isolated snapshot of the Scene-authored render graph. */ + async getRenderGraph() { + await this.ready; + if (!this.#renderGraph) throw new Error("Render graph is not ready"); + return structuredClone(this.#renderGraph); + } + async #compileRenderGraph() { const buffers = new Map(); const textures = new Map(); @@ -1127,11 +1143,6 @@ export class Scene { (material) => this.array("materialExtensions").row(material)[0] > 0, ); }); - const compositeStudioBackground = - renderedMeshes.length > 0 && - !hasTransmission && - this.#effects.size === 0 && - this.#shaders.size === 0; const compositeSceneColor = hasTransmission && this.#effects.size === 0 && this.#shaders.size === 0; const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm"; @@ -1190,7 +1201,7 @@ export class Scene { type: "render", pipeline: "empty-forward", after: previous, - color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }], + color: [{ resource: "hdr", clear: [...this.clearColor] }], draw: { vertices: 0 }, }); previous = ["forward-empty"]; @@ -1204,27 +1215,27 @@ export class Scene { ["camera-matrices", "cameraMatrices"], ]) addBuffer({ id, array, usage: ["storage"] }); - if (!compositeStudioBackground) { + if (compositeSceneColor) { renderPipelines.push({ - id: "studio-background", - code: studioBackgroundShader, + id: "scene-color-clear", + code: clearShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] }, }); passes.push({ - id: "studio-background", + id: "scene-color-clear", type: "render", - pipeline: "studio-background", + pipeline: "scene-color-clear", after: previous, color: [ { - resource: compositeSceneColor ? "scene-color" : "hdr", - clear: [0, 0, 0, 1], + resource: "scene-color", + clear: [...this.clearColor], }, ], - draw: { vertices: 3 }, + draw: { vertices: 0 }, }); - previous = ["studio-background"]; + previous = ["scene-color-clear"]; } const forwardPipelines = new Map(); let firstColor = true; @@ -1363,7 +1374,9 @@ export class Scene { { group: 0, binding: 0, resource: "hdr" }, { group: 0, binding: 1, resource: "linear" }, ], - color: [{ resource: "scene-color", clear: [0, 0, 0, 1] }], + color: [ + { resource: "scene-color", clear: [...this.clearColor] }, + ], draw: { vertices: 3 }, }); previous = ["copy-scene-color"]; @@ -1543,6 +1556,11 @@ export class Scene { ? [ { group: 0, binding: 8, resource: "point-lights" }, { group: 0, binding: 9, resource: "spot-lights" }, + { + group: 0, + binding: 10, + resource: "directional-lights", + }, ] : []), ...(detailed && mask @@ -1577,10 +1595,11 @@ export class Scene { color: [ { resource: forwardTarget, - ...((firstColor && compositeStudioBackground) || - clearCompositeLayer + ...(clearCompositeLayer ? { clear: [0, 0, 0, 0] } - : { load: "load" }), + : firstColor + ? { clear: [...this.clearColor] } + : { load: "load" }), }, ], depth: { @@ -1689,14 +1708,7 @@ export class Scene { ); renderPipelines.push({ id: "present", - code: presentShader( - toneMap, - compositeStudioBackground - ? "studio" - : compositeSceneColor - ? "scene" - : undefined, - ), + code: presentShader(toneMap, compositeSceneColor ? "scene" : undefined), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] }, }); @@ -1712,7 +1724,7 @@ export class Scene { ? [{ group: 0, binding: 2, resource: "scene-color" }] : []), ], - color: [{ resource: "canvas", clear: [0, 0, 0, 1] }], + color: [{ resource: "canvas", clear: [...this.clearColor] }], draw: { vertices: 3 }, }); @@ -1729,6 +1741,7 @@ export class Scene { pipelines: { render: renderPipelines, compute: computePipelines }, passes, }; + this.#renderGraph = structuredClone(graph); const id = await this.core.compileGraph(serialize(graph)); await this.core.switchLoadout(id); } diff --git a/addons/handles/src/lights/PointLight.ts b/addons/handles/src/lights/PointLight.ts index 98d363f..887d05c 100644 --- a/addons/handles/src/lights/PointLight.ts +++ b/addons/handles/src/lights/PointLight.ts @@ -1,25 +1,62 @@ import { Node, type NodeOptions } from "../Node"; import type { Scene } from "../Scene"; -export type PointLightOptions = NodeOptions & { color?: ArrayLike; intensity?: number; range?: number }; +export type PointLightOptions = NodeOptions & { + color?: ArrayLike; + intensity?: number; + range?: number; + radius?: number; +}; export class PointLight extends Node { constructor(scene: Scene, options: PointLightOptions = {}) { super(scene, options); const nodeReady = this.ready; this.ready = nodeReady.then(async () => { - const array = await scene.ensureRows("pointLights", this.id + 1, 32, "f32"); - array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, 1, 0, 0]); + const array = await scene.ensureRows( + "pointLights", + this.id + 1, + 32, + "f32", + ); + array + .row(this.id) + .set([ + ...Array.from(options.color ?? [1, 1, 1]), + options.intensity ?? 1, + options.range ?? 10, + 1, + options.radius ?? 0, + 0, + ]); return this; }); } - get color() { return this.scene.array("pointLights").row(this.id).subarray(0, 3); } - set color(value: ArrayLike) { this.scene.array("pointLights").row(this.id).set(value, 0); } - get intensity() { return this.scene.array("pointLights").row(this.id)[3]; } - set intensity(value: number) { this.scene.array("pointLights").row(this.id)[3] = value; } - get range() { return this.scene.array("pointLights").row(this.id)[4]; } - set range(value: number) { this.scene.array("pointLights").row(this.id)[4] = value; } + get color() { + return this.scene.array("pointLights").row(this.id).subarray(0, 3); + } + set color(value: ArrayLike) { + this.scene.array("pointLights").row(this.id).set(value, 0); + } + get intensity() { + return this.scene.array("pointLights").row(this.id)[3]; + } + set intensity(value: number) { + this.scene.array("pointLights").row(this.id)[3] = value; + } + get range() { + return this.scene.array("pointLights").row(this.id)[4]; + } + set range(value: number) { + this.scene.array("pointLights").row(this.id)[4] = value; + } + get radius() { + return this.scene.array("pointLights").row(this.id)[6]; + } + set radius(value: number) { + this.scene.array("pointLights").row(this.id)[6] = value; + } override async dispose() { await this.ready; diff --git a/addons/handles/test/handles.test.ts b/addons/handles/test/handles.test.ts index 47381b9..c5db862 100644 --- a/addons/handles/test/handles.test.ts +++ b/addons/handles/test/handles.test.ts @@ -193,15 +193,18 @@ test("Node, ArcRotateCamera, and light handles update their shared rows", async color: [0.2, 0.4, 0.6], intensity: 7, range: 8, + radius: 2, }); await light.ready; close( scene.array("pointLights").row(light.id), - [0.2, 0.4, 0.6, 7, 8, 1, 0, 0], + [0.2, 0.4, 0.6, 7, 8, 1, 2, 0], ); light.intensity = 9; light.range = 10; + light.radius = 3; close(scene.array("pointLights").row(light.id).subarray(3, 5), [9, 10]); + assert.equal(scene.array("pointLights").row(light.id)[6], 3); }); test("ArcRotateCamera pans one projected world pixel per pointer pixel", async () => { diff --git a/addons/handles/test/scene-graph.test.ts b/addons/handles/test/scene-graph.test.ts index 44ed212..4b5bf1b 100644 --- a/addons/handles/test/scene-graph.test.ts +++ b/addons/handles/test/scene-graph.test.ts @@ -20,16 +20,18 @@ if (typeof globalThis.ImageBitmap === "undefined") value: class ImageBitmap {}, }); -test("empty scenes clear without drawing placeholder geometry", async () => { +test("empty scenes use neutral authored defaults without placeholder geometry", async () => { const scene = await createScene(); const emptyPass = latestGraph(scene).passes.find( (pass: any) => pass.id === "forward-empty", ); assert.equal(emptyPass.draw.vertices, 0); + assert.deepEqual(emptyPass.color[0].clear, [0, 0, 0, 1]); + assert.deepEqual(Array.from(scene.array("sceneAccent").row(0)), [1, 1, 1, 1]); }); -test("opaque PBR graph fuses the background and compacts sparse lights", async () => { +test("opaque PBR graph has no implicit environment and compacts sparse lights", async () => { const scene = await createScene(); const material = new PBRMaterial(scene); await material.ready; @@ -46,19 +48,35 @@ test("opaque PBR graph fuses the background and compacts sparse lights", async ( assert.equal(pipeline.primitive.frontFace, "ccw"); assert.equal( - graph.passes.some((pass: any) => pass.id === "studio-background"), + graph.passes.some((pass: any) => pass.id.includes("background")), false, ); - assert.match(present.code, /fn studioBackground\(/u); - assert.deepEqual(forward.color[0].clear, [0, 0, 0, 0]); + assert.doesNotMatch(JSON.stringify(graph), /studio/iu); + assert.deepEqual(forward.color[0].clear, [0, 0, 0, 1]); assert.match(pipeline.code, /@interpolate\(flat\) material: u32/u); assert.match(pipeline.code, /materials\[input\.material \* 3u\]/u); + assert.doesNotMatch(pipeline.code, /defaultLight|studioEnvironment/u); + assert.doesNotMatch(pipeline.code, /0\.4, 0\.7, 0\.6/u); + assert.match( + pipeline.code, + /let ambient = vec3\(bitcast\(clusters\[1012\]\)/u, + ); + assert.match(pipeline.code, /fn directContribution/u); + assert.match(pipeline.code, /fn directionalContribution/u); + assert.equal( + forward.bindings.find((binding: any) => binding.binding === 10)?.resource, + "directional-lights", + ); assert.match(clusters.code, /clusters\[11u \+ pointCount\] = i/u); assert.match(clusters.code, /clusters\[512u \+ spotCount\] = i/u); assert.match(pipeline.code, /let pointCount = clusters\[10\]/u); assert.match(pipeline.code, /pointCount <= 500u/u); assert.match(pipeline.code, /arrayLength\(&pointLights\) \/ 2u/u); + assert.match( + pipeline.code, + /let sourceRoughness = clamp\(roughness \+ max\(state\.z/u, + ); assert.match(pipeline.code, /let spotCount = clusters\[511\]/u); assert.match(pipeline.code, /spotCount <= 500u/u); }); @@ -103,9 +121,14 @@ test("transmission renders directly into a composited layer with five taps", asy const presentPass = graph.passes.find((pass: any) => pass.id === "present"); assert.equal( - graph.passes.some((pass: any) => pass.id === "studio-background"), + graph.passes.some((pass: any) => pass.id === "scene-color-clear"), true, ); + const sceneColorClear = graph.passes.find( + (pass: any) => pass.id === "scene-color-clear", + ); + assert.equal(sceneColorClear.draw.vertices, 0); + assert.deepEqual(sceneColorClear.color[0].clear, [0, 0, 0, 1]); assert.equal( graph.passes.some((pass: any) => pass.id === "copy-scene-color"), false, @@ -124,6 +147,27 @@ test("transmission renders directly into a composited layer with five taps", asy assert.match(pipeline.code, /transmitted \*= pow/u); }); +test("clear color is explicit scene data used by empty and forward graphs", async () => { + const clearColor = [0.03, 0.06, 0.09, 1] as const; + const scene = await createScene({ clearColor }); + const empty = latestGraph(scene).passes.find( + (pass: any) => pass.id === "forward-empty", + ); + assert.deepEqual(empty.color[0].clear, clearColor); + + const material = new PBRMaterial(scene); + await material.ready; + const mesh = detailedMesh(scene, material); + await mesh.ready; + + const graph = latestGraph(scene); + assert.deepEqual(forwardPasses(graph)[0].color[0].clear, clearColor); + assert.deepEqual( + graph.passes.find((pass: any) => pass.id === "present").color[0].clear, + clearColor, + ); +}); + test("post-processing keeps the conservative transmission copy", async () => { const scene = await createScene(); const material = new PBRMaterial(scene, { transmission: 1 }); @@ -273,3 +317,35 @@ test("batchGraphUpdates compiles once for multiple mesh registrations", async () assert.equal(testCore(scene).graphs.length - before, 1); assert.equal(forwardPasses(latestGraph(scene)).length, 2); }); + +test("render graph snapshots expose isolated scene-owned declarations", async () => { + const scene = await createScene(); + const source: any = await scene.getRenderGraph(); + + assert.equal(source.id, "scene"); + assert.ok( + source.resources.buffers.some((buffer: any) => buffer.id === "clusters"), + ); + source.id = "mutated"; + source.resources.buffers.length = 0; + + const next: any = await scene.getRenderGraph(); + assert.equal(next.id, "scene"); + assert.ok( + next.resources.buffers.some((buffer: any) => buffer.id === "clusters"), + ); +}); + +test("serialized render graphs compile into explicitly selectable loadouts", async () => { + const scene = await createScene(); + const core = testCore(scene); + const serialized = core.serializedGraphs.at(-1)!; + const before = core.graphs.length; + + const loadoutId = await scene.compileRenderGraph(serialized); + await scene.setLoadout(loadoutId); + + assert.equal(core.graphs.length, before + 1); + assert.equal(loadoutId, `loadout-${before + 1}`); + assert.equal(core.loadouts.at(-1), loadoutId); +}); diff --git a/addons/handles/test/scene-helpers.ts b/addons/handles/test/scene-helpers.ts index b1e23a5..897452d 100644 --- a/addons/handles/test/scene-helpers.ts +++ b/addons/handles/test/scene-helpers.ts @@ -13,8 +13,10 @@ export const triangle = { indices: [0, 1, 2], }; -export async function createScene() { - const scene = new Scene({} as HTMLCanvasElement); +export async function createScene( + options: ConstructorParameters[1] = {}, +) { + const scene = new Scene({} as HTMLCanvasElement, options); await scene.ready; return scene; } diff --git a/docs/guide/lights.md b/docs/guide/lights.md index d3d86ea..cdcd5b9 100644 --- a/docs/guide/lights.md +++ b/docs/guide/lights.md @@ -8,6 +8,7 @@ const point = new PointLight(scene, { color: [1, 0.25, 0.05], intensity: 20, range: 8, + radius: 0.5, // Softens the specular footprint like a small spherical emitter. }); const sun = new DirectionalLight(scene, { @@ -16,7 +17,10 @@ const sun = new DirectionalLight(scene, { 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]); ``` diff --git a/package-lock.json b/package-lock.json index 127c362..c77c6ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,13 @@ "vitepress": "^1.6.4" } }, + "addons/fxnode_rg": { + "name": "@yawn/fxnode-rg", + "version": "0.1.0", + "dependencies": { + "fxnode": "file:../../vendor/fxnode" + } + }, "addons/handles": { "name": "@yawn/handles", "version": "0.1.0", @@ -950,6 +957,62 @@ "node": ">=12" } }, + "node_modules/@gerrit0/mini-shiki": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@gerrit0/mini-shiki/-/mini-shiki-3.23.0.tgz", + "integrity": "sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/engine-oniguruma": "^3.23.0", + "@shikijs/langs": "^3.23.0", + "@shikijs/themes": "^3.23.0", + "@shikijs/types": "^3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/engine-oniguruma": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-3.23.0.tgz", + "integrity": "sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0", + "@shikijs/vscode-textmate": "^10.0.2" + } + }, + "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/langs": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-3.23.0.tgz", + "integrity": "sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/themes": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-3.23.0.tgz", + "integrity": "sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/types": "3.23.0" + } + }, + "node_modules/@gerrit0/mini-shiki/node_modules/@shikijs/types": { + "version": "3.23.0", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-3.23.0.tgz", + "integrity": "sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@shikijs/vscode-textmate": "^10.0.2", + "@types/hast": "^3.0.4" + } + }, "node_modules/@iconify-json/simple-icons": { "version": "1.2.93", "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.93.tgz", @@ -1020,6 +1083,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.46.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz", @@ -1459,6 +1538,10 @@ "resolved": "core", "link": true }, + "node_modules/@yawn/fxnode-rg": { + "resolved": "addons/fxnode_rg", + "link": true + }, "node_modules/@yawn/handles": { "resolved": "addons/handles", "link": true @@ -1504,6 +1587,23 @@ "node": ">= 14.0.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/birpc": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", @@ -1514,6 +1614,19 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -2096,6 +2209,23 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fxnode": { + "resolved": "vendor/fxnode", + "link": true + }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/hast-util-to-html": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", @@ -2165,6 +2295,33 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/lunr": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/lunr/-/lunr-2.3.9.tgz", + "integrity": "sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2182,6 +2339,47 @@ "dev": true, "license": "MIT" }, + "node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -2204,6 +2402,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -2298,6 +2503,22 @@ ], "license": "MIT" }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minisearch": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", @@ -2364,6 +2585,53 @@ "dev": true, "license": "ISC" }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -2412,6 +2680,22 @@ } } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -2423,6 +2707,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -2450,6 +2744,16 @@ "dev": true, "license": "MIT" }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -2606,6 +2910,542 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tsx": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", + "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, + "node_modules/typedoc": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/typedoc/-/typedoc-0.28.20.tgz", + "integrity": "sha512-uSKqkh8Cr48vllnEy+jdaAgOeR6Y+QCBW7usgUsKj7gJEfR7stw9U/fE49LBnj2tPRKPY0c0EBJSWe9Appmplg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@gerrit0/mini-shiki": "^3.23.0", + "lunr": "^2.3.9", + "markdown-it": "^14.3.0", + "minimatch": "^10.2.5", + "yaml": "^2.9.0" + }, + "bin": { + "typedoc": "bin/typedoc" + }, + "engines": { + "node": ">= 18", + "pnpm": ">= 10" + }, + "peerDependencies": { + "typescript": "5.0.x || 5.1.x || 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x || 5.7.x || 5.8.x || 5.9.x || 6.0.x" + } + }, + "node_modules/typedoc-plugin-markdown": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/typedoc-plugin-markdown/-/typedoc-plugin-markdown-4.12.0.tgz", + "integrity": "sha512-eJDEMAfxCmede22c/Jw7d0FA13ggAQv+KkwQYKYCdqI02cin6Rc9QRwbG/7XvvHWinuFejySnZVUWDtvGk3Vbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "typedoc": "0.28.x" + } + }, + "node_modules/typedoc-vitepress-theme": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/typedoc-vitepress-theme/-/typedoc-vitepress-theme-1.1.3.tgz", + "integrity": "sha512-EK9iV7e3+R8lFNigdc0rIPWMxqfmDku0uGac3qYUu9tS4Qf1rhWZnyZJ4zu4G3iXrP5mqNPkv2wpODzRlA7jLw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typedoc-plugin-markdown": ">=4.11.0" + } + }, + "node_modules/typescript": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", + "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", @@ -2718,6 +3558,544 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vite": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.1.0.tgz", + "integrity": "sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.24.2", + "postcss": "^8.5.1", + "rollup": "^4.30.1" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, "node_modules/vitepress": { "version": "1.6.4", "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", @@ -2919,6 +4297,22 @@ "dev": true, "license": "MIT" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", @@ -2929,6 +4323,42 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "vendor/fxnode": { + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@playwright/test": "1.61.1", + "@types/node": "22.14.0", + "prettier": "^3.6.2", + "tsx": "4.19.2", + "typedoc": "0.28.20", + "typedoc-plugin-markdown": "4.12.0", + "typedoc-vitepress-theme": "1.1.3", + "typescript": "5.7.2", + "vite": "6.1.0", + "vitepress": "1.6.4" + }, + "engines": { + "node": ">=20" + } + }, + "vendor/fxnode/node_modules/@types/node": { + "version": "22.14.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.0.tgz", + "integrity": "sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "vendor/fxnode/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 863a9b4..40fb688 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "server": "go run ./server", "coredocs": "vitepress dev coredocs --host 0.0.0.0 --port ${PORT:-8080}", "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:fxnode-rg": "npm test --workspace @yawn/fxnode-rg", "test:handles:screenshots": "npm run test:screenshots --workspace @yawn/handles" }, "devDependencies": { diff --git a/server/app.go b/server/app.go index b39d469..da174a3 100644 --- a/server/app.go +++ b/server/app.go @@ -163,11 +163,16 @@ func (app *application) savePlayground(response http.ResponseWriter, request *ht http.Error(response, "method not allowed", http.StatusMethodNotAllowed) return } - request.Body = http.MaxBytesReader(response, request.Body, maximumCodeBytes+4096) + request.Body = http.MaxBytesReader( + response, + request.Body, + maximumCodeBytes+maximumLoadoutsBytes+4096, + ) var input struct { - ID string `json:"id"` - Title string `json:"title"` - Code string `json:"code"` + ID string `json:"id"` + Title string `json:"title"` + Code string `json:"code"` + Loadouts json.RawMessage `json:"loadouts"` } decoder := json.NewDecoder(request.Body) 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) 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 { writeJSONError(response, err.Error(), http.StatusBadRequest) return diff --git a/server/app_test.go b/server/app_test.go index 7aff265..7aab904 100644 --- a/server/app_test.go +++ b/server/app_test.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "context" + "database/sql" "encoding/json" "net/http" "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) { app := testApplication(t) handler := app.routes() - requestBody, _ := json.Marshal(map[string]string{ + requestBody, _ := json.Marshal(map[string]any{ "title": "First scene", "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)) response := httptest.NewRecorder() @@ -118,6 +160,17 @@ func TestSaveAndLoadPlaygroundRevision(t *testing.T) { if loaded.Code != "const scene = true;" || loaded.Title != "First scene" { 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) response = httptest.NewRecorder() diff --git a/server/build.mjs b/server/build.mjs index 1b1b918..caacc03 100644 --- a/server/build.mjs +++ b/server/build.mjs @@ -16,15 +16,39 @@ const browser = { 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([ - [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"), - "./importer-worker.js", + { url: "./importer-worker.js" }, ], [ 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"); const transformed = contents.replace( /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) throw new Error(`worker constructor not found in ${source}`); 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); const source = "import " + JSON.stringify(url.href) + ";"; const bootstrap = URL.createObjectURL(new Blob([source], { type: "text/javascript" })); @@ -70,6 +102,17 @@ await Promise.all([ outfile: path.join(output, "handles.js"), 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({ bundle: false, entryPoints: [path.join(root, "core", "worker.js")], diff --git a/server/database.go b/server/database.go index 7674b17..36195d8 100644 --- a/server/database.go +++ b/server/database.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "database/sql" + "encoding/json" "errors" "fmt" "math/big" @@ -17,18 +18,20 @@ import ( ) const ( - maximumCodeBytes = 200 * 1024 - maximumTitleRunes = 80 + maximumCodeBytes = 200 * 1024 + maximumLoadoutsBytes = 512 * 1024 + maximumTitleRunes = 80 ) var playgroundIDPattern = regexp.MustCompile(`^[a-z0-9]{6,24}$`) type playground struct { - ID string `json:"id"` - Revision int `json:"revision"` - Title string `json:"title"` - Code string `json:"code"` - CreatedAt time.Time `json:"createdAt"` + ID string `json:"id"` + Revision int `json:"revision"` + Title string `json:"title"` + Code string `json:"code"` + Loadouts json.RawMessage `json:"loadouts"` + CreatedAt time.Time `json:"createdAt"` } type playgroundStore struct { @@ -61,16 +64,45 @@ func (store *playgroundStore) initialize() error { revision INTEGER NOT NULL CHECK (revision > 0), title TEXT NOT NULL, code TEXT NOT NULL, + loadouts TEXT NOT NULL DEFAULT '[]', created_at TEXT NOT NULL, PRIMARY KEY (id, revision) ); CREATE INDEX IF NOT EXISTS playgrounds_created_at 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 } -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) { 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) { 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 == "" { var err error 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) if _, err := transaction.ExecContext( ctx, - `INSERT INTO playgrounds (id, revision, title, code, created_at) VALUES (?, ?, ?, ?, ?)`, + `INSERT INTO playgrounds (id, revision, title, code, loadouts, created_at) VALUES (?, ?, ?, ?, ?, ?)`, id, revision, title, code, + loadouts, createdAt.Format(time.RFC3339Nano), ); err != nil { return playground{}, err @@ -117,7 +160,10 @@ func (store *playgroundStore) save(ctx context.Context, id, title, code string) if err := transaction.Commit(); err != nil { 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) { @@ -125,16 +171,17 @@ func (store *playgroundStore) get(ctx context.Context, id string, revision int) return playground{}, sql.ErrNoRows } var result playground - var createdAt string + var createdAt, loadouts string err := store.QueryRowContext( 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, 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 { return playground{}, err } + result.Loadouts = json.RawMessage(loadouts) result.CreatedAt, err = time.Parse(time.RFC3339Nano, createdAt) return result, err } diff --git a/server/packages.go b/server/packages.go index edd8c3e..f404101 100644 --- a/server/packages.go +++ b/server/packages.go @@ -41,6 +41,8 @@ func loadPackages(directory string) (map[string]memoryAsset, error) { for _, name := range []string{ "core.js", "handles.js", + "fxnode-rg.js", + "fxnode-worker.js", "worker.js", "importer-worker.js", "bvh-worker.js", diff --git a/server/packages_test.go b/server/packages_test.go index f4eb78c..7a98014 100644 --- a/server/packages_test.go +++ b/server/packages_test.go @@ -11,6 +11,8 @@ func TestLoadPackages(t *testing.T) { for _, name := range []string{ "core.js", "handles.js", + "fxnode-rg.js", + "fxnode-worker.js", "worker.js", "importer-worker.js", "bvh-worker.js", @@ -29,7 +31,7 @@ func TestLoadPackages(t *testing.T) { if err != nil { t.Fatal(err) } - if len(packages) != 6 { + if len(packages) != 8 { 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") { diff --git a/server/web/assets/gltf.js b/server/web/assets/gltf.js index 6c618b1..c046d8e 100644 --- a/server/web/assets/gltf.js +++ b/server/web/assets/gltf.js @@ -1,6 +1,7 @@ import { AmbientLight, ArcRotateCamera, + PointLight, Scene, importGltf, } from "@yawn/handles"; @@ -223,24 +224,47 @@ async function openFile(file) { scene = new Scene(canvas, { arenaBytes: 384 * 1024 * 1024, hdr: true, + clearColor: [0.025, 0.035, 0.045, 1], }); await scene.ready; - scene.array("sceneAccent").row(0).set([1, 1, 1, 1]); const meshes = await importGltf(scene, url); if (!meshes.length) throw new Error("The file does not contain any triangle meshes."); const bounds = findBounds(scene, meshes); - const light = new AmbientLight(scene, { - color: [1, 0.94, 0.86], - intensity: 0.28, - }); + const radius = Math.max(bounds.radius, 0.001); + const lights = [ + 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( scene, 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.renderTime.hidden = false; @@ -251,10 +275,7 @@ async function openFile(file) { if (!profiling) elements.renderTime.title = "GPU timestamp queries are not supported by this adapter."; - else { - elements.renderTime.removeAttribute("title"); - scene.array("sceneAccent").row(0)[3] = 1; - } + else elements.renderTime.removeAttribute("title"); activeViewer = { camera, diff --git a/server/web/assets/playground.css b/server/web/assets/playground.css index 31def0f..a54d27e 100644 --- a/server/web/assets/playground.css +++ b/server/web/assets/playground.css @@ -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:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; } .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; } .language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; } .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.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; } +.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-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; } .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.has-errors { color: #e8a4a4; } #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::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; } @@ -153,9 +210,13 @@ button { color: inherit; } .run-button kbd { display: none; } .tool-context { display: none; } .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; } .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) { diff --git a/server/web/assets/playground.js b/server/web/assets/playground.js index 8658261..a71eaae 100644 --- a/server/web/assets/playground.js +++ b/server/web/assets/playground.js @@ -1,3 +1,12 @@ +import { + compileFxNodeRenderGraph, + createRenderGraphEditor, + renderGraphNodeGroups, + renderGraphPresets, +} from "@yawn/fxnode-rg"; + +const renderGraphComment = "/* Render graph loadout:"; + const defaultSource = `import { AmbientLight, ArcRotateCamera, @@ -11,7 +20,11 @@ const defaultSource = `import { SpotLight, } 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.reserve({ nodes: 6, materials: 2 }); @@ -62,15 +75,16 @@ const lights = [ outerAngle: 0.72, }), new PointLight(scene, { - position: [2.2, 0.9, 1.8], - color: [1, 0.02, 0.45], - intensity: 16, - range: 6, + position: [3.4, 1.8, 3.6], + color: [0.9, 0.04, 0.38], + intensity: 45, + range: 14, + radius: 1.5, }), ]; 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 { scene, @@ -121,19 +135,27 @@ await scene.batchGraphUpdates(async () => { const elements = Object.fromEntries( [ "canvas", + "apply-loadout-button", "clear-console", "console-count", "console-empty", "console-lines", + "copy-loadout-code-button", "cursor-status", "dirty-indicator", "editor", "editor-diagnostics", + "editor-mode-status", + "encoding-status", "format-button", "fps-status", "fullscreen-button", + "graph-tab-dirty", "inspector", "inspector-button", + "loadout-name", + "loadout-select", + "loadout-status", "preview", "preview-message", "profile-adapter", @@ -143,6 +165,9 @@ const elements = Object.fromEntries( "profile-results", "profile-total", "project-title", + "render-graph-canvas", + "render-graph-editor", + "render-graph-hint", "reset-button", "resize-handle", "resolution-status", @@ -156,6 +181,7 @@ const elements = Object.fromEntries( "tab-dirty", "toast", "type-status", + "workspace-language", "workbench", ].map((id) => [id, document.getElementById(id)]), ); @@ -222,14 +248,29 @@ let currentID = ""; let currentRevision = 0; let initialSource = defaultSource; let generation = 0; +let lastRunSource = ""; let dirty = false; let activePanel = "console"; +let activeCodingTab = "scene"; let stopProfile; let profileCore; let toastTimer; let frameRequest; let sampledFrame = 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) => { 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; } +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() { const saved = savedLocation(); if (saved) { @@ -258,6 +322,7 @@ async function loadInitialPlayground() { elements["revision-label"].textContent = `Revision ${result.revision}`; elements["share-button"].hidden = false; initialSource = result.code; + customLoadouts = validCustomLoadouts(result.loadouts); document.title = `${result.title} — Yawn Playground`; return result.code; } catch (error) { @@ -266,7 +331,8 @@ async function loadInitialPlayground() { showToast(error.message); } } - return localStorage.getItem("yawn:playground:draft") || defaultSource; + customLoadouts = []; + return defaultSource; } function loadMonaco() { @@ -367,13 +433,12 @@ async function createEditor(source) { run, }); editor.onDidChangeCursorPosition(({ position }) => { + if (activeCodingTab !== "scene") return; elements["cursor-status"].textContent = `Ln ${position.lineNumber}, Col ${position.column}`; }); model.onDidChangeContent(() => { markDirty(true); - if (!currentID) - localStorage.setItem("yawn:playground:draft", model.getValue()); }); monaco.editor.onDidChangeMarkers(([resource]) => { if (resource.toString() === model.uri.toString()) updateDiagnostics(); @@ -384,6 +449,7 @@ async function createEditor(source) { function updateDiagnostics() { if (!monaco || !model) return; + if (activeCodingTab !== "scene") return; const markers = monaco.editor.getModelMarkers({ resource: model.uri }); const errors = markers.filter( (marker) => marker.severity === monaco.MarkerSeverity.Error, @@ -401,10 +467,403 @@ function updateDiagnostics() { : "TypeScript language service ready"; } -function markDirty(value) { +function markDirty(value, target = "code") { dirty = 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 = ''; + 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 = `${group.icon}${group.title}
${group.items.length} nodes
›`; + 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() { @@ -414,10 +873,11 @@ async function transpile() { const output = emitted.outputFiles.find((file) => file.name.endsWith(".js")); if (emitted.emitSkipped || !output) 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*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() { @@ -459,7 +919,7 @@ function replaceCanvas() { } async function run() { - if (!editor || elements["run-button"].disabled) return; + if (!editor || elements["run-button"].disabled) return false; clearPlaygroundTimers(); const runID = ++generation; elements["run-button"].disabled = true; @@ -500,16 +960,18 @@ async function run() { ); if (runID !== generation) { if (result?.dispose) await result.dispose(); - return; + return false; } current = result; window.__yawnPlayground = result; + lastRunSource = model.getValue(); elements["preview-message"].hidden = true; setRunStatus( `Running · ${Math.round(performance.now() - started)} ms`, "ready", ); if (activePanel === "profile") await attachProfiler(); + return true; } catch (error) { if (runID === generation) { const message = error instanceof Error ? error.message : String(error); @@ -518,6 +980,7 @@ async function run() { showPreviewMessage("Scene could not start", message); openInspector("console"); } + return false; } finally { if (runID === generation) { elements["run-button"].disabled = false; @@ -641,6 +1104,10 @@ async function save() { if (!editor || elements["save-button"].disabled) return; elements["save-button"].disabled = true; try { + const hadLoadoutChanges = loadoutsDirty; + let stagedLoadout; + if (graphDirty && renderGraphEditor) + stagedLoadout = stageActiveLoadout(await renderGraphEditor.save()); const response = await fetch("/api/playgrounds", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -648,6 +1115,7 @@ async function save() { id: currentID, title: elements["project-title"].value.trim() || "Untitled playground", code: model.getValue(), + loadouts: customLoadouts, }), }); const result = await response.json(); @@ -656,11 +1124,15 @@ async function save() { currentID = result.id; currentRevision = result.revision; initialSource = result.code; + customLoadouts = validCustomLoadouts(result.loadouts); + renderLoadoutOptions(); history.pushState(null, "", `/playground/${result.id}/${result.revision}`); elements["revision-label"].textContent = `Revision ${result.revision}`; elements["share-button"].hidden = false; - localStorage.removeItem("yawn:playground:draft"); 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}`); } catch (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["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 () => { await navigator.clipboard.writeText(location.href); showToast("Playground URL copied"); @@ -786,14 +1275,23 @@ elements["resize-handle"].addEventListener("pointerup", (event) => { elements["resize-handle"].classList.remove("dragging"); }); 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); + closeRenderGraphMenu(); + void renderGraphEditor?.destroy(); void disposeCurrent(); }); try { const source = await loadInitialPlayground(); await createEditor(source); + await initializeRenderGraphWorkspace(); markDirty(false); sampleFPS(); await run(); diff --git a/server/web/assets/yawn.d.ts b/server/web/assets/yawn.d.ts index 29353ae..e218535 100644 --- a/server/web/assets/yawn.d.ts +++ b/server/web/assets/yawn.d.ts @@ -69,6 +69,7 @@ declare module "@yawn/handles" { export interface SceneOptions { arenaBytes?: number; + clearColor?: ArrayLike; debug?: boolean; fps?: number; hdr?: boolean; @@ -78,6 +79,7 @@ declare module "@yawn/handles" { readonly core: YawnCore; readonly ready: Promise; readonly hdr: boolean; + readonly clearColor: readonly [number, number, number, number]; array(name: string): SharedRows; ensureRows( name: string, @@ -88,6 +90,9 @@ declare module "@yawn/handles" { reserve(additional: { nodes?: number; materials?: number }): Promise; batchWrites(operation: () => T): T; batchGraphUpdates(operation: () => T | Promise): Promise; + compileRenderGraph(serialized: string): Promise; + setLoadout(id: string): Promise; + getRenderGraph(): Promise; dispose(): void; } @@ -140,12 +145,7 @@ declare module "@yawn/handles" { } export type VertexKind = - | "positions" - | "normals" - | "tangents" - | "uvs" - | "colors" - | "indices"; + "positions" | "normals" | "tangents" | "uvs" | "colors" | "indices"; export interface MeshOptions extends NodeOptions { geometryId?: number; material?: PBRMaterial; @@ -167,8 +167,10 @@ declare module "@yawn/handles" { ): Promise; } - export interface CubeOptions - extends Omit { + export interface CubeOptions extends Omit< + MeshOptions, + "geometryId" | "vertexData" + > { size?: number; width?: number; height?: number; @@ -177,16 +179,20 @@ declare module "@yawn/handles" { export class Cube extends Mesh { constructor(scene: Scene, options?: CubeOptions); } - export interface IcosphereOptions - extends Omit { + export interface IcosphereOptions extends Omit< + MeshOptions, + "geometryId" | "vertexData" + > { radius?: number; subdivisions?: number; } export class Icosphere extends Mesh { constructor(scene: Scene, options?: IcosphereOptions); } - export interface PlaneOptions - extends Omit { + export interface PlaneOptions extends Omit< + MeshOptions, + "geometryId" | "vertexData" + > { size?: number; width?: number; depth?: number; @@ -194,8 +200,10 @@ declare module "@yawn/handles" { export class Plane extends Mesh { constructor(scene: Scene, options?: PlaneOptions); } - export interface SphereOptions - extends Omit { + export interface SphereOptions extends Omit< + MeshOptions, + "geometryId" | "vertexData" + > { radius?: number; segments?: number; rings?: number; @@ -267,9 +275,13 @@ declare module "@yawn/handles" { intensity?: number; } export class PointLight extends Node { - constructor(scene: Scene, options?: LightOptions & { range?: number }); + constructor( + scene: Scene, + options?: LightOptions & { range?: number; radius?: number }, + ); intensity: number; range: number; + radius: number; } export class AmbientLight extends Node { constructor(scene: Scene, options?: LightOptions); diff --git a/server/web/playground.html b/server/web/playground.html index c2cd484..0beadad 100644 --- a/server/web/playground.html +++ b/server/web/playground.html @@ -16,7 +16,8 @@ { "imports": { "@yawn/core": "/pkg/core.js", - "@yawn/handles": "/pkg/handles.js" + "@yawn/handles": "/pkg/handles.js", + "@yawn/fxnode-rg": "/pkg/fxnode-rg.js" } } @@ -64,19 +65,22 @@
- - - +
- TypeScript + TypeScript Starting language service…
@@ -95,19 +99,100 @@
-
-
- +
-
+
Loading TypeScript editor…
+