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:
Amp
2026-08-25 05:18:14 +00:00
co-authored by heaust
parent d71da85307
commit e7546e4b78
20 changed files with 3058 additions and 303 deletions
+252 -1
View File
@@ -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",
+169 -33
View File
@@ -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,23 +546,25 @@ 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}`;
textures.push({ if (!final)
id: output, textures.push({
format: sourceTexture.format, id: output,
size: ["canvas", "canvas", 1], format: sourceTexture.format,
usage: ["render", "sampled"], size: ["canvas", "canvas", 1],
transient: true, usage: ["render", "sampled"],
}); transient: true,
});
pipelines.push({ pipelines.push({
id: pipeline, id: pipeline,
code: effectShader(node), code: effectShader(node),
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)));
graph, if (mode && mode.typeId !== "yawn.rg.mode.beauty" && effects.length === 0)
chain.filter((node) => effectTypes.has(String(node.typeId))), routeDebugModeToCanvas(
); graph,
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));
} }
+42 -5
View File
@@ -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,15 +127,21 @@ 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
: 1.1, ? 0.56
: 0.9
: initialPhysical
? 0.72
: 1.1,
}, },
}); });
canvas.tabIndex = 0; canvas.tabIndex = 0;
@@ -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
View File
@@ -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";
+688
View File
@@ -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,
};
}
+152 -6
View File
@@ -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);
File diff suppressed because it is too large Load Diff
+258 -26
View File
@@ -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; id,
const staged = changed name: name.slice(0, 64),
? { builtIn: false,
id, layout,
name: name.slice(0, 64), };
builtIn: false, const index = customLoadouts.findIndex((loadout) => loadout.id === id);
layout, if (index < 0) customLoadouts.push(staged);
} else customLoadouts[index] = staged;
: currentLoadout; loadoutsDirty = true;
if (changed) {
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
if (index < 0) customLoadouts.push(staged);
else customLoadouts[index] = staged;
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;
+43 -5
View File
@@ -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"> </div>
<span>+</span> </section>
<strong>Add another pass</strong> <section
<small>Right click anywhere to browse the node catalog</small> id="shader-workspace"
</div> 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">
+2
View File
@@ -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
+12 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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" },
},
}); });
}); });
+2
View File
@@ -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
View File
@@ -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 },
+15 -1
View File
@@ -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,