Expand render graph playground editing
Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -39,6 +39,33 @@ const dataOutput: FxNodeSocketDefinition = {
|
|||||||
showValue: false,
|
showValue: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const physicalOutput = (
|
||||||
|
title: string,
|
||||||
|
type: "resource" | "pipeline" | "pass",
|
||||||
|
): FxNodeSocketDefinition => ({
|
||||||
|
title,
|
||||||
|
direction: "output",
|
||||||
|
type,
|
||||||
|
maxIncomingLinks: 0,
|
||||||
|
visible: true,
|
||||||
|
value: null,
|
||||||
|
showValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const physicalInput = (
|
||||||
|
title: string,
|
||||||
|
type: "resource" | "pipeline" | "pass",
|
||||||
|
maxIncomingLinks = 1,
|
||||||
|
): FxNodeSocketDefinition => ({
|
||||||
|
title,
|
||||||
|
direction: "input",
|
||||||
|
type,
|
||||||
|
maxIncomingLinks,
|
||||||
|
visible: true,
|
||||||
|
value: null,
|
||||||
|
showValue: false,
|
||||||
|
});
|
||||||
|
|
||||||
function stageNode(
|
function stageNode(
|
||||||
title: string,
|
title: string,
|
||||||
style: string,
|
style: string,
|
||||||
@@ -100,6 +127,45 @@ const stringParameter = (value: string, values?: readonly string[]) => ({
|
|||||||
...(values ? { enum: values } : {}),
|
...(values ? { enum: values } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function physicalNode(
|
||||||
|
title: string,
|
||||||
|
style: string,
|
||||||
|
sockets: FxNodeDefinition["sockets"],
|
||||||
|
declaration: object,
|
||||||
|
code?: string,
|
||||||
|
): FxNodeDefinition {
|
||||||
|
const definition = stageNode(
|
||||||
|
title,
|
||||||
|
style,
|
||||||
|
{
|
||||||
|
details: stringParameter("Physical render graph declaration"),
|
||||||
|
declaration: stringParameter(JSON.stringify(declaration)),
|
||||||
|
...(code === undefined ? {} : { code: stringParameter(code) }),
|
||||||
|
},
|
||||||
|
sockets,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
ui: [
|
||||||
|
{ kind: "parameter", parameter: "details", title: "Details" },
|
||||||
|
{ kind: "hidden", target: "parameter", parameter: "declaration" },
|
||||||
|
...(code === undefined
|
||||||
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
kind: "hidden" as const,
|
||||||
|
target: "parameter" as const,
|
||||||
|
parameter: "code",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
...Object.keys(sockets).map((socket) => ({
|
||||||
|
kind: "socket" as const,
|
||||||
|
socket,
|
||||||
|
})),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const nodes = {
|
const nodes = {
|
||||||
"yawn.rg.input.scene": stageNode(
|
"yawn.rg.input.scene": stageNode(
|
||||||
"Scene",
|
"Scene",
|
||||||
@@ -126,6 +192,92 @@ const nodes = {
|
|||||||
{ data: dataOutput },
|
{ data: dataOutput },
|
||||||
),
|
),
|
||||||
|
|
||||||
|
"yawn.rg.physical.frame-in": physicalNode(
|
||||||
|
"Frame In",
|
||||||
|
"frame",
|
||||||
|
{ frame: physicalOutput("Frame", "pass") },
|
||||||
|
{ kind: "frame-in" },
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.frame-out": physicalNode(
|
||||||
|
"Frame Out",
|
||||||
|
"frame",
|
||||||
|
{ after: physicalInput("Final passes", "pass", 64) },
|
||||||
|
{ kind: "frame-out" },
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.buffer": physicalNode(
|
||||||
|
"Buffer",
|
||||||
|
"buffer",
|
||||||
|
{ resource: physicalOutput("Buffer", "resource") },
|
||||||
|
{ id: "buffer", array: "signals", usage: ["storage"] },
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.texture": physicalNode(
|
||||||
|
"Texture",
|
||||||
|
"texture",
|
||||||
|
{ resource: physicalOutput("Texture", "resource") },
|
||||||
|
{
|
||||||
|
id: "texture",
|
||||||
|
size: ["canvas", "canvas", 1],
|
||||||
|
format: "rgba8unorm",
|
||||||
|
usage: ["render", "sampled"],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.sampler": physicalNode(
|
||||||
|
"Sampler",
|
||||||
|
"sampler",
|
||||||
|
{ resource: physicalOutput("Sampler", "resource") },
|
||||||
|
{ id: "sampler", magFilter: "linear", minFilter: "linear" },
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.external": physicalNode(
|
||||||
|
"External Resource",
|
||||||
|
"resource",
|
||||||
|
{ resource: physicalOutput("Resource", "resource") },
|
||||||
|
{ id: "external", external: true },
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.render-pipeline": physicalNode(
|
||||||
|
"Render Pipeline",
|
||||||
|
"pipeline",
|
||||||
|
{ pipeline: physicalOutput("Render pipeline", "pipeline") },
|
||||||
|
{
|
||||||
|
id: "render-pipeline",
|
||||||
|
vertex: { entry: "vertex", buffers: [] },
|
||||||
|
fragment: { entry: "fragment", targets: [{ format: "canvas" }] },
|
||||||
|
},
|
||||||
|
"@vertex fn vertex(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> { return vec4<f32>(0.0); }\n@fragment fn fragment() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }",
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.compute-pipeline": physicalNode(
|
||||||
|
"Compute Pipeline",
|
||||||
|
"pipeline",
|
||||||
|
{ pipeline: physicalOutput("Compute pipeline", "pipeline") },
|
||||||
|
{ id: "compute-pipeline", entry: "main" },
|
||||||
|
"@compute @workgroup_size(1) fn main() {}",
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.render-pass": physicalNode(
|
||||||
|
"Render Pass",
|
||||||
|
"pass",
|
||||||
|
{
|
||||||
|
after: physicalInput("After", "pass", 64),
|
||||||
|
pipeline: physicalInput("Pipeline", "pipeline"),
|
||||||
|
bindings: physicalInput("Bindings", "resource", 128),
|
||||||
|
color: physicalInput("Color attachments", "resource", 16),
|
||||||
|
depth: physicalInput("Depth attachment", "resource"),
|
||||||
|
vertex: physicalInput("Vertex buffers", "resource", 32),
|
||||||
|
index: physicalInput("Index buffer", "resource"),
|
||||||
|
pass: physicalOutput("Pass", "pass"),
|
||||||
|
},
|
||||||
|
{ id: "render-pass", type: "render", draw: { vertices: 3 } },
|
||||||
|
),
|
||||||
|
"yawn.rg.physical.compute-pass": physicalNode(
|
||||||
|
"Compute Pass",
|
||||||
|
"pass",
|
||||||
|
{
|
||||||
|
after: physicalInput("After", "pass", 64),
|
||||||
|
pipeline: physicalInput("Pipeline", "pipeline"),
|
||||||
|
bindings: physicalInput("Bindings", "resource", 128),
|
||||||
|
pass: physicalOutput("Pass", "pass"),
|
||||||
|
},
|
||||||
|
{ id: "compute-pass", type: "compute", dispatch: [1, 1, 1] },
|
||||||
|
),
|
||||||
|
|
||||||
"yawn.rg.mode.beauty": stageNode("Standard / PBR", "geometry"),
|
"yawn.rg.mode.beauty": stageNode("Standard / PBR", "geometry"),
|
||||||
"yawn.rg.mode.normals": stageNode("World Normals", "geometry"),
|
"yawn.rg.mode.normals": stageNode("World Normals", "geometry"),
|
||||||
"yawn.rg.mode.depth": stageNode("Camera Depth", "geometry", {
|
"yawn.rg.mode.depth": stageNode("Camera Depth", "geometry", {
|
||||||
@@ -391,11 +543,22 @@ const nodes = {
|
|||||||
export const renderGraphComposition = {
|
export const renderGraphComposition = {
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
id: "yawn.render-graph",
|
id: "yawn.render-graph",
|
||||||
version: 2,
|
version: 3,
|
||||||
compatibility: { wildcardInputTypes: [] },
|
compatibility: { wildcardInputTypes: [] },
|
||||||
socketTypes: {
|
socketTypes: {
|
||||||
surface: { title: "Surface", color: "#43b9ed", acceptsFrom: ["surface"] },
|
surface: { title: "Surface", color: "#43b9ed", acceptsFrom: ["surface"] },
|
||||||
data: { title: "Data", color: "#a8b7c5", acceptsFrom: ["data"] },
|
data: { title: "Data", color: "#a8b7c5", acceptsFrom: ["data"] },
|
||||||
|
resource: {
|
||||||
|
title: "Resource",
|
||||||
|
color: "#67c7ac",
|
||||||
|
acceptsFrom: ["resource"],
|
||||||
|
},
|
||||||
|
pipeline: {
|
||||||
|
title: "Pipeline",
|
||||||
|
color: "#d69bea",
|
||||||
|
acceptsFrom: ["pipeline"],
|
||||||
|
},
|
||||||
|
pass: { title: "Pass", color: "#f0aa66", acceptsFrom: ["pass"] },
|
||||||
},
|
},
|
||||||
theme: {
|
theme: {
|
||||||
background: "#081522",
|
background: "#081522",
|
||||||
@@ -431,6 +594,13 @@ export const renderGraphComposition = {
|
|||||||
},
|
},
|
||||||
nodeStyles: {
|
nodeStyles: {
|
||||||
input: { header: "#2676a5" },
|
input: { header: "#2676a5" },
|
||||||
|
frame: { header: "#247f91" },
|
||||||
|
buffer: { header: "#287c68" },
|
||||||
|
texture: { header: "#277493" },
|
||||||
|
sampler: { header: "#426f8c" },
|
||||||
|
resource: { header: "#4c6678" },
|
||||||
|
pipeline: { header: "#77518b" },
|
||||||
|
pass: { header: "#a36632" },
|
||||||
geometry: { header: "#3d7797" },
|
geometry: { header: "#3d7797" },
|
||||||
lighting: { header: "#a27332" },
|
lighting: { header: "#a27332" },
|
||||||
post: { header: "#7253a1" },
|
post: { header: "#7253a1" },
|
||||||
@@ -456,6 +626,87 @@ export interface RenderGraphCatalogGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const renderGraphNodeGroups: readonly RenderGraphCatalogGroup[] = [
|
export const renderGraphNodeGroups: readonly RenderGraphCatalogGroup[] = [
|
||||||
|
{
|
||||||
|
id: "physical-resources",
|
||||||
|
title: "Physical resources",
|
||||||
|
icon: "▦",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.buffer",
|
||||||
|
title: "Buffer",
|
||||||
|
description: "Shared row-backed GPU buffer declaration",
|
||||||
|
keywords: "storage uniform vertex index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.texture",
|
||||||
|
title: "Texture",
|
||||||
|
description: "Sampled, storage, render, or depth texture",
|
||||||
|
keywords: "attachment image target",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.sampler",
|
||||||
|
title: "Sampler",
|
||||||
|
description: "Texture sampling and addressing state",
|
||||||
|
keywords: "filter anisotropy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.external",
|
||||||
|
title: "External Resource",
|
||||||
|
description: "Canvas or application-supplied GPU resource",
|
||||||
|
keywords: "surface swap chain imported",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "physical-pipelines",
|
||||||
|
title: "Physical pipelines",
|
||||||
|
icon: "⌁",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.render-pipeline",
|
||||||
|
title: "Render Pipeline",
|
||||||
|
description: "Vertex/fragment WGSL and fixed-function state",
|
||||||
|
keywords: "shader wgsl primitive blend depth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.compute-pipeline",
|
||||||
|
title: "Compute Pipeline",
|
||||||
|
description: "Compute WGSL and entry point",
|
||||||
|
keywords: "shader wgsl dispatch",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "physical-passes",
|
||||||
|
title: "Physical passes",
|
||||||
|
icon: "▶",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.render-pass",
|
||||||
|
title: "Render Pass",
|
||||||
|
description: "Bindings, attachments, buffers, and draw call",
|
||||||
|
keywords: "pipeline color depth vertex index",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.compute-pass",
|
||||||
|
title: "Compute Pass",
|
||||||
|
description: "Bindings, dependencies, and dispatch dimensions",
|
||||||
|
keywords: "pipeline workgroups",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.frame-in",
|
||||||
|
title: "Frame In",
|
||||||
|
description: "Start of synchronized work for one frame",
|
||||||
|
keywords: "boundary begin source",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
typeId: "yawn.rg.physical.frame-out",
|
||||||
|
title: "Frame Out",
|
||||||
|
description: "Submission boundary for terminal passes",
|
||||||
|
keywords: "boundary end present",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "inputs",
|
id: "inputs",
|
||||||
title: "Inputs",
|
title: "Inputs",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
GraphSnapshot,
|
GraphSnapshot,
|
||||||
ParameterValue,
|
ParameterValue,
|
||||||
} from "fxnode";
|
} from "fxnode";
|
||||||
|
import { isPhysicalRenderGraph, reconstructRenderGraph } from "./physical";
|
||||||
|
|
||||||
type PlainObject = Record<string, any>;
|
type PlainObject = Record<string, any>;
|
||||||
type GraphSource = GraphLayoutV2 | GraphSnapshot;
|
type GraphSource = GraphLayoutV2 | GraphSnapshot;
|
||||||
@@ -113,22 +114,38 @@ function activeChain(layout: GraphSource): any[] {
|
|||||||
return chain;
|
return chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
function debugShader(mode: string, detailed: boolean, node: any): string {
|
function passResources(pass: PlainObject): string[] {
|
||||||
|
return [
|
||||||
|
...(pass.bindings ?? []),
|
||||||
|
...(pass.color ?? []),
|
||||||
|
...(pass.vertexBuffers ?? []),
|
||||||
|
...(pass.depth ? [pass.depth] : []),
|
||||||
|
...(pass.indexBuffer ? [pass.indexBuffer] : []),
|
||||||
|
]
|
||||||
|
.map((item: PlainObject) => item?.resource)
|
||||||
|
.filter(
|
||||||
|
(resource: unknown): resource is string => typeof resource === "string",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function debugShader(mode: string, normalBuffer: boolean, node: any): string {
|
||||||
const contrast = Number(value(node, "contrast", 1.35));
|
const contrast = Number(value(node, "contrast", 1.35));
|
||||||
const clay = value<readonly number[]>(node, "color", [0.36, 0.68, 0.9, 1]);
|
const clay = value<readonly number[]>(node, "color", [0.36, 0.68, 0.9, 1]);
|
||||||
|
const usesNormals = mode === "normals" || mode === "unlit";
|
||||||
|
const usesObjectId = mode === "object-id";
|
||||||
const fragment =
|
const fragment =
|
||||||
mode === "normals"
|
mode === "normals"
|
||||||
? "return vec4<f32>(normalize(input.normal) * 0.5 + 0.5, 1.0);"
|
? "return vec4<f32>(normalize(input.normal) * 0.5 + 0.5, 1.0);"
|
||||||
: mode === "depth"
|
: mode === "depth"
|
||||||
? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${Math.max(0.01, contrast).toFixed(4)}); return vec4<f32>(vec3<f32>(depth), 1.0);`
|
? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${(Math.max(0.01, contrast) * 0.12).toFixed(4)}); return vec4<f32>(vec3<f32>(depth), 1.0);`
|
||||||
: mode === "unlit"
|
: mode === "unlit"
|
||||||
? `let base = vec3<f32>(${Number(clay[0] ?? 0.36).toFixed(5)}, ${Number(clay[1] ?? 0.68).toFixed(5)}, ${Number(clay[2] ?? 0.9).toFixed(5)}); let light = 0.32 + max(dot(normalize(input.normal), normalize(vec3<f32>(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4<f32>(base * light, 1.0);`
|
? `let base = vec3<f32>(${Number(clay[0] ?? 0.36).toFixed(5)}, ${Number(clay[1] ?? 0.68).toFixed(5)}, ${Number(clay[2] ?? 0.9).toFixed(5)}); let light = 0.32 + max(dot(normalize(input.normal), normalize(vec3<f32>(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4<f32>(base * light, 1.0);`
|
||||||
: "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3<f32>(0.0, 2.1, 4.2) + seed * 6.28318); return vec4<f32>(color, 1.0);";
|
: "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3<f32>(0.0, 2.1, 4.2) + seed * 6.28318); return vec4<f32>(color, 1.0);";
|
||||||
return `
|
return `
|
||||||
struct VertexOutput {
|
struct VertexOutput {
|
||||||
@invariant @builtin(position) position: vec4<f32>,
|
@invariant @builtin(position) position: vec4<f32>,
|
||||||
@location(0) normal: vec3<f32>,
|
${usesNormals ? "@location(0) normal: vec3<f32>," : ""}
|
||||||
@location(1) @interpolate(flat) objectId: u32,
|
${usesObjectId ? `@location(${usesNormals ? 1 : 0}) @interpolate(flat) objectId: u32,` : ""}
|
||||||
}
|
}
|
||||||
@group(0) @binding(0) var<storage, read> positions: array<vec4<f32>>;
|
@group(0) @binding(0) var<storage, read> positions: array<vec4<f32>>;
|
||||||
@group(0) @binding(1) var<storage, read> rotors: array<vec4<f32>>;
|
@group(0) @binding(1) var<storage, read> rotors: array<vec4<f32>>;
|
||||||
@@ -140,7 +157,7 @@ fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
|
|||||||
}
|
}
|
||||||
@vertex fn vertex(
|
@vertex fn vertex(
|
||||||
@location(0) point: vec3<f32>,
|
@location(0) point: vec3<f32>,
|
||||||
${detailed ? "@location(1) localNormal: vec3<f32>," : ""}
|
${normalBuffer ? "@location(1) localNormal: vec3<f32>," : ""}
|
||||||
@builtin(instance_index) packed: u32,
|
@builtin(instance_index) packed: u32,
|
||||||
) -> VertexOutput {
|
) -> VertexOutput {
|
||||||
let instance = packed & 65535u;
|
let instance = packed & 65535u;
|
||||||
@@ -152,8 +169,8 @@ fn rotate(rotor: vec4<f32>, point: vec3<f32>) -> vec3<f32> {
|
|||||||
dot(cameraMatrices[2], world), dot(cameraMatrices[3], world));
|
dot(cameraMatrices[2], world), dot(cameraMatrices[3], world));
|
||||||
if (cameraMatrices[4].w == 0.0) { output.position = world; }
|
if (cameraMatrices[4].w == 0.0) { output.position = world; }
|
||||||
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), output.position, meshInfo[instance * 4u + 2u] != 0u);
|
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), output.position, meshInfo[instance * 4u + 2u] != 0u);
|
||||||
output.normal = normalize(rotate(rotors[instance], ${detailed ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));
|
${usesNormals ? `output.normal = normalize(rotate(rotors[instance], ${normalBuffer ? "localNormal / scales[instance].xyz" : "vec3<f32>(0.0, 0.0, 1.0)"}));` : ""}
|
||||||
output.objectId = instance;
|
${usesObjectId ? "output.objectId = instance;" : ""}
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
@@ -170,6 +187,10 @@ function applyDebugMode(graph: PlainObject, node: any): void {
|
|||||||
const byId = new Map(
|
const byId = new Map(
|
||||||
renderPipelines.map((pipeline: any) => [pipeline.id, pipeline]),
|
renderPipelines.map((pipeline: any) => [pipeline.id, pipeline]),
|
||||||
);
|
);
|
||||||
|
const passesById = new Map(
|
||||||
|
graph.passes.map((pass: PlainObject) => [pass.id, pass]),
|
||||||
|
);
|
||||||
|
const detachedComputePasses = new Set<string>();
|
||||||
const generated = new Map<string, string>();
|
const generated = new Map<string, string>();
|
||||||
for (const pass of graph.passes) {
|
for (const pass of graph.passes) {
|
||||||
if (
|
if (
|
||||||
@@ -187,19 +208,20 @@ function applyDebugMode(graph: PlainObject, node: any): void {
|
|||||||
const format = original.fragment?.targets?.[0]?.format;
|
const format = original.fragment?.targets?.[0]?.format;
|
||||||
if (typeof format !== "string")
|
if (typeof format !== "string")
|
||||||
throw new Error(`Forward pipeline ${pass.pipeline} has no color target`);
|
throw new Error(`Forward pipeline ${pass.pipeline} has no color target`);
|
||||||
const key = `${mode}:${detailed}:${format}`;
|
const normalBuffer = (mode === "normals" || mode === "unlit") && detailed;
|
||||||
|
const key = `${mode}:${normalBuffer}:${format}`;
|
||||||
let id = generated.get(key);
|
let id = generated.get(key);
|
||||||
if (!id) {
|
if (!id) {
|
||||||
id = `rg-${mode}-${detailed ? "detailed" : "basic"}`;
|
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}`;
|
||||||
let suffix = 1;
|
let suffix = 1;
|
||||||
while (byId.has(id))
|
while (byId.has(id))
|
||||||
id = `rg-${mode}-${detailed ? "detailed" : "basic"}-${suffix++}`;
|
id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}-${suffix++}`;
|
||||||
const pipeline = {
|
const pipeline = {
|
||||||
id,
|
id,
|
||||||
code: debugShader(mode, detailed, node),
|
code: debugShader(mode, normalBuffer, node),
|
||||||
vertex: {
|
vertex: {
|
||||||
entry: "vertex",
|
entry: "vertex",
|
||||||
buffers: original.vertex.buffers.slice(0, detailed ? 2 : 1),
|
buffers: original.vertex.buffers.slice(0, normalBuffer ? 2 : 1),
|
||||||
},
|
},
|
||||||
fragment: { entry: "fragment", targets: [{ format }] },
|
fragment: { entry: "fragment", targets: [{ format }] },
|
||||||
depthStencil: original.depthStencil ?? null,
|
depthStencil: original.depthStencil ?? null,
|
||||||
@@ -223,9 +245,37 @@ function applyDebugMode(graph: PlainObject, node: any): void {
|
|||||||
resource,
|
resource,
|
||||||
}));
|
}));
|
||||||
pass.vertexBuffers = (pass.vertexBuffers ?? []).filter(
|
pass.vertexBuffers = (pass.vertexBuffers ?? []).filter(
|
||||||
(binding: any) => binding.slot === 0 || (detailed && binding.slot === 1),
|
(binding: any) =>
|
||||||
|
binding.slot === 0 || (normalBuffer && binding.slot === 1),
|
||||||
);
|
);
|
||||||
|
const used = new Set(passResources(pass));
|
||||||
|
pass.after = (pass.after ?? []).filter((dependency: string) => {
|
||||||
|
const candidate = passesById.get(dependency);
|
||||||
|
if (
|
||||||
|
candidate?.type !== "compute" ||
|
||||||
|
passResources(candidate).some((resource) => used.has(resource))
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
detachedComputePasses.add(dependency);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
const retainedDependencies = new Set(
|
||||||
|
graph.passes.flatMap((pass: PlainObject) => pass.after ?? []),
|
||||||
|
);
|
||||||
|
graph.passes = graph.passes.filter((pass: PlainObject) => {
|
||||||
|
if (
|
||||||
|
!detachedComputePasses.has(pass.id) ||
|
||||||
|
retainedDependencies.has(pass.id)
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
const resources = new Set(passResources(pass));
|
||||||
|
return graph.passes.some(
|
||||||
|
(other: PlainObject) =>
|
||||||
|
other !== pass &&
|
||||||
|
passResources(other).some((resource) => resources.has(resource)),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rgb = readonly [number, number, number];
|
type Rgb = readonly [number, number, number];
|
||||||
@@ -496,9 +546,11 @@ function applyEffects(graph: PlainObject, effects: any[]): void {
|
|||||||
let after = Array.isArray(present.after) ? [...present.after] : [];
|
let after = Array.isArray(present.after) ? [...present.after] : [];
|
||||||
for (const [index, node] of effects.entries()) {
|
for (const [index, node] of effects.entries()) {
|
||||||
const suffix = `${index}-${String(node.id).replace(/[^a-z0-9-]/gi, "-")}`;
|
const suffix = `${index}-${String(node.id).replace(/[^a-z0-9-]/gi, "-")}`;
|
||||||
const output = `rg-effect-${suffix}`;
|
const final = index === effects.length - 1;
|
||||||
|
const output = final ? "canvas" : `rg-effect-${suffix}`;
|
||||||
const pipeline = `rg-effect-pipeline-${suffix}`;
|
const pipeline = `rg-effect-pipeline-${suffix}`;
|
||||||
const pass = `rg-effect-pass-${suffix}`;
|
const pass = `rg-effect-pass-${suffix}`;
|
||||||
|
if (!final)
|
||||||
textures.push({
|
textures.push({
|
||||||
id: output,
|
id: output,
|
||||||
format: sourceTexture.format,
|
format: sourceTexture.format,
|
||||||
@@ -512,7 +564,7 @@ function applyEffects(graph: PlainObject, effects: any[]): void {
|
|||||||
vertex: { entry: "vertex", buffers: [] },
|
vertex: { entry: "vertex", buffers: [] },
|
||||||
fragment: {
|
fragment: {
|
||||||
entry: "fragment",
|
entry: "fragment",
|
||||||
targets: [{ format: sourceTexture.format }],
|
targets: [{ format: final ? "canvas" : sourceTexture.format }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
graph.passes.push({
|
graph.passes.push({
|
||||||
@@ -530,28 +582,101 @@ function applyEffects(graph: PlainObject, effects: any[]): void {
|
|||||||
input = output;
|
input = output;
|
||||||
after = [pass];
|
after = [pass];
|
||||||
}
|
}
|
||||||
sourceBinding.resource = input;
|
graph.passes = graph.passes.filter((pass: PlainObject) => pass !== present);
|
||||||
present.after = after;
|
}
|
||||||
|
|
||||||
|
function routeDebugModeToCanvas(graph: PlainObject, mode: string): void {
|
||||||
|
const present = graph.passes?.find(
|
||||||
|
(pass: PlainObject) =>
|
||||||
|
pass.type === "render" &&
|
||||||
|
pass.color?.some(
|
||||||
|
(attachment: PlainObject) => attachment.resource === "canvas",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const source = present?.bindings?.find(
|
||||||
|
(binding: PlainObject) => binding.group === 0 && binding.binding === 0,
|
||||||
|
)?.resource;
|
||||||
|
if (!present || typeof source !== "string")
|
||||||
|
throw new Error("Base render graph has no sampled presentation pass");
|
||||||
|
const producers = graph.passes.filter(
|
||||||
|
(pass: PlainObject) =>
|
||||||
|
pass.type === "render" &&
|
||||||
|
pass.color?.some(
|
||||||
|
(attachment: PlainObject) => attachment.resource === source,
|
||||||
|
) &&
|
||||||
|
String(pass.pipeline).startsWith(`rg-${mode}-`),
|
||||||
|
);
|
||||||
|
if (!producers.length)
|
||||||
|
throw new Error(`Debug mode ${mode} has no render passes`);
|
||||||
|
const pipelines = new Map(
|
||||||
|
(graph.pipelines?.render ?? []).map((pipeline: PlainObject) => [
|
||||||
|
pipeline.id,
|
||||||
|
pipeline,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
for (const pass of producers) {
|
||||||
|
for (const attachment of pass.color)
|
||||||
|
if (attachment.resource === source) attachment.resource = "canvas";
|
||||||
|
const pipeline = pipelines.get(pass.pipeline);
|
||||||
|
const targets = pipeline?.fragment?.targets;
|
||||||
|
if (!Array.isArray(targets) || !targets.length)
|
||||||
|
throw new Error(`Debug pipeline ${String(pass.pipeline)} has no target`);
|
||||||
|
targets[0].format = "canvas";
|
||||||
|
}
|
||||||
|
graph.passes = graph.passes.filter((pass: PlainObject) => pass !== present);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneDeclarations(graph: PlainObject): void {
|
||||||
|
const usedResources = new Set(
|
||||||
|
(graph.passes ?? []).flatMap((pass: PlainObject) => passResources(pass)),
|
||||||
|
);
|
||||||
|
for (const kind of ["buffers", "textures", "samplers"])
|
||||||
|
if (Array.isArray(graph.resources?.[kind]))
|
||||||
|
graph.resources[kind] = graph.resources[kind].filter(
|
||||||
|
(resource: PlainObject) => usedResources.has(resource.id),
|
||||||
|
);
|
||||||
|
const renderPipelines = new Set(
|
||||||
|
(graph.passes ?? [])
|
||||||
|
.filter((pass: PlainObject) => pass.type === "render")
|
||||||
|
.map((pass: PlainObject) => pass.pipeline),
|
||||||
|
);
|
||||||
|
const computePipelines = new Set(
|
||||||
|
(graph.passes ?? [])
|
||||||
|
.filter((pass: PlainObject) => pass.type === "compute")
|
||||||
|
.map((pass: PlainObject) => pass.pipeline),
|
||||||
|
);
|
||||||
|
if (Array.isArray(graph.pipelines?.render))
|
||||||
|
graph.pipelines.render = graph.pipelines.render.filter(
|
||||||
|
(pipeline: PlainObject) => renderPipelines.has(pipeline.id),
|
||||||
|
);
|
||||||
|
if (Array.isArray(graph.pipelines?.compute))
|
||||||
|
graph.pipelines.compute = graph.pipelines.compute.filter(
|
||||||
|
(pipeline: PlainObject) => computePipelines.has(pipeline.id),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compiles an FxNode loadout over a scene-authored graph and returns the exact
|
* Resolves an FxNode loadout into the concrete graph that Yawn will compile.
|
||||||
* S-expression consumed by `YawnCore.compileGraph`.
|
|
||||||
*/
|
*/
|
||||||
export function compileFxNodeRenderGraph(
|
export function buildFxNodeRenderGraph(
|
||||||
layout: GraphSource,
|
layout: GraphSource,
|
||||||
baseGraph: object,
|
baseGraph: object,
|
||||||
): string {
|
): object {
|
||||||
if (!plainRecord(baseGraph))
|
if (!plainRecord(baseGraph))
|
||||||
throw new TypeError("Base render graph must be plain data");
|
throw new TypeError("Base render graph must be plain data");
|
||||||
|
if (isPhysicalRenderGraph(layout)) return reconstructRenderGraph(layout);
|
||||||
const graph = structuredClone(baseGraph) as PlainObject;
|
const graph = structuredClone(baseGraph) as PlainObject;
|
||||||
const chain = activeChain(layout);
|
const chain = activeChain(layout);
|
||||||
const mode = chain.findLast((node) => modeTypes.has(String(node.typeId)));
|
const mode = chain.findLast((node) => modeTypes.has(String(node.typeId)));
|
||||||
if (mode) applyDebugMode(graph, mode);
|
if (mode) applyDebugMode(graph, mode);
|
||||||
applyEffects(
|
const effects = chain.filter((node) => effectTypes.has(String(node.typeId)));
|
||||||
|
if (mode && mode.typeId !== "yawn.rg.mode.beauty" && effects.length === 0)
|
||||||
|
routeDebugModeToCanvas(
|
||||||
graph,
|
graph,
|
||||||
chain.filter((node) => effectTypes.has(String(node.typeId))),
|
String(mode.typeId).slice("yawn.rg.mode.".length),
|
||||||
);
|
);
|
||||||
|
else applyEffects(graph, effects);
|
||||||
|
pruneDeclarations(graph);
|
||||||
const suffix = chain
|
const suffix = chain
|
||||||
.filter(
|
.filter(
|
||||||
(node) =>
|
(node) =>
|
||||||
@@ -562,5 +687,16 @@ export function compileFxNodeRenderGraph(
|
|||||||
.map((stage) => (stage === "beauty" ? "standard" : stage))
|
.map((stage) => (stage === "beauty" ? "standard" : stage))
|
||||||
.join("-");
|
.join("-");
|
||||||
graph.id = `${String(graph.id || "scene")}-fxnode-${suffix || "standard"}`;
|
graph.id = `${String(graph.id || "scene")}-fxnode-${suffix || "standard"}`;
|
||||||
return encodeRenderGraph(graph);
|
return graph;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles an FxNode loadout over a scene-authored graph and returns the exact
|
||||||
|
* S-expression consumed by `YawnCore.compileGraph`.
|
||||||
|
*/
|
||||||
|
export function compileFxNodeRenderGraph(
|
||||||
|
layout: GraphSource,
|
||||||
|
baseGraph: object,
|
||||||
|
): string {
|
||||||
|
return encodeRenderGraph(buildFxNodeRenderGraph(layout, baseGraph));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type FxNode,
|
type FxNode,
|
||||||
type FxNodeView,
|
type FxNodeView,
|
||||||
type GraphLayoutV2,
|
type GraphLayoutV2,
|
||||||
|
type ParameterValue,
|
||||||
type Vec2,
|
type Vec2,
|
||||||
} from "fxnode";
|
} from "fxnode";
|
||||||
import { renderGraphComposition, renderGraphPresets } from "./catalog";
|
import { renderGraphComposition, renderGraphPresets } from "./catalog";
|
||||||
@@ -17,6 +18,7 @@ export interface RenderGraphEditorOptions {
|
|||||||
readonly initialLayout?: GraphLayoutV2;
|
readonly initialLayout?: GraphLayoutV2;
|
||||||
readonly workerUrl: string | URL;
|
readonly workerUrl: string | URL;
|
||||||
readonly onChange?: () => void;
|
readonly onChange?: () => void;
|
||||||
|
readonly onSelectionChange?: (nodeIds: readonly string[]) => void;
|
||||||
readonly onAddNodeMenu?: (request: RenderGraphAddNodeRequest) => void;
|
readonly onAddNodeMenu?: (request: RenderGraphAddNodeRequest) => void;
|
||||||
readonly onError?: (error: unknown) => void;
|
readonly onError?: (error: unknown) => void;
|
||||||
}
|
}
|
||||||
@@ -24,6 +26,11 @@ export interface RenderGraphEditorOptions {
|
|||||||
export interface RenderGraphEditor {
|
export interface RenderGraphEditor {
|
||||||
load(layout: GraphLayoutV2): Promise<void>;
|
load(layout: GraphLayoutV2): Promise<void>;
|
||||||
save(): Promise<GraphLayoutV2>;
|
save(): Promise<GraphLayoutV2>;
|
||||||
|
setParameter(
|
||||||
|
nodeId: string,
|
||||||
|
key: string,
|
||||||
|
value: ParameterValue,
|
||||||
|
): Promise<void>;
|
||||||
syncViewport(): Promise<void>;
|
syncViewport(): Promise<void>;
|
||||||
whenRendered(): Promise<void>;
|
whenRendered(): Promise<void>;
|
||||||
destroy(): Promise<void>;
|
destroy(): Promise<void>;
|
||||||
@@ -78,6 +85,7 @@ export async function createRenderGraphEditor({
|
|||||||
initialLayout = renderGraphPresets[0]!.layout,
|
initialLayout = renderGraphPresets[0]!.layout,
|
||||||
workerUrl,
|
workerUrl,
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
|
onSelectionChange = () => {},
|
||||||
onAddNodeMenu = () => {},
|
onAddNodeMenu = () => {},
|
||||||
onError = console.error,
|
onError = console.error,
|
||||||
}: RenderGraphEditorOptions): Promise<RenderGraphEditor> {
|
}: RenderGraphEditorOptions): Promise<RenderGraphEditor> {
|
||||||
@@ -119,14 +127,20 @@ export async function createRenderGraphEditor({
|
|||||||
await root.load(initialLayout);
|
await root.load(initialLayout);
|
||||||
const initialViewport = viewport(canvas);
|
const initialViewport = viewport(canvas);
|
||||||
sizeCanvas(canvas, initialViewport);
|
sizeCanvas(canvas, initialViewport);
|
||||||
|
const initialPhysical =
|
||||||
|
initialLayout.metadata.schema === "yawn.render-graph/v1";
|
||||||
view = await root.attachView({
|
view = await root.attachView({
|
||||||
canvas,
|
canvas,
|
||||||
viewport: initialViewport,
|
viewport: initialViewport,
|
||||||
initialCamera: {
|
initialCamera: {
|
||||||
center: { x: 0, y: 10 },
|
center: initialPhysical ? { x: 320, y: 300 } : { x: 0, y: 10 },
|
||||||
zoom:
|
zoom:
|
||||||
(canvas.ownerDocument.defaultView?.innerWidth ?? 1024) <= 800
|
(canvas.ownerDocument.defaultView?.innerWidth ?? 1024) <= 800
|
||||||
? 0.9
|
? initialPhysical
|
||||||
|
? 0.56
|
||||||
|
: 0.9
|
||||||
|
: initialPhysical
|
||||||
|
? 0.72
|
||||||
: 1.1,
|
: 1.1,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -267,20 +281,43 @@ export async function createRenderGraphEditor({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
subscriptions.push(root.onMutations(() => onChange()));
|
subscriptions.push(root.onMutations(() => onChange()));
|
||||||
await fitGraph();
|
let selectionSignature = "";
|
||||||
|
const publishSelection = () => {
|
||||||
|
const nodeIds = activeView.getHostSnapshot().selection.nodeIds;
|
||||||
|
const signature = JSON.stringify(nodeIds);
|
||||||
|
if (signature === selectionSignature) return;
|
||||||
|
selectionSignature = signature;
|
||||||
|
onSelectionChange([...nodeIds]);
|
||||||
|
};
|
||||||
|
subscriptions.push(activeView.subscribeHost(publishSelection));
|
||||||
|
publishSelection();
|
||||||
|
if (initialPhysical) await activeView.whenRendered();
|
||||||
|
else await fitGraph();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
async load(layout) {
|
async load(layout) {
|
||||||
if (destroyed || !root)
|
if (destroyed || !root)
|
||||||
throw new Error("Render graph editor is closed");
|
throw new Error("Render graph editor is closed");
|
||||||
await root.load(layout);
|
await root.load(layout);
|
||||||
await fitGraph();
|
if (layout.metadata.schema === "yawn.render-graph/v1")
|
||||||
|
await activeView.whenRendered();
|
||||||
|
else await fitGraph();
|
||||||
},
|
},
|
||||||
async save() {
|
async save() {
|
||||||
if (destroyed || !root)
|
if (destroyed || !root)
|
||||||
throw new Error("Render graph editor is closed");
|
throw new Error("Render graph editor is closed");
|
||||||
return root.save();
|
return root.save();
|
||||||
},
|
},
|
||||||
|
async setParameter(nodeId, key, value) {
|
||||||
|
if (destroyed || !root)
|
||||||
|
throw new Error("Render graph editor is closed");
|
||||||
|
await root.dispatch({
|
||||||
|
type: "node.parameter",
|
||||||
|
id: nodeId as never,
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
},
|
||||||
syncViewport,
|
syncViewport,
|
||||||
whenRendered: () => activeView.whenRendered(),
|
whenRendered: () => activeView.whenRendered(),
|
||||||
async destroy() {
|
async destroy() {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export * from "./catalog";
|
export * from "./catalog";
|
||||||
export * from "./compiler";
|
export * from "./compiler";
|
||||||
export * from "./editor";
|
export * from "./editor";
|
||||||
|
export * from "./physical";
|
||||||
|
|||||||
@@ -0,0 +1,688 @@
|
|||||||
|
import type {
|
||||||
|
GraphLayoutNodeV1,
|
||||||
|
GraphLayoutV2,
|
||||||
|
GraphLink,
|
||||||
|
GraphSnapshot,
|
||||||
|
JsonValue,
|
||||||
|
ParameterValue,
|
||||||
|
} from "fxnode";
|
||||||
|
import { renderGraphComposition } from "./catalog";
|
||||||
|
|
||||||
|
type PlainObject = Record<string, any>;
|
||||||
|
type GraphSource = GraphLayoutV2 | GraphSnapshot;
|
||||||
|
|
||||||
|
export const physicalRenderGraphSchema = "yawn.render-graph/v1";
|
||||||
|
|
||||||
|
const physicalTypes = {
|
||||||
|
frameIn: "yawn.rg.physical.frame-in",
|
||||||
|
frameOut: "yawn.rg.physical.frame-out",
|
||||||
|
buffer: "yawn.rg.physical.buffer",
|
||||||
|
texture: "yawn.rg.physical.texture",
|
||||||
|
sampler: "yawn.rg.physical.sampler",
|
||||||
|
external: "yawn.rg.physical.external",
|
||||||
|
renderPipeline: "yawn.rg.physical.render-pipeline",
|
||||||
|
computePipeline: "yawn.rg.physical.compute-pipeline",
|
||||||
|
renderPass: "yawn.rg.physical.render-pass",
|
||||||
|
computePass: "yawn.rg.physical.compute-pass",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
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 parameterString(node: any, key: string): string {
|
||||||
|
const parameter = node.parameters?.[key] as ParameterValue | undefined;
|
||||||
|
if (parameter?.kind !== "string")
|
||||||
|
throw new Error(
|
||||||
|
`${String(node.label || node.id)} has no ${key} declaration`,
|
||||||
|
);
|
||||||
|
return parameter.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function declaration(node: any): PlainObject {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(parameterString(node, "declaration"));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`${String(node.label || node.id)} has an invalid physical declaration`,
|
||||||
|
{ cause: error },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!plainRecord(parsed))
|
||||||
|
throw new Error(
|
||||||
|
`${String(node.label || node.id)} declaration must be an object`,
|
||||||
|
);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloned<T>(value: T): T {
|
||||||
|
return structuredClone(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withoutResource(value: PlainObject): PlainObject {
|
||||||
|
const result = cloned(value);
|
||||||
|
delete result.resource;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeSize(value: unknown): string {
|
||||||
|
return Array.isArray(value) ? value.join(" × ") : "automatic size";
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSummary(pass: PlainObject): string {
|
||||||
|
if (pass.type === "compute") {
|
||||||
|
const dispatch = Array.isArray(pass.dispatch)
|
||||||
|
? pass.dispatch.join(" × ")
|
||||||
|
: "1 × 1 × 1";
|
||||||
|
return `${dispatch} workgroups · ${(pass.bindings ?? []).length} bindings`;
|
||||||
|
}
|
||||||
|
const draw = plainRecord(pass.draw) ? pass.draw : {};
|
||||||
|
const count = Number(draw.indices || draw.vertices || 3);
|
||||||
|
const primitive = draw.indices ? "indices" : "vertices";
|
||||||
|
const instances = Number(draw.instances ?? 1);
|
||||||
|
return `${count} ${primitive} × ${instances} · ${(pass.bindings ?? []).length} bindings · ${(pass.color ?? []).length} color`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutNode(
|
||||||
|
id: string,
|
||||||
|
typeId: keyof typeof renderGraphComposition.nodes,
|
||||||
|
label: string,
|
||||||
|
details: string,
|
||||||
|
value: PlainObject,
|
||||||
|
position: { readonly x: number; readonly y: number },
|
||||||
|
code?: string,
|
||||||
|
): GraphLayoutNodeV1 {
|
||||||
|
const definition = renderGraphComposition.nodes[typeId];
|
||||||
|
const parameters = Object.fromEntries(
|
||||||
|
Object.entries(definition.parameters).map(([key, schema]) => [
|
||||||
|
key,
|
||||||
|
cloned(schema.default),
|
||||||
|
]),
|
||||||
|
) as Record<string, ParameterValue>;
|
||||||
|
parameters.details = { kind: "string", value: details };
|
||||||
|
parameters.declaration = { kind: "string", value: JSON.stringify(value) };
|
||||||
|
if (code !== undefined) parameters.code = { kind: "string", value: code };
|
||||||
|
const sockets = Object.entries(definition.sockets).map(([key, socket]) => ({
|
||||||
|
id: `${id}:${key}` as GraphLayoutNodeV1["sockets"][number]["id"],
|
||||||
|
key,
|
||||||
|
label: socket.title,
|
||||||
|
direction: socket.direction,
|
||||||
|
dataType: socket.type,
|
||||||
|
accepts:
|
||||||
|
socket.direction === "input"
|
||||||
|
? cloned(
|
||||||
|
renderGraphComposition.socketTypes[
|
||||||
|
socket.type as keyof typeof renderGraphComposition.socketTypes
|
||||||
|
].acceptsFrom,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
maxIncomingLinks: socket.maxIncomingLinks,
|
||||||
|
...(socket.value ? { defaultValue: cloned(socket.value.default) } : {}),
|
||||||
|
visible: socket.visible,
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
id: id as GraphLayoutNodeV1["id"],
|
||||||
|
typeId,
|
||||||
|
typeVersion: definition.version,
|
||||||
|
position,
|
||||||
|
size: {
|
||||||
|
x: typeId.includes("pass") ? 228 : 210,
|
||||||
|
y: Math.max(98, 47 + definition.ui.length * 24),
|
||||||
|
},
|
||||||
|
label,
|
||||||
|
parameters,
|
||||||
|
sockets,
|
||||||
|
muted: false,
|
||||||
|
collapsed: false,
|
||||||
|
extensions: { physical: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutLink(
|
||||||
|
index: number,
|
||||||
|
from: GraphLayoutNodeV1,
|
||||||
|
fromSocket: string,
|
||||||
|
to: GraphLayoutNodeV1,
|
||||||
|
toSocket: string,
|
||||||
|
relation: string,
|
||||||
|
relationIndex = 0,
|
||||||
|
descriptor: PlainObject = {},
|
||||||
|
): GraphLink {
|
||||||
|
return {
|
||||||
|
id: `physical-link-${index}` as GraphLink["id"],
|
||||||
|
fromNodeId: from.id,
|
||||||
|
fromSocketId: `${from.id}:${fromSocket}` as GraphLink["fromSocketId"],
|
||||||
|
toNodeId: to.id,
|
||||||
|
toSocketId: `${to.id}:${toSocket}` as GraphLink["toSocketId"],
|
||||||
|
muted: false,
|
||||||
|
extensions: {
|
||||||
|
relation,
|
||||||
|
index: relationIndex,
|
||||||
|
descriptor: descriptor as JsonValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPassRelations(pass: PlainObject): PlainObject {
|
||||||
|
const result = cloned(pass);
|
||||||
|
result.pipeline = "";
|
||||||
|
if (Object.hasOwn(result, "after")) result.after = [];
|
||||||
|
if (Object.hasOwn(result, "bindings")) result.bindings = [];
|
||||||
|
if (Object.hasOwn(result, "color")) result.color = [];
|
||||||
|
if (Object.hasOwn(result, "depth")) result.depth = null;
|
||||||
|
if (Object.hasOwn(result, "vertexBuffers")) result.vertexBuffers = [];
|
||||||
|
if (Object.hasOwn(result, "indexBuffer")) result.indexBuffer = null;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Expands a concrete Yawn graph into editable physical FxNode declarations. */
|
||||||
|
export function materializeRenderGraph(
|
||||||
|
graphValue: object,
|
||||||
|
preset = "custom",
|
||||||
|
): GraphLayoutV2 {
|
||||||
|
if (!plainRecord(graphValue))
|
||||||
|
throw new TypeError("Render graph must be plain data");
|
||||||
|
const graph = cloned(graphValue);
|
||||||
|
const buffers = Array.isArray(graph.resources?.buffers)
|
||||||
|
? graph.resources.buffers
|
||||||
|
: [];
|
||||||
|
const textures = Array.isArray(graph.resources?.textures)
|
||||||
|
? graph.resources.textures
|
||||||
|
: [];
|
||||||
|
const samplers = Array.isArray(graph.resources?.samplers)
|
||||||
|
? graph.resources.samplers
|
||||||
|
: [];
|
||||||
|
const renderPipelines = Array.isArray(graph.pipelines?.render)
|
||||||
|
? graph.pipelines.render
|
||||||
|
: [];
|
||||||
|
const computePipelines = Array.isArray(graph.pipelines?.compute)
|
||||||
|
? graph.pipelines.compute
|
||||||
|
: [];
|
||||||
|
const passes = Array.isArray(graph.passes) ? graph.passes : [];
|
||||||
|
const nodes: GraphLayoutNodeV1[] = [];
|
||||||
|
const links: GraphLink[] = [];
|
||||||
|
const resources = new Map<string, GraphLayoutNodeV1>();
|
||||||
|
const pipelines = new Map<string, GraphLayoutNodeV1>();
|
||||||
|
const passNodes = new Map<string, GraphLayoutNodeV1>();
|
||||||
|
const passLevels = new Map<string, number>();
|
||||||
|
const levelOf = (pass: PlainObject, visiting = new Set<string>()): number => {
|
||||||
|
const id = String(pass.id);
|
||||||
|
const known = passLevels.get(id);
|
||||||
|
if (known !== undefined) return known;
|
||||||
|
if (visiting.has(id)) return 0;
|
||||||
|
visiting.add(id);
|
||||||
|
const dependencies = (pass.after ?? [])
|
||||||
|
.map((dependency: string) =>
|
||||||
|
passes.find((candidate: PlainObject) => candidate.id === dependency),
|
||||||
|
)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((dependency: PlainObject) => levelOf(dependency, visiting));
|
||||||
|
visiting.delete(id);
|
||||||
|
const level = dependencies.length ? Math.max(...dependencies) + 1 : 0;
|
||||||
|
passLevels.set(id, level);
|
||||||
|
return level;
|
||||||
|
};
|
||||||
|
passes.forEach((pass: PlainObject) => levelOf(pass));
|
||||||
|
const addUnique = (
|
||||||
|
map: Map<string, GraphLayoutNodeV1>,
|
||||||
|
id: unknown,
|
||||||
|
node: GraphLayoutNodeV1,
|
||||||
|
kind: string,
|
||||||
|
) => {
|
||||||
|
if (typeof id !== "string" || !id)
|
||||||
|
throw new Error(`${kind} requires an ID`);
|
||||||
|
if (map.has(id)) throw new Error(`Duplicate ${kind} ID ${id}`);
|
||||||
|
map.set(id, node);
|
||||||
|
nodes.push(node);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceEntries = [
|
||||||
|
...buffers.map((value: PlainObject, index: number) => ({
|
||||||
|
kind: "buffer",
|
||||||
|
value,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
...textures.map((value: PlainObject, index: number) => ({
|
||||||
|
kind: "texture",
|
||||||
|
value,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
...samplers.map((value: PlainObject, index: number) => ({
|
||||||
|
kind: "sampler",
|
||||||
|
value,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const resourceLevels = new Map<number, number>();
|
||||||
|
const resourceLevel = (id: string): number => {
|
||||||
|
const levels = passes
|
||||||
|
.filter((pass: PlainObject) =>
|
||||||
|
[
|
||||||
|
...(pass.bindings ?? []),
|
||||||
|
...(pass.color ?? []),
|
||||||
|
...(pass.vertexBuffers ?? []),
|
||||||
|
...(pass.depth ? [pass.depth] : []),
|
||||||
|
...(pass.indexBuffer ? [pass.indexBuffer] : []),
|
||||||
|
].some((item: PlainObject) => item?.resource === id),
|
||||||
|
)
|
||||||
|
.map((pass: PlainObject) => levelOf(pass));
|
||||||
|
return levels.length ? Math.min(...levels) : 0;
|
||||||
|
};
|
||||||
|
const resourcePosition = (id: string) => {
|
||||||
|
const level = resourceLevel(id);
|
||||||
|
const row = resourceLevels.get(level) ?? 0;
|
||||||
|
resourceLevels.set(level, row + 1);
|
||||||
|
return { x: 40 + level * 280, y: -420 - row * 142 };
|
||||||
|
};
|
||||||
|
for (const entry of resourceEntries) {
|
||||||
|
const typeId =
|
||||||
|
physicalTypes[entry.kind as "buffer" | "texture" | "sampler"];
|
||||||
|
const details =
|
||||||
|
entry.kind === "buffer"
|
||||||
|
? `${String(entry.value.array || "rows")} · ${(entry.value.usage ?? []).join(" + ") || "copy"}${entry.value.sync ? ` · ${entry.value.sync} sync` : ""}`
|
||||||
|
: entry.kind === "texture"
|
||||||
|
? `${String(entry.value.format || "format")} · ${summarizeSize(entry.value.size)} · ${(entry.value.usage ?? []).join(" + ") || "texture"}`
|
||||||
|
: `${Object.keys(entry.value.descriptor ?? {}).join(" · ") || "default filtering"}`;
|
||||||
|
const node = layoutNode(
|
||||||
|
`physical-${entry.kind}-${entry.index}`,
|
||||||
|
typeId,
|
||||||
|
`${entry.kind[0]!.toUpperCase()}${entry.kind.slice(1)} · ${String(entry.value.id)}`,
|
||||||
|
details,
|
||||||
|
entry.value,
|
||||||
|
resourcePosition(entry.value.id),
|
||||||
|
);
|
||||||
|
addUnique(resources, entry.value.id, node, "resource");
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedResources = new Set<string>();
|
||||||
|
for (const pass of passes) {
|
||||||
|
for (const item of [
|
||||||
|
...(pass.bindings ?? []),
|
||||||
|
...(pass.color ?? []),
|
||||||
|
...(pass.vertexBuffers ?? []),
|
||||||
|
...(pass.depth ? [pass.depth] : []),
|
||||||
|
...(pass.indexBuffer ? [pass.indexBuffer] : []),
|
||||||
|
]) {
|
||||||
|
if (typeof item?.resource === "string")
|
||||||
|
referencedResources.add(item.resource);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let externalIndex = 0;
|
||||||
|
for (const id of referencedResources) {
|
||||||
|
if (resources.has(id)) continue;
|
||||||
|
const node = layoutNode(
|
||||||
|
`physical-external-${externalIndex}`,
|
||||||
|
physicalTypes.external,
|
||||||
|
id === "canvas" ? "Canvas surface" : `External · ${id}`,
|
||||||
|
id === "canvas"
|
||||||
|
? "Current WebGPU swap-chain texture"
|
||||||
|
: "Externally supplied resource",
|
||||||
|
{ id, external: true },
|
||||||
|
resourcePosition(id),
|
||||||
|
);
|
||||||
|
externalIndex++;
|
||||||
|
addUnique(resources, id, node, "resource");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pipelineEntries = [
|
||||||
|
...renderPipelines.map((value: PlainObject, index: number) => ({
|
||||||
|
kind: "render",
|
||||||
|
value,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
...computePipelines.map((value: PlainObject, index: number) => ({
|
||||||
|
kind: "compute",
|
||||||
|
value,
|
||||||
|
index,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
for (const [layoutIndex, entry] of pipelineEntries.entries()) {
|
||||||
|
const value = cloned(entry.value);
|
||||||
|
const code = typeof value.code === "string" ? value.code : "";
|
||||||
|
delete value.code;
|
||||||
|
const details =
|
||||||
|
entry.kind === "render"
|
||||||
|
? `${String(value.vertex?.entry || "vertex")} → ${String(value.fragment?.entry || "fragment")} · ${value.fragment?.targets?.length ?? 0} targets · ${code.split("\n").length} WGSL lines`
|
||||||
|
: `${String(value.entry || "main")} · ${code.split("\n").length} WGSL lines`;
|
||||||
|
const consumerLevels = passes
|
||||||
|
.filter((pass: PlainObject) => pass.pipeline === value.id)
|
||||||
|
.map((pass: PlainObject) => levelOf(pass));
|
||||||
|
const pipelineLevel = consumerLevels.length
|
||||||
|
? Math.min(...consumerLevels)
|
||||||
|
: layoutIndex;
|
||||||
|
const pipelineRow = pipelineEntries
|
||||||
|
.slice(0, layoutIndex)
|
||||||
|
.filter((candidate) =>
|
||||||
|
passes.some(
|
||||||
|
(pass: PlainObject) =>
|
||||||
|
pass.pipeline === candidate.value.id &&
|
||||||
|
levelOf(pass) === pipelineLevel,
|
||||||
|
),
|
||||||
|
).length;
|
||||||
|
const node = layoutNode(
|
||||||
|
`physical-${entry.kind}-pipeline-${entry.index}`,
|
||||||
|
entry.kind === "render"
|
||||||
|
? physicalTypes.renderPipeline
|
||||||
|
: physicalTypes.computePipeline,
|
||||||
|
`${entry.kind === "render" ? "Render" : "Compute"} pipeline · ${String(value.id)}`,
|
||||||
|
details,
|
||||||
|
value,
|
||||||
|
{
|
||||||
|
x: 280 + pipelineLevel * 280,
|
||||||
|
y: 590 + pipelineRow * 142,
|
||||||
|
},
|
||||||
|
code,
|
||||||
|
);
|
||||||
|
addUnique(pipelines, value.id, node, "pipeline");
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameIn = layoutNode(
|
||||||
|
"physical-frame-in",
|
||||||
|
physicalTypes.frameIn,
|
||||||
|
"Frame In",
|
||||||
|
"Resources ready",
|
||||||
|
{ kind: "frame-in" },
|
||||||
|
{ x: 30, y: 280 },
|
||||||
|
);
|
||||||
|
nodes.push(frameIn);
|
||||||
|
|
||||||
|
const passRows = new Map<number, number>();
|
||||||
|
for (const [index, pass] of passes.entries()) {
|
||||||
|
const level = levelOf(pass);
|
||||||
|
const row = passRows.get(level) ?? 0;
|
||||||
|
passRows.set(level, row + 1);
|
||||||
|
const node = layoutNode(
|
||||||
|
`physical-pass-${index}`,
|
||||||
|
pass.type === "compute"
|
||||||
|
? physicalTypes.computePass
|
||||||
|
: physicalTypes.renderPass,
|
||||||
|
`${pass.type === "compute" ? "Compute" : "Render"} pass · ${String(pass.id)}`,
|
||||||
|
drawSummary(pass),
|
||||||
|
resetPassRelations(pass),
|
||||||
|
{ x: 280 + level * 280, y: 280 - row * 300 },
|
||||||
|
);
|
||||||
|
addUnique(passNodes, pass.id, node, "pass");
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalDependencies = new Set(
|
||||||
|
passes.flatMap((pass: PlainObject) =>
|
||||||
|
Array.isArray(pass.after) ? pass.after : [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const maximumLevel = Math.max(0, ...passLevels.values());
|
||||||
|
const frameOut = layoutNode(
|
||||||
|
"physical-frame-out",
|
||||||
|
physicalTypes.frameOut,
|
||||||
|
"Frame Out",
|
||||||
|
"GPU work submitted",
|
||||||
|
{ kind: "frame-out" },
|
||||||
|
{ x: 280 + (maximumLevel + 1) * 280, y: 280 },
|
||||||
|
);
|
||||||
|
nodes.push(frameOut);
|
||||||
|
|
||||||
|
const connect = (
|
||||||
|
from: GraphLayoutNodeV1 | undefined,
|
||||||
|
fromSocket: string,
|
||||||
|
to: GraphLayoutNodeV1,
|
||||||
|
toSocket: string,
|
||||||
|
relation: string,
|
||||||
|
index = 0,
|
||||||
|
descriptor: PlainObject = {},
|
||||||
|
) => {
|
||||||
|
if (!from)
|
||||||
|
throw new Error(
|
||||||
|
`${to.label} references a missing ${relation} declaration`,
|
||||||
|
);
|
||||||
|
links.push(
|
||||||
|
layoutLink(
|
||||||
|
links.length,
|
||||||
|
from,
|
||||||
|
fromSocket,
|
||||||
|
to,
|
||||||
|
toSocket,
|
||||||
|
relation,
|
||||||
|
index,
|
||||||
|
descriptor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (const pass of passes) {
|
||||||
|
const target = passNodes.get(pass.id)!;
|
||||||
|
const after = Array.isArray(pass.after) ? pass.after : [];
|
||||||
|
if (after.length) {
|
||||||
|
after.forEach((id: string, index: number) =>
|
||||||
|
connect(passNodes.get(id), "pass", target, "after", "after", index),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
connect(frameIn, "frame", target, "after", "frame", 0);
|
||||||
|
}
|
||||||
|
connect(
|
||||||
|
pipelines.get(pass.pipeline),
|
||||||
|
"pipeline",
|
||||||
|
target,
|
||||||
|
"pipeline",
|
||||||
|
"pipeline",
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
(pass.bindings ?? []).forEach((item: PlainObject, index: number) =>
|
||||||
|
connect(
|
||||||
|
resources.get(item.resource),
|
||||||
|
"resource",
|
||||||
|
target,
|
||||||
|
"bindings",
|
||||||
|
"binding",
|
||||||
|
index,
|
||||||
|
withoutResource(item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
(pass.color ?? []).forEach((item: PlainObject, index: number) =>
|
||||||
|
connect(
|
||||||
|
resources.get(item.resource),
|
||||||
|
"resource",
|
||||||
|
target,
|
||||||
|
"color",
|
||||||
|
"color",
|
||||||
|
index,
|
||||||
|
withoutResource(item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (pass.depth)
|
||||||
|
connect(
|
||||||
|
resources.get(pass.depth.resource),
|
||||||
|
"resource",
|
||||||
|
target,
|
||||||
|
"depth",
|
||||||
|
"depth",
|
||||||
|
0,
|
||||||
|
withoutResource(pass.depth),
|
||||||
|
);
|
||||||
|
(pass.vertexBuffers ?? []).forEach((item: PlainObject, index: number) =>
|
||||||
|
connect(
|
||||||
|
resources.get(item.resource),
|
||||||
|
"resource",
|
||||||
|
target,
|
||||||
|
"vertex",
|
||||||
|
"vertex",
|
||||||
|
index,
|
||||||
|
withoutResource(item),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (pass.indexBuffer)
|
||||||
|
connect(
|
||||||
|
resources.get(pass.indexBuffer.resource),
|
||||||
|
"resource",
|
||||||
|
target,
|
||||||
|
"index",
|
||||||
|
"index",
|
||||||
|
0,
|
||||||
|
withoutResource(pass.indexBuffer),
|
||||||
|
);
|
||||||
|
if (!terminalDependencies.has(pass.id))
|
||||||
|
connect(target, "pass", frameOut, "after", "frame-out", 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 2,
|
||||||
|
graphId: `yawn-physical-${preset}` as GraphLayoutV2["graphId"],
|
||||||
|
catalogVersion: renderGraphComposition.version,
|
||||||
|
nodes,
|
||||||
|
links,
|
||||||
|
metadata: {
|
||||||
|
schema: physicalRenderGraphSchema,
|
||||||
|
preset,
|
||||||
|
renderGraphId: String(graph.id || "scene"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPhysicalRenderGraph(layout: GraphSource): boolean {
|
||||||
|
return layout?.metadata?.schema === physicalRenderGraphSchema;
|
||||||
|
}
|
||||||
|
|
||||||
|
function relationDescriptor(link: GraphLink): PlainObject {
|
||||||
|
return plainRecord(link.extensions?.descriptor)
|
||||||
|
? cloned(link.extensions.descriptor)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconstructs a Yawn graph from physical nodes and their typed relationships. */
|
||||||
|
export function reconstructRenderGraph(layout: GraphSource): PlainObject {
|
||||||
|
if (!isPhysicalRenderGraph(layout))
|
||||||
|
throw new TypeError("FxNode layout is not a physical render graph");
|
||||||
|
const activeNodes = layout.nodes.filter((node: any) => !node.muted);
|
||||||
|
const byNodeId = new Map(
|
||||||
|
activeNodes.map((node: any) => [String(node.id), node]),
|
||||||
|
);
|
||||||
|
const frameIn = activeNodes.filter(
|
||||||
|
(node: any) => node.typeId === physicalTypes.frameIn,
|
||||||
|
);
|
||||||
|
const frameOut = activeNodes.filter(
|
||||||
|
(node: any) => node.typeId === physicalTypes.frameOut,
|
||||||
|
);
|
||||||
|
if (frameIn.length !== 1 || frameOut.length !== 1)
|
||||||
|
throw new Error(
|
||||||
|
"Physical loadout requires exactly one Frame In and Frame Out",
|
||||||
|
);
|
||||||
|
|
||||||
|
const resources = {
|
||||||
|
buffers: [] as PlainObject[],
|
||||||
|
textures: [] as PlainObject[],
|
||||||
|
samplers: [] as PlainObject[],
|
||||||
|
};
|
||||||
|
const pipelines = {
|
||||||
|
render: [] as PlainObject[],
|
||||||
|
compute: [] as PlainObject[],
|
||||||
|
};
|
||||||
|
const passes: PlainObject[] = [];
|
||||||
|
const passByNodeId = new Map<string, PlainObject>();
|
||||||
|
for (const node of activeNodes as any[]) {
|
||||||
|
if (node.typeId === physicalTypes.buffer)
|
||||||
|
resources.buffers.push(declaration(node));
|
||||||
|
else if (node.typeId === physicalTypes.texture)
|
||||||
|
resources.textures.push(declaration(node));
|
||||||
|
else if (node.typeId === physicalTypes.sampler)
|
||||||
|
resources.samplers.push(declaration(node));
|
||||||
|
else if (
|
||||||
|
node.typeId === physicalTypes.renderPipeline ||
|
||||||
|
node.typeId === physicalTypes.computePipeline
|
||||||
|
) {
|
||||||
|
const pipeline = declaration(node);
|
||||||
|
pipeline.code = parameterString(node, "code");
|
||||||
|
pipelines[
|
||||||
|
node.typeId === physicalTypes.renderPipeline ? "render" : "compute"
|
||||||
|
].push(pipeline);
|
||||||
|
} else if (
|
||||||
|
node.typeId === physicalTypes.renderPass ||
|
||||||
|
node.typeId === physicalTypes.computePass
|
||||||
|
) {
|
||||||
|
const pass = declaration(node);
|
||||||
|
if (Array.isArray(pass.after)) pass.after = [];
|
||||||
|
if (Array.isArray(pass.bindings)) pass.bindings = [];
|
||||||
|
if (Array.isArray(pass.color)) pass.color = [];
|
||||||
|
if (Array.isArray(pass.vertexBuffers)) pass.vertexBuffers = [];
|
||||||
|
if (Object.hasOwn(pass, "depth")) pass.depth = null;
|
||||||
|
if (Object.hasOwn(pass, "indexBuffer")) pass.indexBuffer = null;
|
||||||
|
pass.pipeline = "";
|
||||||
|
passes.push(pass);
|
||||||
|
passByNodeId.set(String(node.id), pass);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceId = (node: any): string => {
|
||||||
|
const value = declaration(node).id;
|
||||||
|
if (typeof value !== "string" || !value)
|
||||||
|
throw new Error(`${String(node.label || node.id)} requires an ID`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
const incoming = layout.links
|
||||||
|
.filter(
|
||||||
|
(link) =>
|
||||||
|
!link.muted &&
|
||||||
|
byNodeId.has(String(link.fromNodeId)) &&
|
||||||
|
byNodeId.has(String(link.toNodeId)),
|
||||||
|
)
|
||||||
|
.sort(
|
||||||
|
(left, right) =>
|
||||||
|
Number(left.extensions?.index ?? 0) -
|
||||||
|
Number(right.extensions?.index ?? 0),
|
||||||
|
);
|
||||||
|
for (const link of incoming) {
|
||||||
|
const source = byNodeId.get(String(link.fromNodeId));
|
||||||
|
const target = byNodeId.get(String(link.toNodeId));
|
||||||
|
const pass = passByNodeId.get(String(link.toNodeId));
|
||||||
|
if (!source || !target || !pass) continue;
|
||||||
|
const socket = target.sockets.find(
|
||||||
|
(candidate: any) => String(candidate.id) === String(link.toSocketId),
|
||||||
|
)?.key;
|
||||||
|
if (socket === "after") {
|
||||||
|
if (
|
||||||
|
source.typeId === physicalTypes.renderPass ||
|
||||||
|
source.typeId === physicalTypes.computePass
|
||||||
|
) {
|
||||||
|
if (!Array.isArray(pass.after)) pass.after = [];
|
||||||
|
pass.after.push(sourceId(source));
|
||||||
|
}
|
||||||
|
} else if (socket === "pipeline") {
|
||||||
|
pass.pipeline = sourceId(source);
|
||||||
|
} else if (socket === "bindings") {
|
||||||
|
if (!Array.isArray(pass.bindings)) pass.bindings = [];
|
||||||
|
const descriptor = relationDescriptor(link);
|
||||||
|
if (!Object.hasOwn(descriptor, "group")) descriptor.group = 0;
|
||||||
|
if (!Object.hasOwn(descriptor, "binding"))
|
||||||
|
descriptor.binding = pass.bindings.length;
|
||||||
|
pass.bindings.push({ ...descriptor, resource: sourceId(source) });
|
||||||
|
} else if (socket === "color") {
|
||||||
|
if (!Array.isArray(pass.color)) pass.color = [];
|
||||||
|
pass.color.push({
|
||||||
|
...relationDescriptor(link),
|
||||||
|
resource: sourceId(source),
|
||||||
|
});
|
||||||
|
} else if (socket === "depth") {
|
||||||
|
pass.depth = { ...relationDescriptor(link), resource: sourceId(source) };
|
||||||
|
} else if (socket === "vertex") {
|
||||||
|
if (!Array.isArray(pass.vertexBuffers)) pass.vertexBuffers = [];
|
||||||
|
const descriptor = relationDescriptor(link);
|
||||||
|
if (!Object.hasOwn(descriptor, "slot"))
|
||||||
|
descriptor.slot = pass.vertexBuffers.length;
|
||||||
|
pass.vertexBuffers.push({ ...descriptor, resource: sourceId(source) });
|
||||||
|
} else if (socket === "index") {
|
||||||
|
pass.indexBuffer = {
|
||||||
|
...relationDescriptor(link),
|
||||||
|
resource: sourceId(source),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const pass of passes) {
|
||||||
|
if (!pass.pipeline)
|
||||||
|
throw new Error(`Pass ${String(pass.id)} has no pipeline`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: String(layout.metadata.renderGraphId || "scene"),
|
||||||
|
resources,
|
||||||
|
pipelines,
|
||||||
|
passes,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,16 @@ import {
|
|||||||
renderGraphNodeGroups,
|
renderGraphNodeGroups,
|
||||||
renderGraphPresets,
|
renderGraphPresets,
|
||||||
} from "../src/catalog";
|
} from "../src/catalog";
|
||||||
import { compileFxNodeRenderGraph, encodeRenderGraph } from "../src/compiler";
|
import {
|
||||||
|
buildFxNodeRenderGraph,
|
||||||
|
compileFxNodeRenderGraph,
|
||||||
|
encodeRenderGraph,
|
||||||
|
} from "../src/compiler";
|
||||||
|
import {
|
||||||
|
isPhysicalRenderGraph,
|
||||||
|
materializeRenderGraph,
|
||||||
|
reconstructRenderGraph,
|
||||||
|
} from "../src/physical";
|
||||||
import { bindFxNodeHeadless } from "../../../vendor/fxnode/src/headless-runtime";
|
import { bindFxNodeHeadless } from "../../../vendor/fxnode/src/headless-runtime";
|
||||||
import { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile";
|
import { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile";
|
||||||
|
|
||||||
@@ -115,13 +124,66 @@ const baseGraph = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
test("catalog exposes grouped, materially rich render graph nodes", () => {
|
test("catalog exposes grouped, materially rich render graph nodes", () => {
|
||||||
assert.equal(renderGraphNodeGroups.length, 6);
|
assert.equal(renderGraphNodeGroups.length, 9);
|
||||||
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 25);
|
assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 32);
|
||||||
for (const item of renderGraphNodeGroups.flatMap((group) => group.items))
|
for (const item of renderGraphNodeGroups.flatMap((group) => group.items))
|
||||||
assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId);
|
assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId);
|
||||||
assert.doesNotThrow(() => compileFxNodeComposition(renderGraphComposition));
|
assert.doesNotThrow(() => compileFxNodeComposition(renderGraphComposition));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("prepared intent expands into the complete physical render graph", () => {
|
||||||
|
const standard = renderGraphPresets.find((preset) => preset.id === "beauty")!;
|
||||||
|
const concrete = buildFxNodeRenderGraph(standard.layout, baseGraph);
|
||||||
|
const layout = materializeRenderGraph(concrete, "beauty");
|
||||||
|
assert.equal(isPhysicalRenderGraph(layout), true);
|
||||||
|
assert.ok(layout.nodes.length >= 14);
|
||||||
|
for (const typeId of [
|
||||||
|
"yawn.rg.physical.frame-in",
|
||||||
|
"yawn.rg.physical.frame-out",
|
||||||
|
"yawn.rg.physical.buffer",
|
||||||
|
"yawn.rg.physical.texture",
|
||||||
|
"yawn.rg.physical.sampler",
|
||||||
|
"yawn.rg.physical.render-pipeline",
|
||||||
|
"yawn.rg.physical.render-pass",
|
||||||
|
]) {
|
||||||
|
assert.ok(
|
||||||
|
layout.nodes.some((node) => node.typeId === typeId),
|
||||||
|
typeId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
layout.links.some((link) => link.extensions.relation === "pipeline"),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
layout.links.some((link) => link.extensions.relation === "binding"),
|
||||||
|
);
|
||||||
|
assert.ok(layout.links.some((link) => link.extensions.relation === "color"));
|
||||||
|
assert.ok(layout.links.some((link) => link.extensions.relation === "depth"));
|
||||||
|
assert.ok(layout.links.some((link) => link.extensions.relation === "vertex"));
|
||||||
|
assert.deepEqual(reconstructRenderGraph(layout), concrete);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("physical pipeline shader edits compile into the S-expression", () => {
|
||||||
|
const concrete = buildFxNodeRenderGraph(
|
||||||
|
renderGraphPresets[0]!.layout,
|
||||||
|
baseGraph,
|
||||||
|
);
|
||||||
|
const layout = structuredClone(materializeRenderGraph(concrete, "beauty"));
|
||||||
|
const pipeline = layout.nodes.find(
|
||||||
|
(node) =>
|
||||||
|
node.typeId === "yawn.rg.physical.render-pipeline" &&
|
||||||
|
node.label.includes("forward-pbr-0"),
|
||||||
|
)!;
|
||||||
|
pipeline.parameters.code = {
|
||||||
|
kind: "string",
|
||||||
|
value:
|
||||||
|
"@fragment fn edited_shader() -> @location(0) vec4<f32> { return vec4<f32>(0.25); }",
|
||||||
|
};
|
||||||
|
const serialized = compileFxNodeRenderGraph(layout, baseGraph);
|
||||||
|
assert.match(serialized, /edited_shader/u);
|
||||||
|
assert.doesNotMatch(serialized, /original pbr shader/u);
|
||||||
|
});
|
||||||
|
|
||||||
test("Color Grade exposes Blender-style grading modes and migrates version 1 nodes", () => {
|
test("Color Grade exposes Blender-style grading modes and migrates version 1 nodes", () => {
|
||||||
const definition = renderGraphComposition.nodes["yawn.rg.post.color-grade"];
|
const definition = renderGraphComposition.nodes["yawn.rg.post.color-grade"];
|
||||||
assert.equal(definition.version, 2);
|
assert.equal(definition.version, 2);
|
||||||
@@ -200,24 +262,108 @@ test("wire encoder is stable and rejects unsupported numbers", () => {
|
|||||||
|
|
||||||
test("normals loadout replaces scene forward shading without mutating the base", () => {
|
test("normals loadout replaces scene forward shading without mutating the base", () => {
|
||||||
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
|
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
|
||||||
const serialized = compileFxNodeRenderGraph(normals.layout, baseGraph);
|
const graph = buildFxNodeRenderGraph(normals.layout, baseGraph) as any;
|
||||||
|
const serialized = encodeRenderGraph(graph);
|
||||||
assert.match(serialized, /scene-fxnode-normals/u);
|
assert.match(serialized, /scene-fxnode-normals/u);
|
||||||
assert.match(serialized, /rg-normals-detailed/u);
|
assert.match(serialized, /rg-normals-detailed/u);
|
||||||
assert.match(serialized, /normalize\(input\.normal\) \* 0\.5 \+ 0\.5/u);
|
assert.match(serialized, /normalize\(input\.normal\) \* 0\.5 \+ 0\.5/u);
|
||||||
assert.match(serialized, /camera-matrices/u);
|
assert.match(serialized, /camera-matrices/u);
|
||||||
|
assert.deepEqual(
|
||||||
|
graph.passes.map((pass: any) => pass.id),
|
||||||
|
["forward-1--1"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(graph.passes[0].color, [
|
||||||
|
{ resource: "canvas", clear: [0, 0, 0, 1] },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
graph.pipelines.render.map((pipeline: any) => pipeline.id),
|
||||||
|
["rg-normals-detailed"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
graph.resources.textures.map((texture: any) => texture.id),
|
||||||
|
["depth"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(graph.resources.samplers, []);
|
||||||
assert.equal(baseGraph.passes[0].pipeline, "forward-pbr-0");
|
assert.equal(baseGraph.passes[0].pipeline, "forward-pbr-0");
|
||||||
assert.equal(baseGraph.pipelines.render.length, 2);
|
assert.equal(baseGraph.pipelines.render.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("debug loadouts remove detached clustered lighting work", () => {
|
||||||
|
const graph = structuredClone(baseGraph) as any;
|
||||||
|
graph.resources.buffers.push(
|
||||||
|
{ id: "point-lights", array: "pointLights", usage: ["storage"] },
|
||||||
|
{ id: "clusters", array: "clusters", usage: ["storage"] },
|
||||||
|
);
|
||||||
|
graph.pipelines.compute.push({
|
||||||
|
id: "cluster-lights",
|
||||||
|
code: "@compute @workgroup_size(1) fn main() {}",
|
||||||
|
entry: "main",
|
||||||
|
});
|
||||||
|
graph.passes.unshift({
|
||||||
|
id: "cluster-lights",
|
||||||
|
type: "compute",
|
||||||
|
pipeline: "cluster-lights",
|
||||||
|
bindings: [
|
||||||
|
{ group: 0, binding: 0, resource: "point-lights" },
|
||||||
|
{ group: 0, binding: 1, resource: "clusters" },
|
||||||
|
],
|
||||||
|
dispatch: [1, 1, 1],
|
||||||
|
});
|
||||||
|
graph.passes[1].after = ["cluster-lights"];
|
||||||
|
const normals = renderGraphPresets.find((preset) => preset.id === "normals")!;
|
||||||
|
const result = buildFxNodeRenderGraph(normals.layout, graph) as any;
|
||||||
|
assert.equal(
|
||||||
|
result.passes.some((pass: any) => pass.type === "compute"),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.pipelines.compute, []);
|
||||||
|
assert.equal(
|
||||||
|
result.resources.buffers.some((buffer: any) =>
|
||||||
|
["point-lights", "clusters"].includes(buffer.id),
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("depth loadout uses position-only geometry and renders to canvas", () => {
|
||||||
|
const depth = renderGraphPresets.find((preset) => preset.id === "depth")!;
|
||||||
|
const graph = buildFxNodeRenderGraph(depth.layout, baseGraph) as any;
|
||||||
|
assert.deepEqual(
|
||||||
|
graph.pipelines.render.map((pipeline: any) => pipeline.id),
|
||||||
|
["rg-depth-basic"],
|
||||||
|
);
|
||||||
|
assert.match(graph.pipelines.render[0].code, /0\.2040/u);
|
||||||
|
assert.deepEqual(graph.pipelines.render[0].vertex.buffers.length, 1);
|
||||||
|
assert.deepEqual(graph.passes[0].vertexBuffers, [
|
||||||
|
{ slot: 0, resource: "geometry-1-positions" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(graph.passes[0].color, [
|
||||||
|
{ resource: "canvas", clear: [0, 0, 0, 1] },
|
||||||
|
]);
|
||||||
|
assert.equal(
|
||||||
|
graph.resources.buffers.some(
|
||||||
|
(buffer: any) => buffer.id === "geometry-1-normals",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("cinematic loadout inserts ordered sampled post passes", () => {
|
test("cinematic loadout inserts ordered sampled post passes", () => {
|
||||||
const cinematic = renderGraphPresets.find(
|
const cinematic = renderGraphPresets.find(
|
||||||
(preset) => preset.id === "cinematic",
|
(preset) => preset.id === "cinematic",
|
||||||
)!;
|
)!;
|
||||||
const serialized = compileFxNodeRenderGraph(cinematic.layout, baseGraph);
|
const graph = buildFxNodeRenderGraph(cinematic.layout, baseGraph) as any;
|
||||||
|
const serialized = encodeRenderGraph(graph);
|
||||||
assert.match(serialized, /rg-effect-pass-0-bloom/u);
|
assert.match(serialized, /rg-effect-pass-0-bloom/u);
|
||||||
assert.match(serialized, /rg-effect-pass-1-grade/u);
|
assert.match(serialized, /rg-effect-pass-1-grade/u);
|
||||||
assert.match(serialized, /rg-effect-pass-2-vignette/u);
|
assert.match(serialized, /rg-effect-pass-2-vignette/u);
|
||||||
assert.match(serialized, /rg-effect-2-vignette/u);
|
assert.doesNotMatch(serialized, /\(field "id" "rg-effect-2-vignette"\)/u);
|
||||||
|
assert.doesNotMatch(serialized, /\(field "id" "present"\)/u);
|
||||||
|
assert.deepEqual(
|
||||||
|
graph.passes.find((pass: any) => pass.id === "rg-effect-pass-2-vignette")
|
||||||
|
.color,
|
||||||
|
[{ resource: "canvas", clear: [0, 0, 0, 1] }],
|
||||||
|
);
|
||||||
assert.match(serialized, /let liftBalance = vec3<f32>/u);
|
assert.match(serialized, /let liftBalance = vec3<f32>/u);
|
||||||
assert.match(serialized, /let gammaBalance = vec3<f32>/u);
|
assert.match(serialized, /let gammaBalance = vec3<f32>/u);
|
||||||
assert.match(serialized, /let gainBalance = vec3<f32>/u);
|
assert.match(serialized, /let gainBalance = vec3<f32>/u);
|
||||||
|
|||||||
+1369
-207
File diff suppressed because it is too large
Load Diff
+250
-18
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
|
buildFxNodeRenderGraph,
|
||||||
compileFxNodeRenderGraph,
|
compileFxNodeRenderGraph,
|
||||||
createRenderGraphEditor,
|
createRenderGraphEditor,
|
||||||
|
materializeRenderGraph,
|
||||||
renderGraphNodeGroups,
|
renderGraphNodeGroups,
|
||||||
renderGraphPresets,
|
renderGraphPresets,
|
||||||
} from "@yawn/fxnode-rg";
|
} from "@yawn/fxnode-rg";
|
||||||
@@ -167,7 +169,6 @@ const elements = Object.fromEntries(
|
|||||||
"project-title",
|
"project-title",
|
||||||
"render-graph-canvas",
|
"render-graph-canvas",
|
||||||
"render-graph-editor",
|
"render-graph-editor",
|
||||||
"render-graph-hint",
|
|
||||||
"reset-button",
|
"reset-button",
|
||||||
"resize-handle",
|
"resize-handle",
|
||||||
"resolution-status",
|
"resolution-status",
|
||||||
@@ -175,6 +176,11 @@ const elements = Object.fromEntries(
|
|||||||
"run-button",
|
"run-button",
|
||||||
"run-status",
|
"run-status",
|
||||||
"save-button",
|
"save-button",
|
||||||
|
"shader-editor",
|
||||||
|
"shader-empty",
|
||||||
|
"shader-node-name",
|
||||||
|
"shader-tab",
|
||||||
|
"shader-workspace",
|
||||||
"share-button",
|
"share-button",
|
||||||
"snippets-button",
|
"snippets-button",
|
||||||
"snippets-dialog",
|
"snippets-dialog",
|
||||||
@@ -243,6 +249,8 @@ const packages = Promise.all([
|
|||||||
let monaco;
|
let monaco;
|
||||||
let editor;
|
let editor;
|
||||||
let model;
|
let model;
|
||||||
|
let shaderEditor;
|
||||||
|
let shaderModel;
|
||||||
let current;
|
let current;
|
||||||
let currentID = "";
|
let currentID = "";
|
||||||
let currentRevision = 0;
|
let currentRevision = 0;
|
||||||
@@ -264,9 +272,14 @@ let renderGraphMenuOutsideListener;
|
|||||||
let graphDirty = false;
|
let graphDirty = false;
|
||||||
let loadoutsDirty = false;
|
let loadoutsDirty = false;
|
||||||
let suppressGraphChanges = false;
|
let suppressGraphChanges = false;
|
||||||
|
let suppressShaderChanges = false;
|
||||||
let customLoadouts = [];
|
let customLoadouts = [];
|
||||||
|
let preparedLoadouts = [];
|
||||||
let currentLoadoutID = "beauty";
|
let currentLoadoutID = "beauty";
|
||||||
let compiledRenderGraph = "";
|
let compiledRenderGraph = "";
|
||||||
|
let selectedShaderNodeID = "";
|
||||||
|
let shaderSelectionGeneration = 0;
|
||||||
|
let shaderWrite = Promise.resolve();
|
||||||
|
|
||||||
localStorage.removeItem("yawn:playground:loadouts");
|
localStorage.removeItem("yawn:playground:loadouts");
|
||||||
localStorage.removeItem("yawn:playground:active-loadout");
|
localStorage.removeItem("yawn:playground:active-loadout");
|
||||||
@@ -403,6 +416,86 @@ async function createEditor(source) {
|
|||||||
"editorSuggestWidget.background": "#102538",
|
"editorSuggestWidget.background": "#102538",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
if (
|
||||||
|
!monaco.languages.getLanguages().some((language) => language.id === "wgsl")
|
||||||
|
) {
|
||||||
|
monaco.languages.register({ id: "wgsl", extensions: [".wgsl"] });
|
||||||
|
monaco.languages.setMonarchTokensProvider("wgsl", {
|
||||||
|
keywords: [
|
||||||
|
"alias",
|
||||||
|
"break",
|
||||||
|
"case",
|
||||||
|
"const",
|
||||||
|
"continue",
|
||||||
|
"continuing",
|
||||||
|
"default",
|
||||||
|
"discard",
|
||||||
|
"else",
|
||||||
|
"enable",
|
||||||
|
"false",
|
||||||
|
"fn",
|
||||||
|
"for",
|
||||||
|
"if",
|
||||||
|
"let",
|
||||||
|
"loop",
|
||||||
|
"override",
|
||||||
|
"requires",
|
||||||
|
"return",
|
||||||
|
"struct",
|
||||||
|
"switch",
|
||||||
|
"true",
|
||||||
|
"var",
|
||||||
|
"while",
|
||||||
|
],
|
||||||
|
typeKeywords: [
|
||||||
|
"array",
|
||||||
|
"atomic",
|
||||||
|
"bool",
|
||||||
|
"f16",
|
||||||
|
"f32",
|
||||||
|
"i32",
|
||||||
|
"mat2x2",
|
||||||
|
"mat3x3",
|
||||||
|
"mat4x4",
|
||||||
|
"ptr",
|
||||||
|
"sampler",
|
||||||
|
"texture_2d",
|
||||||
|
"texture_cube",
|
||||||
|
"u32",
|
||||||
|
"vec2",
|
||||||
|
"vec3",
|
||||||
|
"vec4",
|
||||||
|
],
|
||||||
|
tokenizer: {
|
||||||
|
root: [
|
||||||
|
[/@[a-zA-Z_]\w*/u, "annotation"],
|
||||||
|
[
|
||||||
|
/[a-zA-Z_]\w*/u,
|
||||||
|
{
|
||||||
|
cases: {
|
||||||
|
"@keywords": "keyword",
|
||||||
|
"@typeKeywords": "type",
|
||||||
|
"@default": "identifier",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[/0[xX][0-9a-fA-F]+[iu]?/u, "number.hex"],
|
||||||
|
[/(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[fihu]?/u, "number"],
|
||||||
|
[/\/\*/u, "comment", "@comment"],
|
||||||
|
[/\/\/.*$/u, "comment"],
|
||||||
|
[/[{}()[\]]/u, "delimiter.bracket"],
|
||||||
|
[/[;,.]/u, "delimiter"],
|
||||||
|
[/[+\-*\/%=!<>|&^~?:]+/u, "operator"],
|
||||||
|
],
|
||||||
|
comment: [
|
||||||
|
[/[^/*]+/u, "comment"],
|
||||||
|
[/\/\*/u, "comment", "@push"],
|
||||||
|
[/\*\//u, "comment", "@pop"],
|
||||||
|
[/[/*]/u, "comment"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
elements.editor.textContent = "";
|
elements.editor.textContent = "";
|
||||||
model = monaco.editor.createModel(
|
model = monaco.editor.createModel(
|
||||||
source,
|
source,
|
||||||
@@ -443,6 +536,36 @@ async function createEditor(source) {
|
|||||||
monaco.editor.onDidChangeMarkers(([resource]) => {
|
monaco.editor.onDidChangeMarkers(([resource]) => {
|
||||||
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
||||||
});
|
});
|
||||||
|
shaderModel = monaco.editor.createModel(
|
||||||
|
"",
|
||||||
|
"wgsl",
|
||||||
|
monaco.Uri.parse("file:///playground/shader.wgsl"),
|
||||||
|
);
|
||||||
|
shaderEditor = monaco.editor.create(elements["shader-editor"], {
|
||||||
|
model: shaderModel,
|
||||||
|
theme: document.documentElement.dataset.theme === "dark" ? "yawn" : "vs",
|
||||||
|
automaticLayout: true,
|
||||||
|
fontFamily:
|
||||||
|
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
fontLigatures: true,
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 20,
|
||||||
|
minimap: { enabled: true, maxColumn: 80, renderCharacters: false },
|
||||||
|
padding: { top: 12, bottom: 12 },
|
||||||
|
renderLineHighlight: "all",
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
smoothScrolling: true,
|
||||||
|
tabSize: 2,
|
||||||
|
wordWrap: "off",
|
||||||
|
});
|
||||||
|
shaderEditor.onDidChangeCursorPosition(({ position }) => {
|
||||||
|
if (activeCodingTab !== "shader") return;
|
||||||
|
elements["cursor-status"].textContent =
|
||||||
|
`Ln ${position.lineNumber}, Col ${position.column}`;
|
||||||
|
});
|
||||||
|
shaderModel.onDidChangeContent(() => {
|
||||||
|
if (!suppressShaderChanges) queueShaderEdit();
|
||||||
|
});
|
||||||
elements["type-status"].textContent = "TypeScript language service ready";
|
elements["type-status"].textContent = "TypeScript language service ready";
|
||||||
updateDiagnostics();
|
updateDiagnostics();
|
||||||
}
|
}
|
||||||
@@ -467,6 +590,61 @@ function updateDiagnostics() {
|
|||||||
: "TypeScript language service ready";
|
: "TypeScript language service ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reportShaderError(error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setLoadoutStatus(message, "failed");
|
||||||
|
showToast(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueShaderEdit() {
|
||||||
|
if (!selectedShaderNodeID || !shaderModel || !renderGraphEditor) return;
|
||||||
|
const nodeId = selectedShaderNodeID;
|
||||||
|
const code = shaderModel.getValue();
|
||||||
|
shaderWrite = shaderWrite
|
||||||
|
.then(async () => {
|
||||||
|
await renderGraphEditor.setParameter(nodeId, "code", {
|
||||||
|
kind: "string",
|
||||||
|
value: code,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(reportShaderError);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushShaderEdits() {
|
||||||
|
await shaderWrite;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateShaderSelection(nodeIds) {
|
||||||
|
const generation = ++shaderSelectionGeneration;
|
||||||
|
await flushShaderEdits();
|
||||||
|
const layout = renderGraphEditor ? await renderGraphEditor.save() : undefined;
|
||||||
|
if (generation !== shaderSelectionGeneration) return;
|
||||||
|
const node =
|
||||||
|
nodeIds.length === 1
|
||||||
|
? layout?.nodes.find((candidate) => candidate.id === nodeIds[0])
|
||||||
|
: undefined;
|
||||||
|
const code = node?.parameters?.code;
|
||||||
|
const shader = code?.kind === "string" ? code.value : undefined;
|
||||||
|
selectedShaderNodeID = shader === undefined ? "" : String(node.id);
|
||||||
|
elements["shader-tab"].disabled = !selectedShaderNodeID;
|
||||||
|
elements["shader-tab"].title = selectedShaderNodeID
|
||||||
|
? `Edit ${node.label}`
|
||||||
|
: "Select a render or compute pipeline to edit its WGSL";
|
||||||
|
elements["shader-node-name"].textContent =
|
||||||
|
node?.label || "Select a pipeline node";
|
||||||
|
elements["shader-empty"].hidden = Boolean(selectedShaderNodeID);
|
||||||
|
suppressShaderChanges = true;
|
||||||
|
try {
|
||||||
|
shaderModel?.setValue(shader ?? "");
|
||||||
|
} finally {
|
||||||
|
suppressShaderChanges = false;
|
||||||
|
}
|
||||||
|
if (activeCodingTab === "shader") {
|
||||||
|
shaderEditor?.layout();
|
||||||
|
shaderEditor?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function markDirty(value, target = "code") {
|
function markDirty(value, target = "code") {
|
||||||
dirty = value;
|
dirty = value;
|
||||||
elements["dirty-indicator"].classList.toggle("visible", value);
|
elements["dirty-indicator"].classList.toggle("visible", value);
|
||||||
@@ -483,7 +661,7 @@ function markDirty(value, target = "code") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function allLoadouts() {
|
function allLoadouts() {
|
||||||
return [...renderGraphPresets, ...customLoadouts];
|
return [...preparedLoadouts, ...customLoadouts];
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeLoadout() {
|
function activeLoadout() {
|
||||||
@@ -495,12 +673,28 @@ function loadoutLayout(loadout = activeLoadout()) {
|
|||||||
return loadout.layout;
|
return loadout.layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshPreparedLoadouts() {
|
||||||
|
const scene = current?.scene;
|
||||||
|
if (!scene?.getRenderGraph)
|
||||||
|
throw new Error("Run a Scene before opening its physical render graph");
|
||||||
|
const baseGraph = await scene.getRenderGraph();
|
||||||
|
preparedLoadouts = renderGraphPresets.map((preset) => ({
|
||||||
|
id: preset.id,
|
||||||
|
name: preset.name,
|
||||||
|
builtIn: true,
|
||||||
|
layout: materializeRenderGraph(
|
||||||
|
buildFxNodeRenderGraph(preset.layout, baseGraph),
|
||||||
|
preset.id,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function renderLoadoutOptions() {
|
function renderLoadoutOptions() {
|
||||||
const builtIn = document.createElement("optgroup");
|
const builtIn = document.createElement("optgroup");
|
||||||
const saved = document.createElement("optgroup");
|
const saved = document.createElement("optgroup");
|
||||||
builtIn.label = "Prepared loadouts";
|
builtIn.label = "Prepared loadouts";
|
||||||
saved.label = "Playground loadouts";
|
saved.label = "Playground loadouts";
|
||||||
for (const loadout of renderGraphPresets) {
|
for (const loadout of preparedLoadouts) {
|
||||||
const option = document.createElement("option");
|
const option = document.createElement("option");
|
||||||
option.value = loadout.id;
|
option.value = loadout.id;
|
||||||
option.textContent = loadout.name;
|
option.textContent = loadout.name;
|
||||||
@@ -541,32 +735,34 @@ function stageActiveLoadout(layout) {
|
|||||||
const currentLoadout = activeLoadout();
|
const currentLoadout = activeLoadout();
|
||||||
if (!currentLoadout)
|
if (!currentLoadout)
|
||||||
throw new Error("The selected render graph loadout is missing");
|
throw new Error("The selected render graph loadout is missing");
|
||||||
const name =
|
let name =
|
||||||
elements["loadout-name"].value.trim() ||
|
elements["loadout-name"].value.trim() ||
|
||||||
`${currentLoadout.name || "Render graph"} custom`;
|
`${currentLoadout.name || "Render graph"} custom`;
|
||||||
|
const changed = graphDirty || currentLoadout.name !== name;
|
||||||
|
if (!changed) {
|
||||||
|
graphDirty = false;
|
||||||
|
renderLoadoutOptions();
|
||||||
|
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||||||
|
return currentLoadout;
|
||||||
|
}
|
||||||
|
if (currentLoadout.builtIn && name === currentLoadout.name)
|
||||||
|
name = `${name} custom`;
|
||||||
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
|
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
|
||||||
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
|
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
|
||||||
const changed =
|
const staged = {
|
||||||
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
|
|
||||||
const staged = changed
|
|
||||||
? {
|
|
||||||
id,
|
id,
|
||||||
name: name.slice(0, 64),
|
name: name.slice(0, 64),
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
layout,
|
layout,
|
||||||
}
|
};
|
||||||
: currentLoadout;
|
|
||||||
if (changed) {
|
|
||||||
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
|
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
|
||||||
if (index < 0) customLoadouts.push(staged);
|
if (index < 0) customLoadouts.push(staged);
|
||||||
else customLoadouts[index] = staged;
|
else customLoadouts[index] = staged;
|
||||||
loadoutsDirty = true;
|
loadoutsDirty = true;
|
||||||
}
|
|
||||||
currentLoadoutID = id;
|
currentLoadoutID = id;
|
||||||
graphDirty = false;
|
graphDirty = false;
|
||||||
renderLoadoutOptions();
|
renderLoadoutOptions();
|
||||||
if (changed) markDirty(true, "graph");
|
markDirty(true, "graph");
|
||||||
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
|
||||||
return staged;
|
return staged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -731,9 +927,15 @@ function openRenderGraphMenu(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function switchLoadout(id) {
|
async function switchLoadout(id) {
|
||||||
const next = allLoadouts().find((loadout) => loadout.id === id);
|
let next = allLoadouts().find((loadout) => loadout.id === id);
|
||||||
if (!next || !renderGraphEditor) return;
|
if (!next || !renderGraphEditor) return;
|
||||||
|
await flushShaderEdits();
|
||||||
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
|
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
|
||||||
|
if (next.builtIn) {
|
||||||
|
await refreshPreparedLoadouts();
|
||||||
|
next = allLoadouts().find((loadout) => loadout.id === id);
|
||||||
|
if (!next) throw new Error("The selected prepared loadout is missing");
|
||||||
|
}
|
||||||
suppressGraphChanges = true;
|
suppressGraphChanges = true;
|
||||||
try {
|
try {
|
||||||
currentLoadoutID = next.id;
|
currentLoadoutID = next.id;
|
||||||
@@ -753,6 +955,7 @@ async function applyLoadout() {
|
|||||||
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
|
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
|
||||||
elements["apply-loadout-button"].disabled = true;
|
elements["apply-loadout-button"].disabled = true;
|
||||||
try {
|
try {
|
||||||
|
await flushShaderEdits();
|
||||||
const layout = await renderGraphEditor.save();
|
const layout = await renderGraphEditor.save();
|
||||||
const staged = stageActiveLoadout(layout);
|
const staged = stageActiveLoadout(layout);
|
||||||
if (model.getValue() !== lastRunSource && !(await run()))
|
if (model.getValue() !== lastRunSource && !(await run()))
|
||||||
@@ -788,6 +991,7 @@ async function copyLoadoutCode() {
|
|||||||
if (!renderGraphEditor || button.disabled) return;
|
if (!renderGraphEditor || button.disabled) return;
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
await flushShaderEdits();
|
||||||
const layout = await renderGraphEditor.save();
|
const layout = await renderGraphEditor.save();
|
||||||
compiledRenderGraph = await compileLoadout(layout);
|
compiledRenderGraph = await compileLoadout(layout);
|
||||||
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
|
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
|
||||||
@@ -805,12 +1009,16 @@ async function copyLoadoutCode() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initializeRenderGraphWorkspace() {
|
async function initializeRenderGraphWorkspace() {
|
||||||
|
await refreshPreparedLoadouts();
|
||||||
renderLoadoutOptions();
|
renderLoadoutOptions();
|
||||||
renderGraphEditor = await createRenderGraphEditor({
|
renderGraphEditor = await createRenderGraphEditor({
|
||||||
canvas: elements["render-graph-canvas"],
|
canvas: elements["render-graph-canvas"],
|
||||||
initialLayout: loadoutLayout(),
|
initialLayout: loadoutLayout(),
|
||||||
workerUrl: "/pkg/fxnode-worker.js",
|
workerUrl: "/pkg/fxnode-worker.js",
|
||||||
onChange: markGraphChanged,
|
onChange: markGraphChanged,
|
||||||
|
onSelectionChange(nodeIds) {
|
||||||
|
void updateShaderSelection(nodeIds).catch(reportShaderError);
|
||||||
|
},
|
||||||
onAddNodeMenu: openRenderGraphMenu,
|
onAddNodeMenu: openRenderGraphMenu,
|
||||||
onError(error) {
|
onError(error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -820,7 +1028,7 @@ async function initializeRenderGraphWorkspace() {
|
|||||||
window.__yawnRenderGraph = {
|
window.__yawnRenderGraph = {
|
||||||
editor: renderGraphEditor,
|
editor: renderGraphEditor,
|
||||||
layout: loadoutLayout(),
|
layout: loadoutLayout(),
|
||||||
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
|
presets: preparedLoadouts.map(({ id, name }) => ({ id, name })),
|
||||||
selected: currentLoadoutID,
|
selected: currentLoadoutID,
|
||||||
serialized: compiledRenderGraph,
|
serialized: compiledRenderGraph,
|
||||||
switchLoadout,
|
switchLoadout,
|
||||||
@@ -838,6 +1046,7 @@ function activateCodingTab(tab) {
|
|||||||
}
|
}
|
||||||
elements.editor.hidden = tab !== "scene";
|
elements.editor.hidden = tab !== "scene";
|
||||||
elements["render-graph-editor"].hidden = tab !== "render-graph";
|
elements["render-graph-editor"].hidden = tab !== "render-graph";
|
||||||
|
elements["shader-workspace"].hidden = tab !== "shader";
|
||||||
for (const target of document.querySelectorAll("[data-code-tool]"))
|
for (const target of document.querySelectorAll("[data-code-tool]"))
|
||||||
target.hidden = tab !== "scene";
|
target.hidden = tab !== "scene";
|
||||||
for (const target of document.querySelectorAll("[data-graph-tool]"))
|
for (const target of document.querySelectorAll("[data-graph-tool]"))
|
||||||
@@ -852,6 +1061,21 @@ function activateCodingTab(tab) {
|
|||||||
" Graph valid on save";
|
" Graph valid on save";
|
||||||
elements["editor-diagnostics"].classList.remove("has-errors");
|
elements["editor-diagnostics"].classList.remove("has-errors");
|
||||||
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
|
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
|
||||||
|
} else if (tab === "shader") {
|
||||||
|
elements["workspace-language"].textContent = "WGSL";
|
||||||
|
elements["type-status"].textContent = selectedShaderNodeID
|
||||||
|
? elements["shader-node-name"].textContent
|
||||||
|
: "Select a pipeline node";
|
||||||
|
const position = shaderEditor?.getPosition();
|
||||||
|
elements["cursor-status"].textContent = position
|
||||||
|
? `Ln ${position.lineNumber}, Col ${position.column}`
|
||||||
|
: "Ln 1, Col 1";
|
||||||
|
elements["editor-mode-status"].textContent = "WGSL";
|
||||||
|
elements["encoding-status"].textContent = "UTF-8";
|
||||||
|
elements["editor-diagnostics"].lastChild.textContent = " Compiled on Apply";
|
||||||
|
elements["editor-diagnostics"].classList.remove("has-errors");
|
||||||
|
shaderEditor?.layout();
|
||||||
|
shaderEditor?.focus();
|
||||||
} else {
|
} else {
|
||||||
elements["workspace-language"].textContent = "TypeScript";
|
elements["workspace-language"].textContent = "TypeScript";
|
||||||
const position = editor?.getPosition();
|
const position = editor?.getPosition();
|
||||||
@@ -1104,6 +1328,7 @@ async function save() {
|
|||||||
if (!editor || elements["save-button"].disabled) return;
|
if (!editor || elements["save-button"].disabled) return;
|
||||||
elements["save-button"].disabled = true;
|
elements["save-button"].disabled = true;
|
||||||
try {
|
try {
|
||||||
|
await flushShaderEdits();
|
||||||
const hadLoadoutChanges = loadoutsDirty;
|
const hadLoadoutChanges = loadoutsDirty;
|
||||||
let stagedLoadout;
|
let stagedLoadout;
|
||||||
if (graphDirty && renderGraphEditor)
|
if (graphDirty && renderGraphEditor)
|
||||||
@@ -1285,16 +1510,23 @@ addEventListener("pagehide", (event) => {
|
|||||||
cancelAnimationFrame(frameRequest);
|
cancelAnimationFrame(frameRequest);
|
||||||
closeRenderGraphMenu();
|
closeRenderGraphMenu();
|
||||||
void renderGraphEditor?.destroy();
|
void renderGraphEditor?.destroy();
|
||||||
|
shaderEditor?.dispose();
|
||||||
|
shaderModel?.dispose();
|
||||||
void disposeCurrent();
|
void disposeCurrent();
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const source = await loadInitialPlayground();
|
const source = await loadInitialPlayground();
|
||||||
await createEditor(source);
|
await createEditor(source);
|
||||||
await initializeRenderGraphWorkspace();
|
|
||||||
markDirty(false);
|
markDirty(false);
|
||||||
sampleFPS();
|
sampleFPS();
|
||||||
await run();
|
if (await run()) await initializeRenderGraphWorkspace();
|
||||||
|
else
|
||||||
|
setLoadoutStatus(
|
||||||
|
"Run a Scene to inspect its physical render graph",
|
||||||
|
"failed",
|
||||||
|
);
|
||||||
|
markDirty(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
elements.editor.textContent = message;
|
elements.editor.textContent = message;
|
||||||
|
|||||||
@@ -125,6 +125,20 @@
|
|||||||
render graph
|
render graph
|
||||||
<i id="graph-tab-dirty"></i>
|
<i id="graph-tab-dirty"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="pane-tab"
|
||||||
|
id="shader-tab"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="false"
|
||||||
|
aria-controls="shader-workspace"
|
||||||
|
data-editor-tab="shader"
|
||||||
|
title="Select a render or compute pipeline to edit its WGSL"
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<span class="wgsl-icon">WG</span>
|
||||||
|
shader.wgsl
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
id="editor"
|
id="editor"
|
||||||
@@ -182,11 +196,35 @@
|
|||||||
id="render-graph-canvas"
|
id="render-graph-canvas"
|
||||||
aria-label="Render graph node editor"
|
aria-label="Render graph node editor"
|
||||||
></canvas>
|
></canvas>
|
||||||
<div class="render-graph-empty-hint" id="render-graph-hint">
|
|
||||||
<span>+</span>
|
|
||||||
<strong>Add another pass</strong>
|
|
||||||
<small>Right click anywhere to browse the node catalog</small>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="shader-workspace"
|
||||||
|
class="shader-workspace coding-panel"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="shader-tab"
|
||||||
|
hidden
|
||||||
|
>
|
||||||
|
<header class="shader-toolbar">
|
||||||
|
<span>Selected pipeline</span>
|
||||||
|
<strong id="shader-node-name">Select a pipeline node</strong>
|
||||||
|
<small
|
||||||
|
>Changes stay in this loadout until the playground is
|
||||||
|
saved.</small
|
||||||
|
>
|
||||||
|
</header>
|
||||||
|
<div
|
||||||
|
id="shader-editor"
|
||||||
|
class="shader-editor"
|
||||||
|
aria-label="WGSL shader editor"
|
||||||
|
></div>
|
||||||
|
<div id="shader-empty" class="shader-empty">
|
||||||
|
<span>WGSL</span>
|
||||||
|
<strong>Select a pipeline in the render graph</strong>
|
||||||
|
<small
|
||||||
|
>Render and compute pipeline nodes contain editable shader
|
||||||
|
source.</small
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<footer class="editor-status">
|
<footer class="editor-status">
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ fxnode creates one module worker per root and never creates a resize observer, m
|
|||||||
|
|
||||||
Use root methods for composition, state, persistence, subscriptions, and context-free commands. Use view methods for input, viewport changes, selection actions, resource responses, and render checkpoints. A canvas can have only one live view. On teardown, remove application listeners and observers first, detach each view, then destroy the root.
|
Use root methods for composition, state, persistence, subscriptions, and context-free commands. Use view methods for input, viewport changes, selection actions, resource responses, and render checkpoints. A canvas can have only one live view. On teardown, remove application listeners and observers first, detach each view, then destroy the root.
|
||||||
|
|
||||||
|
`view.getHostSnapshot().selection.nodeIds` exposes the worker-authoritative selected node IDs for application-owned inspectors and editors. Subscribe with `view.subscribeHost()` when those controls need to follow selection changes.
|
||||||
|
|
||||||
The repository example host defaults to `lifecycle: "explicit"`, so `host.destroy()` removes only host-owned policy
|
The repository example host defaults to `lifecycle: "explicit"`, so `host.destroy()` removes only host-owned policy
|
||||||
and never detaches its view. Opt in with `lifecycle: "detach-on-disconnect"` for component-style examples. That mode
|
and never detaches its view. Opt in with `lifecycle: "detach-on-disconnect"` for component-style examples. That mode
|
||||||
requires an initially connected canvas and `MutationObserver`; hosts share one observer per document. A removal is
|
requires an initially connected canvas and `MutationObserver`; hosts share one observer per document. A removal is
|
||||||
|
|||||||
Vendored
+12
-2
@@ -206,7 +206,13 @@ type RpcRequest = WorkerRequest extends infer R
|
|||||||
: never;
|
: never;
|
||||||
const STARTUP_TIMEOUT_MS = 5_000;
|
const STARTUP_TIMEOUT_MS = 5_000;
|
||||||
const emptySelection = (): FxNodeSelectionSnapshot =>
|
const emptySelection = (): FxNodeSelectionSnapshot =>
|
||||||
Object.freeze({ nodeCount: 0, linkCount: 0, canRemove: false, mute: Object.freeze({ enabled: false as const }) });
|
Object.freeze({
|
||||||
|
nodeIds: Object.freeze([]),
|
||||||
|
nodeCount: 0,
|
||||||
|
linkCount: 0,
|
||||||
|
canRemove: false,
|
||||||
|
mute: Object.freeze({ enabled: false as const }),
|
||||||
|
});
|
||||||
|
|
||||||
const reservedCanvases = new WeakSet<HTMLCanvasElement>();
|
const reservedCanvases = new WeakSet<HTMLCanvasElement>();
|
||||||
|
|
||||||
@@ -750,7 +756,11 @@ class FxNodeViewClient implements FxNodeView {
|
|||||||
if (this.detachedError) return;
|
if (this.detachedError) return;
|
||||||
if (data.type === "view.frame") this.consumeFrame(data);
|
if (data.type === "view.frame") this.consumeFrame(data);
|
||||||
else if (data.type === "view.selection.host") {
|
else if (data.type === "view.selection.host") {
|
||||||
this.selection = Object.freeze({ ...data.projection, mute: Object.freeze({ ...data.projection.mute }) });
|
this.selection = Object.freeze({
|
||||||
|
...data.projection,
|
||||||
|
nodeIds: Object.freeze([...data.projection.nodeIds]),
|
||||||
|
mute: Object.freeze({ ...data.projection.mute }),
|
||||||
|
});
|
||||||
this.selectionReady();
|
this.selectionReady();
|
||||||
this.publishHost(this.hostSnapshot.colorPickerOpen);
|
this.publishHost(this.hostSnapshot.colorPickerOpen);
|
||||||
} else if (data.type === "view.node-menu.result") {
|
} else if (data.type === "view.node-menu.result") {
|
||||||
|
|||||||
+2
@@ -53,6 +53,8 @@ export interface FxNodeActionOptions {
|
|||||||
readonly expectedVersion?: number;
|
readonly expectedVersion?: number;
|
||||||
}
|
}
|
||||||
export interface FxNodeSelectionSnapshot {
|
export interface FxNodeSelectionSnapshot {
|
||||||
|
/** Worker-authoritative IDs of the selected nodes in this view. */
|
||||||
|
readonly nodeIds: readonly string[];
|
||||||
readonly nodeCount: number;
|
readonly nodeCount: number;
|
||||||
readonly linkCount: number;
|
readonly linkCount: number;
|
||||||
readonly canRemove: boolean;
|
readonly canRemove: boolean;
|
||||||
|
|||||||
+7
-2
@@ -11,7 +11,7 @@ import { validCommand } from "../commands/validate.js";
|
|||||||
import type { FxNodeCamera, FxNodeSelectionSnapshot, FxNodeViewport } from "./host-types.js";
|
import type { FxNodeCamera, FxNodeSelectionSnapshot, FxNodeViewport } from "./host-types.js";
|
||||||
import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js";
|
import { fxNodeDevicePixels, FXNODE_VIEW_LIMITS } from "./view-limits.js";
|
||||||
|
|
||||||
export const PROTOCOL_VERSION = 3 as const;
|
export const PROTOCOL_VERSION = 4 as const;
|
||||||
export type ViewId = string;
|
export type ViewId = string;
|
||||||
export type Camera = FxNodeCamera;
|
export type Camera = FxNodeCamera;
|
||||||
export type VersionExpectation = { readonly kind: "current" } | { readonly kind: "exact"; readonly version: number };
|
export type VersionExpectation = { readonly kind: "current" } | { readonly kind: "exact"; readonly version: number };
|
||||||
@@ -818,8 +818,13 @@ function validHostSnapshot(v: unknown): v is HostInteractionSnapshot {
|
|||||||
function validSelectionProjection(v: unknown): v is FxNodeSelectionSnapshot {
|
function validSelectionProjection(v: unknown): v is FxNodeSelectionSnapshot {
|
||||||
if (
|
if (
|
||||||
!record(v) ||
|
!record(v) ||
|
||||||
!exact(v, ["nodeCount", "linkCount", "canRemove", "mute"]) ||
|
!exact(v, ["nodeIds", "nodeCount", "linkCount", "canRemove", "mute"]) ||
|
||||||
|
!Array.isArray(v.nodeIds) ||
|
||||||
|
v.nodeIds.length > 1024 ||
|
||||||
|
v.nodeIds.some((id) => typeof id !== "string" || id.length === 0 || id.length > 512) ||
|
||||||
|
new Set(v.nodeIds).size !== v.nodeIds.length ||
|
||||||
!nonnegativeSafeInteger(v.nodeCount) ||
|
!nonnegativeSafeInteger(v.nodeCount) ||
|
||||||
|
v.nodeCount !== v.nodeIds.length ||
|
||||||
!nonnegativeSafeInteger(v.linkCount) ||
|
!nonnegativeSafeInteger(v.linkCount) ||
|
||||||
v.canRemove !== (v.nodeCount > 0 || v.linkCount > 0) ||
|
v.canRemove !== (v.nodeCount > 0 || v.linkCount > 0) ||
|
||||||
!record(v.mute)
|
!record(v.mute)
|
||||||
|
|||||||
+10
-5
@@ -483,9 +483,8 @@ function isStandardNode(id: import("../core/types.js").NodeId): boolean {
|
|||||||
}
|
}
|
||||||
function publishSelection(view: WorkerView, force = false): void {
|
function publishSelection(view: WorkerView, force = false): void {
|
||||||
if (!state || !application) return;
|
if (!state || !application) return;
|
||||||
const nodes = [...view.session.selectedNodes]
|
const nodeIds = [...view.session.selectedNodes].filter((id) => !!state!.document.nodes[id]),
|
||||||
.map((id) => state!.document.nodes[id])
|
nodes = nodeIds.map((id) => state!.document.nodes[id]).filter((node): node is NonNullable<typeof node> => !!node),
|
||||||
.filter((node): node is NonNullable<typeof node> => !!node),
|
|
||||||
linkCount = [...view.session.selectedLinks].filter((id) => state!.document.links[id]).length,
|
linkCount = [...view.session.selectedLinks].filter((id) => state!.document.links[id]).length,
|
||||||
eligible = nodes.filter((node) => isStandardNode(node.id)),
|
eligible = nodes.filter((node) => isStandardNode(node.id)),
|
||||||
mute = eligible.length
|
mute = eligible.length
|
||||||
@@ -498,8 +497,14 @@ function publishSelection(view: WorkerView, force = false): void {
|
|||||||
: "mixed") as "all-muted" | "all-unmuted" | "mixed",
|
: "mixed") as "all-muted" | "all-unmuted" | "mixed",
|
||||||
}
|
}
|
||||||
: { enabled: false as const },
|
: { enabled: false as const },
|
||||||
projection = { nodeCount: nodes.length, linkCount, canRemove: nodes.length + linkCount > 0, mute },
|
projection = {
|
||||||
signature = `${projection.nodeCount}|${projection.linkCount}|${+projection.canRemove}|${mute.enabled ? mute.state : "disabled"}`;
|
nodeIds,
|
||||||
|
nodeCount: nodes.length,
|
||||||
|
linkCount,
|
||||||
|
canRemove: nodes.length + linkCount > 0,
|
||||||
|
mute,
|
||||||
|
},
|
||||||
|
signature = `${JSON.stringify(nodeIds)}|${projection.linkCount}|${+projection.canRemove}|${mute.enabled ? mute.state : "disabled"}`;
|
||||||
if (!force && signature === view.selectionSignature) return;
|
if (!force && signature === view.selectionSignature) return;
|
||||||
view.selectionSignature = signature;
|
view.selectionSignature = signature;
|
||||||
post({ protocol: PROTOCOL_VERSION, type: "view.selection.host", viewId: view.id, projection });
|
post({ protocol: PROTOCOL_VERSION, type: "view.selection.host", viewId: view.id, projection });
|
||||||
|
|||||||
+13
-6
@@ -25,10 +25,16 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
|||||||
else if (message.type === "view.attach") {
|
else if (message.type === "view.attach") {
|
||||||
// Exercise the valid event-before-acknowledgement ordering.
|
// Exercise the valid event-before-acknowledgement ordering.
|
||||||
this.emit({
|
this.emit({
|
||||||
protocol: 3,
|
protocol: 4,
|
||||||
type: "view.selection.host",
|
type: "view.selection.host",
|
||||||
viewId: message.viewId,
|
viewId: message.viewId,
|
||||||
projection: { nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } },
|
projection: {
|
||||||
|
nodeIds: [],
|
||||||
|
nodeCount: 0,
|
||||||
|
linkCount: 0,
|
||||||
|
canRemove: false,
|
||||||
|
mute: { enabled: false },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
this.respond(message.id);
|
this.respond(message.id);
|
||||||
} else if (message.type === "view.detach") this.respond(message.id);
|
} else if (message.type === "view.detach") this.respond(message.id);
|
||||||
@@ -50,7 +56,7 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
|||||||
settleViewport(ok: boolean): void {
|
settleViewport(ok: boolean): void {
|
||||||
const id = this.heldViewportIds.shift()!;
|
const id = this.heldViewportIds.shift()!;
|
||||||
this.emit({
|
this.emit({
|
||||||
protocol: 3,
|
protocol: 4,
|
||||||
type: "response",
|
type: "response",
|
||||||
id,
|
id,
|
||||||
ok,
|
ok,
|
||||||
@@ -60,7 +66,7 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
|||||||
|
|
||||||
private respond(id: string, value?: unknown): void {
|
private respond(id: string, value?: unknown): void {
|
||||||
this.emit({
|
this.emit({
|
||||||
protocol: 3,
|
protocol: 4,
|
||||||
type: "response",
|
type: "response",
|
||||||
id,
|
id,
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -100,10 +106,11 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
|||||||
duplicateCode = (error as { code?: string }).code ?? "";
|
duplicateCode = (error as { code?: string }).code ?? "";
|
||||||
}
|
}
|
||||||
worker.emit({
|
worker.emit({
|
||||||
protocol: 3,
|
protocol: 4,
|
||||||
type: "view.selection.host",
|
type: "view.selection.host",
|
||||||
viewId: first.id,
|
viewId: first.id,
|
||||||
projection: {
|
projection: {
|
||||||
|
nodeIds: ["selected-node"],
|
||||||
nodeCount: 1,
|
nodeCount: 1,
|
||||||
linkCount: 0,
|
linkCount: 0,
|
||||||
canRemove: true,
|
canRemove: true,
|
||||||
@@ -171,7 +178,7 @@ test("canvas-free authority owns independent attachable views", async ({ page })
|
|||||||
modifiers,
|
modifiers,
|
||||||
});
|
});
|
||||||
worker.emit({
|
worker.emit({
|
||||||
protocol: 3,
|
protocol: 4,
|
||||||
type: "view.resource.open",
|
type: "view.resource.open",
|
||||||
viewId: first.id,
|
viewId: first.id,
|
||||||
requestId: resourceOpenRequestId,
|
requestId: resourceOpenRequestId,
|
||||||
|
|||||||
+13
-2
@@ -7,7 +7,11 @@ test("host snapshots are stable immutable projections with isolated subscription
|
|||||||
view = window.view,
|
view = window.view,
|
||||||
first = view.getHostSnapshot(),
|
first = view.getHostSnapshot(),
|
||||||
same = first === view.getHostSnapshot(),
|
same = first === view.getHostSnapshot(),
|
||||||
frozen = Object.isFrozen(first) && Object.isFrozen(first.selection) && Object.isFrozen(first.selection.mute);
|
frozen =
|
||||||
|
Object.isFrozen(first) &&
|
||||||
|
Object.isFrozen(first.selection) &&
|
||||||
|
Object.isFrozen(first.selection.nodeIds) &&
|
||||||
|
Object.isFrozen(first.selection.mute);
|
||||||
let good = 0,
|
let good = 0,
|
||||||
bad = 0;
|
bad = 0;
|
||||||
const offBad = view.subscribeHost(() => {
|
const offBad = view.subscribeHost(() => {
|
||||||
@@ -172,6 +176,7 @@ test("explicit node and selection actions preserve worker authority and structur
|
|||||||
expect(result.duplicate).toBe("node.duplicate");
|
expect(result.duplicate).toBe("node.duplicate");
|
||||||
expect(result.added).toEqual({ status: "committed", version: 2 });
|
expect(result.added).toEqual({ status: "committed", version: 2 });
|
||||||
expect(result.selected).toEqual({
|
expect(result.selected).toEqual({
|
||||||
|
nodeIds: ["supplied-id"],
|
||||||
nodeCount: 1,
|
nodeCount: 1,
|
||||||
linkCount: 0,
|
linkCount: 0,
|
||||||
canRemove: true,
|
canRemove: true,
|
||||||
@@ -182,7 +187,13 @@ test("explicit node and selection actions preserve worker authority and structur
|
|||||||
expect(result.mutedSelection.mute).toEqual({ enabled: true, state: "all-muted" });
|
expect(result.mutedSelection.mute).toEqual({ enabled: true, state: "all-muted" });
|
||||||
expect(result.removed).toEqual({ status: "committed", version: 4 });
|
expect(result.removed).toEqual({ status: "committed", version: 4 });
|
||||||
expect(result.remaining).toBe(0);
|
expect(result.remaining).toBe(0);
|
||||||
expect(result.empty).toEqual({ nodeCount: 0, linkCount: 0, canRemove: false, mute: { enabled: false } });
|
expect(result.empty).toEqual({
|
||||||
|
nodeIds: [],
|
||||||
|
nodeCount: 0,
|
||||||
|
linkCount: 0,
|
||||||
|
canRemove: false,
|
||||||
|
mute: { enabled: false },
|
||||||
|
});
|
||||||
expect(result.notifications).toBe(3);
|
expect(result.notifications).toBe(3);
|
||||||
});
|
});
|
||||||
test("dedicated worker renders, commits FIFO, fans out and destroys", async ({ page }) => {
|
test("dedicated worker renders, commits FIFO, fans out and destroys", async ({ page }) => {
|
||||||
|
|||||||
+7
-1
@@ -336,7 +336,13 @@ test("selection publication does not wait for frame consumption", async ({ page
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
receipt: { status: "committed", version: 2 },
|
receipt: { status: "committed", version: 2 },
|
||||||
changed: true,
|
changed: true,
|
||||||
selection: { nodeCount: 1, linkCount: 0, canRemove: true, mute: { enabled: true, state: "all-unmuted" } },
|
selection: {
|
||||||
|
nodeIds: ["blocked-frame"],
|
||||||
|
nodeCount: 1,
|
||||||
|
linkCount: 0,
|
||||||
|
canRemove: true,
|
||||||
|
mute: { enabled: true, state: "all-unmuted" },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -334,6 +334,7 @@ test("worker resource requests close delayed add-menu UI without intercepting in
|
|||||||
compositionRevision: 0,
|
compositionRevision: 0,
|
||||||
colorPickerOpen: false,
|
colorPickerOpen: false,
|
||||||
selection: Object.freeze({
|
selection: Object.freeze({
|
||||||
|
nodeIds: Object.freeze([]),
|
||||||
nodeCount: 0,
|
nodeCount: 0,
|
||||||
linkCount: 0,
|
linkCount: 0,
|
||||||
canRemove: false,
|
canRemove: false,
|
||||||
@@ -424,6 +425,7 @@ test("default resource picker ignores obsolete asynchronous reads", async ({ pag
|
|||||||
compositionRevision: 0,
|
compositionRevision: 0,
|
||||||
colorPickerOpen: false,
|
colorPickerOpen: false,
|
||||||
selection: Object.freeze({
|
selection: Object.freeze({
|
||||||
|
nodeIds: Object.freeze([]),
|
||||||
nodeCount: 0,
|
nodeCount: 0,
|
||||||
linkCount: 0,
|
linkCount: 0,
|
||||||
canRemove: false,
|
canRemove: false,
|
||||||
|
|||||||
+1
-1
@@ -399,7 +399,7 @@ test("live composition instance API keeps one worker-authoritative revisioned ha
|
|||||||
};
|
};
|
||||||
worker.addEventListener("message", listener);
|
worker.addEventListener("message", listener);
|
||||||
worker.postMessage({
|
worker.postMessage({
|
||||||
protocol: 3,
|
protocol: 4,
|
||||||
type: "composition.update",
|
type: "composition.update",
|
||||||
id,
|
id,
|
||||||
expected: { kind: "exact", revision: -1 },
|
expected: { kind: "exact", revision: -1 },
|
||||||
|
|||||||
Vendored
+15
-1
@@ -599,12 +599,26 @@ test("action requests, selection projections, menu results, and receipts are exa
|
|||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
type: "view.selection.host",
|
type: "view.selection.host",
|
||||||
viewId,
|
viewId,
|
||||||
projection: { nodeCount, linkCount, canRemove, mute: { enabled: false } },
|
projection: {
|
||||||
|
nodeIds: Array.from({ length: nodeCount }, (_, index) => `node-${index}`),
|
||||||
|
nodeCount,
|
||||||
|
linkCount,
|
||||||
|
canRemove,
|
||||||
|
mute: { enabled: false },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
assert.equal(validWorkerMessage(selection(0, 0, false)), true);
|
assert.equal(validWorkerMessage(selection(0, 0, false)), true);
|
||||||
assert.equal(validWorkerMessage(selection(1, 0, true)), true);
|
assert.equal(validWorkerMessage(selection(1, 0, true)), true);
|
||||||
assert.equal(validWorkerMessage(selection(0, 0, true)), false);
|
assert.equal(validWorkerMessage(selection(0, 0, true)), false);
|
||||||
assert.equal(validWorkerMessage(selection(1, 0, false)), false);
|
assert.equal(validWorkerMessage(selection(1, 0, false)), false);
|
||||||
|
const mismatchedSelection = selection(1, 0, true);
|
||||||
|
assert.equal(
|
||||||
|
validWorkerMessage({
|
||||||
|
...mismatchedSelection,
|
||||||
|
projection: { ...mismatchedSelection.projection, nodeCount: 0 },
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
validWorkerMessage({
|
validWorkerMessage({
|
||||||
protocol: PROTOCOL_VERSION,
|
protocol: PROTOCOL_VERSION,
|
||||||
|
|||||||
Reference in New Issue
Block a user