diff --git a/addons/fxnode_rg/src/catalog.ts b/addons/fxnode_rg/src/catalog.ts index 804f9a4..f5b9a0b 100644 --- a/addons/fxnode_rg/src/catalog.ts +++ b/addons/fxnode_rg/src/catalog.ts @@ -39,6 +39,33 @@ const dataOutput: FxNodeSocketDefinition = { 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( title: string, style: string, @@ -100,6 +127,45 @@ const stringParameter = (value: string, values?: readonly string[]) => ({ ...(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 = { "yawn.rg.input.scene": stageNode( "Scene", @@ -126,6 +192,92 @@ const nodes = { { 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 { return vec4(0.0); }\n@fragment fn fragment() -> @location(0) vec4 { return vec4(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.normals": stageNode("World Normals", "geometry"), "yawn.rg.mode.depth": stageNode("Camera Depth", "geometry", { @@ -391,11 +543,22 @@ const nodes = { export const renderGraphComposition = { schemaVersion: 2, id: "yawn.render-graph", - version: 2, + version: 3, compatibility: { wildcardInputTypes: [] }, socketTypes: { surface: { title: "Surface", color: "#43b9ed", acceptsFrom: ["surface"] }, 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: { background: "#081522", @@ -431,6 +594,13 @@ export const renderGraphComposition = { }, nodeStyles: { 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" }, lighting: { header: "#a27332" }, post: { header: "#7253a1" }, @@ -456,6 +626,87 @@ export interface 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", title: "Inputs", diff --git a/addons/fxnode_rg/src/compiler.ts b/addons/fxnode_rg/src/compiler.ts index d16b7b3..540028f 100644 --- a/addons/fxnode_rg/src/compiler.ts +++ b/addons/fxnode_rg/src/compiler.ts @@ -4,6 +4,7 @@ import type { GraphSnapshot, ParameterValue, } from "fxnode"; +import { isPhysicalRenderGraph, reconstructRenderGraph } from "./physical"; type PlainObject = Record; type GraphSource = GraphLayoutV2 | GraphSnapshot; @@ -113,22 +114,38 @@ function activeChain(layout: GraphSource): any[] { 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 clay = value(node, "color", [0.36, 0.68, 0.9, 1]); + const usesNormals = mode === "normals" || mode === "unlit"; + const usesObjectId = mode === "object-id"; const fragment = mode === "normals" ? "return vec4(normalize(input.normal) * 0.5 + 0.5, 1.0);" : mode === "depth" - ? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${Math.max(0.01, contrast).toFixed(4)}); return vec4(vec3(depth), 1.0);` + ? `let depth = pow(clamp(1.0 - input.position.z, 0.0, 1.0), ${(Math.max(0.01, contrast) * 0.12).toFixed(4)}); return vec4(vec3(depth), 1.0);` : mode === "unlit" ? `let base = vec3(${Number(clay[0] ?? 0.36).toFixed(5)}, ${Number(clay[1] ?? 0.68).toFixed(5)}, ${Number(clay[2] ?? 0.9).toFixed(5)}); let light = 0.32 + max(dot(normalize(input.normal), normalize(vec3(0.35, 0.8, 0.45))), 0.0) * 0.68; return vec4(base * light, 1.0);` : "let seed = f32(input.objectId) * 0.61803398875; let color = 0.42 + 0.58 * cos(vec3(0.0, 2.1, 4.2) + seed * 6.28318); return vec4(color, 1.0);"; return ` struct VertexOutput { @invariant @builtin(position) position: vec4, - @location(0) normal: vec3, - @location(1) @interpolate(flat) objectId: u32, + ${usesNormals ? "@location(0) normal: vec3," : ""} + ${usesObjectId ? `@location(${usesNormals ? 1 : 0}) @interpolate(flat) objectId: u32,` : ""} } @group(0) @binding(0) var positions: array>; @group(0) @binding(1) var rotors: array>; @@ -140,7 +157,7 @@ fn rotate(rotor: vec4, point: vec3) -> vec3 { } @vertex fn vertex( @location(0) point: vec3, - ${detailed ? "@location(1) localNormal: vec3," : ""} + ${normalBuffer ? "@location(1) localNormal: vec3," : ""} @builtin(instance_index) packed: u32, ) -> VertexOutput { let instance = packed & 65535u; @@ -152,8 +169,8 @@ fn rotate(rotor: vec4, point: vec3) -> vec3 { dot(cameraMatrices[2], world), dot(cameraMatrices[3], world)); if (cameraMatrices[4].w == 0.0) { output.position = world; } output.position = select(vec4(2.0, 2.0, 2.0, 1.0), output.position, meshInfo[instance * 4u + 2u] != 0u); - output.normal = normalize(rotate(rotors[instance], ${detailed ? "localNormal / scales[instance].xyz" : "vec3(0.0, 0.0, 1.0)"})); - output.objectId = instance; + ${usesNormals ? `output.normal = normalize(rotate(rotors[instance], ${normalBuffer ? "localNormal / scales[instance].xyz" : "vec3(0.0, 0.0, 1.0)"}));` : ""} + ${usesObjectId ? "output.objectId = instance;" : ""} return output; } @fragment fn fragment(input: VertexOutput) -> @location(0) vec4 { @@ -170,6 +187,10 @@ function applyDebugMode(graph: PlainObject, node: any): void { const byId = new Map( renderPipelines.map((pipeline: any) => [pipeline.id, pipeline]), ); + const passesById = new Map( + graph.passes.map((pass: PlainObject) => [pass.id, pass]), + ); + const detachedComputePasses = new Set(); const generated = new Map(); for (const pass of graph.passes) { if ( @@ -187,19 +208,20 @@ function applyDebugMode(graph: PlainObject, node: any): void { const format = original.fragment?.targets?.[0]?.format; if (typeof format !== "string") throw new Error(`Forward pipeline ${pass.pipeline} has no color target`); - const key = `${mode}:${detailed}:${format}`; + const normalBuffer = (mode === "normals" || mode === "unlit") && detailed; + const key = `${mode}:${normalBuffer}:${format}`; let id = generated.get(key); if (!id) { - id = `rg-${mode}-${detailed ? "detailed" : "basic"}`; + id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}`; let suffix = 1; while (byId.has(id)) - id = `rg-${mode}-${detailed ? "detailed" : "basic"}-${suffix++}`; + id = `rg-${mode}-${normalBuffer ? "detailed" : "basic"}-${suffix++}`; const pipeline = { id, - code: debugShader(mode, detailed, node), + code: debugShader(mode, normalBuffer, node), 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 }] }, depthStencil: original.depthStencil ?? null, @@ -223,9 +245,37 @@ function applyDebugMode(graph: PlainObject, node: any): void { resource, })); 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]; @@ -496,23 +546,25 @@ function applyEffects(graph: PlainObject, effects: any[]): void { let after = Array.isArray(present.after) ? [...present.after] : []; for (const [index, node] of effects.entries()) { const suffix = `${index}-${String(node.id).replace(/[^a-z0-9-]/gi, "-")}`; - const output = `rg-effect-${suffix}`; + const final = index === effects.length - 1; + const output = final ? "canvas" : `rg-effect-${suffix}`; const pipeline = `rg-effect-pipeline-${suffix}`; const pass = `rg-effect-pass-${suffix}`; - textures.push({ - id: output, - format: sourceTexture.format, - size: ["canvas", "canvas", 1], - usage: ["render", "sampled"], - transient: true, - }); + if (!final) + textures.push({ + id: output, + format: sourceTexture.format, + size: ["canvas", "canvas", 1], + usage: ["render", "sampled"], + transient: true, + }); pipelines.push({ id: pipeline, code: effectShader(node), vertex: { entry: "vertex", buffers: [] }, fragment: { entry: "fragment", - targets: [{ format: sourceTexture.format }], + targets: [{ format: final ? "canvas" : sourceTexture.format }], }, }); graph.passes.push({ @@ -530,28 +582,101 @@ function applyEffects(graph: PlainObject, effects: any[]): void { input = output; after = [pass]; } - sourceBinding.resource = input; - present.after = after; + graph.passes = graph.passes.filter((pass: PlainObject) => pass !== present); +} + +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 - * S-expression consumed by `YawnCore.compileGraph`. + * Resolves an FxNode loadout into the concrete graph that Yawn will compile. */ -export function compileFxNodeRenderGraph( +export function buildFxNodeRenderGraph( layout: GraphSource, baseGraph: object, -): string { +): object { if (!plainRecord(baseGraph)) throw new TypeError("Base render graph must be plain data"); + if (isPhysicalRenderGraph(layout)) return reconstructRenderGraph(layout); const graph = structuredClone(baseGraph) as PlainObject; const chain = activeChain(layout); const mode = chain.findLast((node) => modeTypes.has(String(node.typeId))); if (mode) applyDebugMode(graph, mode); - applyEffects( - graph, - chain.filter((node) => effectTypes.has(String(node.typeId))), - ); + const effects = chain.filter((node) => effectTypes.has(String(node.typeId))); + if (mode && mode.typeId !== "yawn.rg.mode.beauty" && effects.length === 0) + routeDebugModeToCanvas( + graph, + String(mode.typeId).slice("yawn.rg.mode.".length), + ); + else applyEffects(graph, effects); + pruneDeclarations(graph); const suffix = chain .filter( (node) => @@ -562,5 +687,16 @@ export function compileFxNodeRenderGraph( .map((stage) => (stage === "beauty" ? "standard" : stage)) .join("-"); 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)); } diff --git a/addons/fxnode_rg/src/editor.ts b/addons/fxnode_rg/src/editor.ts index 07e0b3a..937e8b5 100644 --- a/addons/fxnode_rg/src/editor.ts +++ b/addons/fxnode_rg/src/editor.ts @@ -3,6 +3,7 @@ import { type FxNode, type FxNodeView, type GraphLayoutV2, + type ParameterValue, type Vec2, } from "fxnode"; import { renderGraphComposition, renderGraphPresets } from "./catalog"; @@ -17,6 +18,7 @@ export interface RenderGraphEditorOptions { readonly initialLayout?: GraphLayoutV2; readonly workerUrl: string | URL; readonly onChange?: () => void; + readonly onSelectionChange?: (nodeIds: readonly string[]) => void; readonly onAddNodeMenu?: (request: RenderGraphAddNodeRequest) => void; readonly onError?: (error: unknown) => void; } @@ -24,6 +26,11 @@ export interface RenderGraphEditorOptions { export interface RenderGraphEditor { load(layout: GraphLayoutV2): Promise; save(): Promise; + setParameter( + nodeId: string, + key: string, + value: ParameterValue, + ): Promise; syncViewport(): Promise; whenRendered(): Promise; destroy(): Promise; @@ -78,6 +85,7 @@ export async function createRenderGraphEditor({ initialLayout = renderGraphPresets[0]!.layout, workerUrl, onChange = () => {}, + onSelectionChange = () => {}, onAddNodeMenu = () => {}, onError = console.error, }: RenderGraphEditorOptions): Promise { @@ -119,15 +127,21 @@ export async function createRenderGraphEditor({ await root.load(initialLayout); const initialViewport = viewport(canvas); sizeCanvas(canvas, initialViewport); + const initialPhysical = + initialLayout.metadata.schema === "yawn.render-graph/v1"; view = await root.attachView({ canvas, viewport: initialViewport, initialCamera: { - center: { x: 0, y: 10 }, + center: initialPhysical ? { x: 320, y: 300 } : { x: 0, y: 10 }, zoom: (canvas.ownerDocument.defaultView?.innerWidth ?? 1024) <= 800 - ? 0.9 - : 1.1, + ? initialPhysical + ? 0.56 + : 0.9 + : initialPhysical + ? 0.72 + : 1.1, }, }); canvas.tabIndex = 0; @@ -267,20 +281,43 @@ export async function createRenderGraphEditor({ }), ); 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 { async load(layout) { if (destroyed || !root) throw new Error("Render graph editor is closed"); await root.load(layout); - await fitGraph(); + if (layout.metadata.schema === "yawn.render-graph/v1") + await activeView.whenRendered(); + else await fitGraph(); }, async save() { if (destroyed || !root) throw new Error("Render graph editor is closed"); 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, whenRendered: () => activeView.whenRendered(), async destroy() { diff --git a/addons/fxnode_rg/src/index.ts b/addons/fxnode_rg/src/index.ts index 0a69912..9d3a477 100644 --- a/addons/fxnode_rg/src/index.ts +++ b/addons/fxnode_rg/src/index.ts @@ -1,3 +1,4 @@ export * from "./catalog"; export * from "./compiler"; export * from "./editor"; +export * from "./physical"; diff --git a/addons/fxnode_rg/src/physical.ts b/addons/fxnode_rg/src/physical.ts new file mode 100644 index 0000000..bd260b4 --- /dev/null +++ b/addons/fxnode_rg/src/physical.ts @@ -0,0 +1,688 @@ +import type { + GraphLayoutNodeV1, + GraphLayoutV2, + GraphLink, + GraphSnapshot, + JsonValue, + ParameterValue, +} from "fxnode"; +import { renderGraphComposition } from "./catalog"; + +type PlainObject = Record; +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(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; + 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(); + const pipelines = new Map(); + const passNodes = new Map(); + const passLevels = new Map(); + const levelOf = (pass: PlainObject, visiting = new Set()): 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, + 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(); + 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(); + 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(); + 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(); + 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, + }; +} diff --git a/addons/fxnode_rg/test/compiler.test.ts b/addons/fxnode_rg/test/compiler.test.ts index 913d636..b005f17 100644 --- a/addons/fxnode_rg/test/compiler.test.ts +++ b/addons/fxnode_rg/test/compiler.test.ts @@ -5,7 +5,16 @@ import { renderGraphNodeGroups, renderGraphPresets, } 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 { compileFxNodeComposition } from "../../../vendor/fxnode/src/composition/compile"; @@ -115,13 +124,66 @@ const baseGraph = { }; test("catalog exposes grouped, materially rich render graph nodes", () => { - assert.equal(renderGraphNodeGroups.length, 6); - assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 25); + assert.equal(renderGraphNodeGroups.length, 9); + assert.ok(renderGraphNodeGroups.flatMap((group) => group.items).length >= 32); for (const item of renderGraphNodeGroups.flatMap((group) => group.items)) assert.ok(renderGraphComposition.nodes[item.typeId], item.typeId); 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 { return vec4(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", () => { const definition = renderGraphComposition.nodes["yawn.rg.post.color-grade"]; 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", () => { 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, /rg-normals-detailed/u); assert.match(serialized, /normalize\(input\.normal\) \* 0\.5 \+ 0\.5/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.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", () => { const cinematic = renderGraphPresets.find( (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-1-grade/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/u); assert.match(serialized, /let gammaBalance = vec3/u); assert.match(serialized, /let gainBalance = vec3/u); diff --git a/server/web/assets/playground.css b/server/web/assets/playground.css index a54d27e..360466b 100644 --- a/server/web/assets/playground.css +++ b/server/web/assets/playground.css @@ -1,224 +1,1386 @@ :root { color: #17324c; - font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: + Inter, + ui-sans-serif, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; font-synthesis: none; } -* { box-sizing: border-box; } -[hidden] { display: none !important; } -html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; } -button, input { font: inherit; } -button { color: inherit; } -.playground-app { display: grid; width: 100%; height: 100%; height: 100dvh; grid-template-rows: 58px 41px minmax(0, 1fr); background: #f7fbfe; } +* { + box-sizing: border-box; +} +[hidden] { + display: none !important; +} +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} +button, +input { + font: inherit; +} +button { + color: inherit; +} +.playground-app { + display: grid; + width: 100%; + height: 100%; + height: 100dvh; + grid-template-rows: 58px 41px minmax(0, 1fr); + background: #f7fbfe; +} -.playground-header { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 0 16px 0 19px; border-bottom: 1px solid #d5e3ed; background: #fff; } -.playground-brand { display: inline-flex; align-items: center; gap: 9px; color: #102d49; text-decoration: none; } -.playground-brand strong { font-size: 18px; letter-spacing: -.04em; } -.brand-mark { position: relative; display: inline-block; width: 22px; height: 22px; } -.brand-mark i { position: absolute; width: 7px; border: 2px solid #218ddd; border-radius: 7px; transform: rotate(-28deg); } -.brand-mark i:nth-child(1) { left: 1px; top: 9px; height: 10px; } -.brand-mark i:nth-child(2) { left: 7px; top: 5px; height: 14px; } -.brand-mark i:nth-child(3) { left: 14px; top: 1px; height: 18px; } -.header-divider { width: 1px; height: 22px; margin-inline: 2px; background: #d8e3eb; } -.project-name { display: flex; min-width: 0; align-items: center; gap: 7px; } -.project-name input { width: min(250px, 24vw); padding: 5px 7px; color: #29445e; border: 1px solid transparent; border-radius: 5px; outline: 0; background: transparent; font-size: 13px; font-weight: 650; text-overflow: ellipsis; } -.project-name input:hover { background: #f5f9fc; } -.project-name input:focus { border-color: #a9d8f5; background: #f7fcff; box-shadow: 0 0 0 2px rgb(50 166 232 / 10%); } -#dirty-indicator, #tab-dirty { display: none; width: 6px; height: 6px; border-radius: 50%; background: #2a9ee6; } -#dirty-indicator.visible, #tab-dirty.visible { display: inline-block; } -#revision-label { padding: 4px 7px; color: #74879a; border: 1px solid #dae5ed; border-radius: 4px; background: #f8fafc; font: 600 9px/1 ui-monospace, monospace; } -.playground-nav { display: flex; flex: 1; justify-content: flex-end; gap: 21px; } -.playground-nav a { color: #687a8c; font-size: 11px; font-weight: 600; text-decoration: none; } -.playground-nav a:hover { color: #1889d2; } -.tool-button, .run-button { display: inline-flex; height: 32px; align-items: center; justify-content: center; gap: 7px; padding: 0 12px; border-radius: 6px; cursor: pointer; font-size: 11px; font-weight: 650; } -.tool-button { color: #486177; border: 1px solid #d4e1ea; background: #fff; } -.tool-button:hover { color: #177fc2; border-color: #add5ee; background: #f6fbfe; } -.run-button { min-width: 111px; color: white; border: 1px solid #1687ce; background: #1a8fda; box-shadow: 0 4px 12px rgb(26 143 218 / 18%); } -.run-button:hover { background: #117fc5; } -.run-button:disabled, .tool-button:disabled { cursor: wait; opacity: .58; } -.run-button kbd { padding-left: 7px; color: rgb(255 255 255 / 70%); border: 0; border-left: 1px solid rgb(255 255 255 / 24%); font: 8px/1 ui-monospace, monospace; } +.playground-header { + display: flex; + min-width: 0; + align-items: center; + gap: 14px; + padding: 0 16px 0 19px; + border-bottom: 1px solid #d5e3ed; + background: #fff; +} +.playground-brand { + display: inline-flex; + align-items: center; + gap: 9px; + color: #102d49; + text-decoration: none; +} +.playground-brand strong { + font-size: 18px; + letter-spacing: -0.04em; +} +.brand-mark { + position: relative; + display: inline-block; + width: 22px; + height: 22px; +} +.brand-mark i { + position: absolute; + width: 7px; + border: 2px solid #218ddd; + border-radius: 7px; + transform: rotate(-28deg); +} +.brand-mark i:nth-child(1) { + left: 1px; + top: 9px; + height: 10px; +} +.brand-mark i:nth-child(2) { + left: 7px; + top: 5px; + height: 14px; +} +.brand-mark i:nth-child(3) { + left: 14px; + top: 1px; + height: 18px; +} +.header-divider { + width: 1px; + height: 22px; + margin-inline: 2px; + background: #d8e3eb; +} +.project-name { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; +} +.project-name input { + width: min(250px, 24vw); + padding: 5px 7px; + color: #29445e; + border: 1px solid transparent; + border-radius: 5px; + outline: 0; + background: transparent; + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; +} +.project-name input:hover { + background: #f5f9fc; +} +.project-name input:focus { + border-color: #a9d8f5; + background: #f7fcff; + box-shadow: 0 0 0 2px rgb(50 166 232 / 10%); +} +#dirty-indicator, +#tab-dirty { + display: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: #2a9ee6; +} +#dirty-indicator.visible, +#tab-dirty.visible { + display: inline-block; +} +#revision-label { + padding: 4px 7px; + color: #74879a; + border: 1px solid #dae5ed; + border-radius: 4px; + background: #f8fafc; + font: + 600 9px/1 ui-monospace, + monospace; +} +.playground-nav { + display: flex; + flex: 1; + justify-content: flex-end; + gap: 21px; +} +.playground-nav a { + color: #687a8c; + font-size: 11px; + font-weight: 600; + text-decoration: none; +} +.playground-nav a:hover { + color: #1889d2; +} +.tool-button, +.run-button { + display: inline-flex; + height: 32px; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 12px; + border-radius: 6px; + cursor: pointer; + font-size: 11px; + font-weight: 650; +} +.tool-button { + color: #486177; + border: 1px solid #d4e1ea; + background: #fff; +} +.tool-button:hover { + color: #177fc2; + border-color: #add5ee; + background: #f6fbfe; +} +.run-button { + min-width: 111px; + color: white; + border: 1px solid #1687ce; + background: #1a8fda; + box-shadow: 0 4px 12px rgb(26 143 218 / 18%); +} +.run-button:hover { + background: #117fc5; +} +.run-button:disabled, +.tool-button:disabled { + cursor: wait; + opacity: 0.58; +} +.run-button kbd { + padding-left: 7px; + color: rgb(255 255 255 / 70%); + border: 0; + border-left: 1px solid rgb(255 255 255 / 24%); + font: + 8px/1 ui-monospace, + monospace; +} -.playground-tools { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 0 10px; border-bottom: 1px solid #cfdce6; background: #f6f9fb; } -.tool-group { display: flex; align-items: center; gap: 2px; } -.tool-group button { display: inline-flex; height: 29px; align-items: center; gap: 6px; padding: 0 9px; color: #586d80; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 10px; } -.tool-group button:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; } -.braces { color: #2498df; font: 700 10px/1 ui-monospace, monospace; } -.graph-tool-hint { display: inline-flex; height: 27px; align-items: center; gap: 5px; padding: 0 9px; color: #6b8193; font-size: 9px; } -.graph-tool-hint b { color: #258dcf; font-weight: 700; } -.tool-context { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: center; gap: 7px; color: #748698; font-size: 9px; } -.language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; } -.context-divider { width: 1px; height: 12px; margin-inline: 4px; background: #cfdae3; } -#type-status { overflow: hidden; max-width: 250px; text-overflow: ellipsis; white-space: nowrap; } -.tool-group-right { margin-left: auto; } +.playground-tools { + display: flex; + min-width: 0; + align-items: center; + gap: 14px; + padding: 0 10px; + border-bottom: 1px solid #cfdce6; + background: #f6f9fb; +} +.tool-group { + display: flex; + align-items: center; + gap: 2px; +} +.tool-group button { + display: inline-flex; + height: 29px; + align-items: center; + gap: 6px; + padding: 0 9px; + color: #586d80; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + cursor: pointer; + font-size: 10px; +} +.tool-group button:hover, +.tool-group button[aria-pressed="true"] { + color: #187fc2; + border-color: #d3e3ed; + background: #fff; +} +.braces { + color: #2498df; + font: + 700 10px/1 ui-monospace, + monospace; +} +.graph-tool-hint { + display: inline-flex; + height: 27px; + align-items: center; + gap: 5px; + padding: 0 9px; + color: #6b8193; + font-size: 9px; +} +.graph-tool-hint b { + color: #258dcf; + font-weight: 700; +} +.tool-context { + display: flex; + min-width: 0; + flex: 1; + align-items: center; + justify-content: center; + gap: 7px; + color: #748698; + font-size: 9px; +} +.language-dot { + width: 6px; + height: 6px; + border-radius: 2px; + background: #2d9de3; +} +.context-divider { + width: 1px; + height: 12px; + margin-inline: 4px; + background: #cfdae3; +} +#type-status { + overflow: hidden; + max-width: 250px; + text-overflow: ellipsis; + white-space: nowrap; +} +.tool-group-right { + margin-left: auto; +} -.workbench { --editor-width: 50%; display: grid; min-width: 0; min-height: 0; grid-template-columns: minmax(330px, var(--editor-width)) 5px minmax(330px, 1fr); background: #0b1725; } -.editor-pane, .stage-pane { min-width: 0; min-height: 0; } -.editor-pane { display: grid; grid-template-rows: 37px minmax(0, 1fr) 24px; } -.pane-tabs, .stage-toolbar { display: flex; min-width: 0; align-items: stretch; border-bottom: 1px solid #1d3245; background: #112233; } -.pane-tab { display: flex; min-width: 145px; align-items: center; gap: 7px; padding: 0 14px; color: #9db1c2; border: 0; border-right: 1px solid #22384b; background: #0d1d2d; font-size: 10px; } -.pane-tab.active { color: #d4e2ed; border-top: 2px solid #2ba5ea; background: #0b1927; } -.ts-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #fff; border-radius: 2px; background: #278dcd; font: 700 7px/1 ui-monospace, monospace; } -.rg-tab-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #b8e6fb; border: 1px solid #277da9; border-radius: 3px; background: #16425c; font-size: 10px; } -#graph-tab-dirty { display: none; width: 6px; height: 6px; border-radius: 50%; background: #4fbceb; } -#graph-tab-dirty.visible { display: inline-block; } -.coding-panel { min-height: 0; } -.editor { position: relative; min-height: 0; overflow: hidden; background: #0b1927; } -.editor-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; } -.monaco-editor .margin { background-color: #0b1927 !important; } -.editor-status { display: flex; align-items: center; gap: 14px; padding: 0 10px; color: #6f8599; border-top: 1px solid #1b3042; background: #0c1d2c; font: 8px/1 ui-monospace, monospace; } -.status-spacer { flex: 1; } -#editor-diagnostics { display: flex; align-items: center; gap: 5px; } -#editor-diagnostics i { width: 5px; height: 5px; border-radius: 50%; background: #4aa975; } -#editor-diagnostics.has-errors { color: #e8a4a4; } -#editor-diagnostics.has-errors i { background: #e26f6f; } -.render-graph-editor { display: grid; min-height: 0; grid-template-rows: 46px minmax(0, 1fr); overflow: hidden; background: #081522; } -.render-graph-toolbar { position: relative; z-index: 2; display: flex; min-width: 0; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid #1d3548; background: #0d2030; box-shadow: 0 5px 18px rgb(0 0 0 / 10%); } -.render-graph-toolbar label { position: relative; display: flex; min-width: 0; height: 31px; align-items: center; } -.render-graph-toolbar label > span { position: absolute; z-index: 1; top: 4px; left: 9px; color: #54758c; font: 700 6px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; pointer-events: none; } -.render-graph-toolbar select, .render-graph-toolbar input { height: 31px; padding: 11px 28px 3px 9px; color: #c2d7e5; border: 1px solid #29465c; border-radius: 5px; outline: 0; background: #10283a; font-size: 9px; } -.render-graph-toolbar select { width: 138px; cursor: pointer; } -.render-graph-toolbar input { width: 120px; padding-right: 8px; } -.render-graph-toolbar select:focus, .render-graph-toolbar input:focus { border-color: #318fc8; box-shadow: 0 0 0 2px rgb(49 155 215 / 12%); } -.render-graph-toolbar button { display: inline-flex; height: 31px; flex: 0 0 auto; align-items: center; gap: 5px; padding: 0 10px; color: #dff4ff; border: 1px solid #238ac8; border-radius: 5px; background: #187cb7; cursor: pointer; font-size: 8px; font-weight: 700; } -.render-graph-toolbar button:hover { background: #208dca; } -.render-graph-toolbar button.secondary { color: #a9c7d9; border-color: #31536a; background: #132d3f; } -.render-graph-toolbar button.secondary:hover { color: #e0f4ff; border-color: #39779c; background: #18394f; } -.render-graph-toolbar button:disabled { cursor: wait; opacity: .55; } -#loadout-status { display: inline-flex; min-width: 0; align-items: center; gap: 6px; margin-left: auto; overflow: hidden; color: #7590a3; font: 7px/1 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; } -#loadout-status i { width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; background: #44ad78; box-shadow: 0 0 0 3px rgb(68 173 120 / 10%); } -#loadout-status.busy i { background: #3aa9e5; animation: status-pulse 1s infinite; } -#loadout-status.failed { color: #df9696; } -#loadout-status.failed i { background: #df6c6c; } -.render-graph-canvas-wrap { position: relative; min-width: 0; min-height: 0; overflow: hidden; } -#render-graph-canvas { display: block; width: 100%; height: 100%; outline: 0; touch-action: none; } -#render-graph-canvas:focus-visible { box-shadow: inset 0 0 0 1px #3ba8e4; } -.render-graph-empty-hint { position: absolute; right: 12px; bottom: 12px; display: grid; grid-template-columns: 24px auto; gap: 2px 8px; align-items: center; padding: 8px 10px; color: #718b9f; border: 1px solid #203e53; border-radius: 7px; background: rgb(9 27 42 / 86%); box-shadow: 0 8px 25px rgb(0 0 0 / 16%); pointer-events: none; backdrop-filter: blur(6px); } -.render-graph-empty-hint > span { display: grid; width: 24px; height: 24px; grid-row: 1 / 3; place-items: center; color: #50b9ed; border: 1px solid #295b77; border-radius: 5px; background: #0f3046; } -.render-graph-empty-hint strong { color: #9bb4c6; font-size: 8px; } -.render-graph-empty-hint small { color: #577085; font-size: 7px; } +.workbench { + --editor-width: 50%; + display: grid; + min-width: 0; + min-height: 0; + grid-template-columns: minmax(330px, var(--editor-width)) 5px minmax( + 330px, + 1fr + ); + background: #0b1725; +} +.editor-pane, +.stage-pane { + min-width: 0; + min-height: 0; +} +.editor-pane { + display: grid; + grid-template-rows: 37px minmax(0, 1fr) 24px; +} +.pane-tabs, +.stage-toolbar { + display: flex; + min-width: 0; + align-items: stretch; + border-bottom: 1px solid #1d3245; + background: #112233; +} +.pane-tab { + display: flex; + min-width: 145px; + align-items: center; + gap: 7px; + padding: 0 14px; + color: #9db1c2; + border: 0; + border-right: 1px solid #22384b; + background: #0d1d2d; + font-size: 10px; +} +.pane-tab.active { + color: #d4e2ed; + border-top: 2px solid #2ba5ea; + background: #0b1927; +} +.ts-icon, +.wgsl-icon { + display: grid; + width: 18px; + height: 16px; + place-items: center; + color: #fff; + border-radius: 2px; + background: #278dcd; + font: + 700 7px/1 ui-monospace, + monospace; +} +.wgsl-icon { + color: #f2dcff; + border: 1px solid #72558b; + background: #49355f; + font-size: 6px; +} +.pane-tab:disabled { + cursor: not-allowed; + opacity: 0.42; +} +.rg-tab-icon { + display: grid; + width: 18px; + height: 16px; + place-items: center; + color: #b8e6fb; + border: 1px solid #277da9; + border-radius: 3px; + background: #16425c; + font-size: 10px; +} +#graph-tab-dirty { + display: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: #4fbceb; +} +#graph-tab-dirty.visible { + display: inline-block; +} +.coding-panel { + min-height: 0; +} +.editor { + position: relative; + min-height: 0; + overflow: hidden; + background: #0b1927; +} +.editor-loading { + display: grid; + height: 100%; + place-items: center; + color: #668097; + font: + 10px/1 ui-monospace, + monospace; +} +.monaco-editor .margin { + background-color: #0b1927 !important; +} +.editor-status { + display: flex; + align-items: center; + gap: 14px; + padding: 0 10px; + color: #6f8599; + border-top: 1px solid #1b3042; + background: #0c1d2c; + font: + 8px/1 ui-monospace, + monospace; +} +.status-spacer { + flex: 1; +} +#editor-diagnostics { + display: flex; + align-items: center; + gap: 5px; +} +#editor-diagnostics i { + width: 5px; + height: 5px; + border-radius: 50%; + background: #4aa975; +} +#editor-diagnostics.has-errors { + color: #e8a4a4; +} +#editor-diagnostics.has-errors i { + background: #e26f6f; +} +.render-graph-editor { + display: grid; + min-height: 0; + grid-template-rows: 46px minmax(0, 1fr); + overflow: hidden; + background: #081522; +} +.render-graph-toolbar { + position: relative; + z-index: 2; + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-bottom: 1px solid #1d3548; + background: #0d2030; + box-shadow: 0 5px 18px rgb(0 0 0 / 10%); +} +.render-graph-toolbar label { + position: relative; + display: flex; + min-width: 0; + height: 31px; + align-items: center; +} +.render-graph-toolbar label > span { + position: absolute; + z-index: 1; + top: 4px; + left: 9px; + color: #54758c; + font: + 700 6px/1 ui-monospace, + monospace; + letter-spacing: 0.08em; + text-transform: uppercase; + pointer-events: none; +} +.render-graph-toolbar select, +.render-graph-toolbar input { + height: 31px; + padding: 11px 28px 3px 9px; + color: #c2d7e5; + border: 1px solid #29465c; + border-radius: 5px; + outline: 0; + background: #10283a; + font-size: 9px; +} +.render-graph-toolbar select { + width: 138px; + cursor: pointer; +} +.render-graph-toolbar input { + width: 120px; + padding-right: 8px; +} +.render-graph-toolbar select:focus, +.render-graph-toolbar input:focus { + border-color: #318fc8; + box-shadow: 0 0 0 2px rgb(49 155 215 / 12%); +} +.render-graph-toolbar button { + display: inline-flex; + height: 31px; + flex: 0 0 auto; + align-items: center; + gap: 5px; + padding: 0 10px; + color: #dff4ff; + border: 1px solid #238ac8; + border-radius: 5px; + background: #187cb7; + cursor: pointer; + font-size: 8px; + font-weight: 700; +} +.render-graph-toolbar button:hover { + background: #208dca; +} +.render-graph-toolbar button.secondary { + color: #a9c7d9; + border-color: #31536a; + background: #132d3f; +} +.render-graph-toolbar button.secondary:hover { + color: #e0f4ff; + border-color: #39779c; + background: #18394f; +} +.render-graph-toolbar button:disabled { + cursor: wait; + opacity: 0.55; +} +#loadout-status { + display: inline-flex; + min-width: 0; + align-items: center; + gap: 6px; + margin-left: auto; + overflow: hidden; + color: #7590a3; + font: + 7px/1 ui-monospace, + monospace; + text-overflow: ellipsis; + white-space: nowrap; +} +#loadout-status i { + width: 5px; + height: 5px; + flex: 0 0 auto; + border-radius: 50%; + background: #44ad78; + box-shadow: 0 0 0 3px rgb(68 173 120 / 10%); +} +#loadout-status.busy i { + background: #3aa9e5; + animation: status-pulse 1s infinite; +} +#loadout-status.failed { + color: #df9696; +} +#loadout-status.failed i { + background: #df6c6c; +} +.render-graph-canvas-wrap { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; +} +#render-graph-canvas { + display: block; + width: 100%; + height: 100%; + outline: 0; + touch-action: none; +} +#render-graph-canvas:focus-visible { + box-shadow: inset 0 0 0 1px #3ba8e4; +} -.rg-node-menu { position: fixed; z-index: 250; width: 226px; padding: 7px; visibility: hidden; color: #bcd0de; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 40%); font-size: 9px; } -.rg-node-menu::before { position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 1px rgb(255 255 255 / 4%); content: ""; pointer-events: none; } -.rg-menu-search { display: flex; height: 31px; align-items: center; gap: 7px; margin-bottom: 6px; padding: 0 8px; color: #5d8098; border: 1px solid #28465b; border-radius: 5px; background: #091927; } -.rg-menu-search input { min-width: 0; flex: 1; color: #cce1ed; border: 0; outline: 0; background: transparent; font-size: 9px; } -.rg-menu-search input::placeholder { color: #557186; } -.rg-menu-groups { display: grid; gap: 2px; } -.rg-menu-group { position: relative; } -.rg-menu-group-trigger { display: grid; width: 100%; min-height: 34px; grid-template-columns: 24px 1fr auto; align-items: center; gap: 7px; padding: 0 7px; color: #a6bfce; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: default; } -.rg-menu-group-trigger:hover, .rg-menu-group.open > .rg-menu-group-trigger { color: #d9effb; border-color: #294b62; background: #153246; } -.rg-menu-group-icon { display: grid; width: 21px; height: 21px; place-items: center; color: #60c3f2; border: 1px solid #2a5a75; border-radius: 4px; background: #0e2a3e; font-size: 10px; } -.rg-menu-group-trigger small { color: #557287; font-size: 7px; } -.rg-menu-chevron { color: #56788f; } -.rg-menu-submenu { position: absolute; top: -7px; left: calc(100% + 7px); display: none; width: 252px; max-height: min(390px, calc(100vh - 20px)); padding: 7px; overflow-y: auto; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 38%); } -.rg-node-menu.open-left .rg-menu-submenu { right: calc(100% + 7px); left: auto; } -.rg-menu-group.open > .rg-menu-submenu { display: grid; gap: 2px; } -.rg-menu-group:nth-child(n + 4) .rg-menu-submenu { top: auto; bottom: -7px; } -.rg-menu-submenu::before { position: absolute; top: 0; right: 100%; width: 8px; height: 42px; content: ""; } -.rg-menu-submenu > strong { padding: 5px 7px 7px; color: #5f8198; font: 700 7px/1 ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; } -.rg-menu-option { display: grid; min-height: 42px; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 6px 8px; color: #a9c0cf; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; } -.rg-menu-option:hover, .rg-menu-option.active { color: #def3ff; border-color: #2b526a; background: #15364c; } -.rg-menu-option span { font-weight: 650; } -.rg-menu-option small { grid-column: 1 / 3; color: #607b8f; font-size: 7px; line-height: 1.3; } -.rg-menu-option b { color: #48b6eb; font-size: 11px; font-weight: 400; } -.rg-menu-results { display: grid; gap: 2px; max-height: min(390px, calc(100vh - 75px)); overflow-y: auto; } -.rg-menu-empty { padding: 17px 9px; color: #657e91; text-align: center; font-size: 8px; } -.resize-handle { position: relative; z-index: 3; cursor: col-resize; background: #172a3b; } -.resize-handle::after { position: absolute; top: 50%; left: 1px; width: 3px; height: 40px; transform: translateY(-50%); border-radius: 3px; background: #355067; content: ""; opacity: .65; } -.resize-handle:hover, .resize-handle.dragging { background: #218ed2; } +.shader-workspace { + position: relative; + display: grid; + min-height: 0; + grid-template-rows: 46px minmax(0, 1fr); + overflow: hidden; + background: #0b1927; +} +.shader-toolbar { + display: grid; + min-width: 0; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 9px; + padding: 0 12px; + color: #668096; + border-bottom: 1px solid #1d3548; + background: #0d2030; + font: + 8px/1 ui-monospace, + monospace; +} +.shader-toolbar > span { + color: #8a67a4; + font-weight: 700; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.shader-toolbar strong { + overflow: hidden; + color: #c9d9e5; + font-size: 9px; + text-overflow: ellipsis; + white-space: nowrap; +} +.shader-toolbar small { + color: #587185; + font-size: 7px; + white-space: nowrap; +} +.shader-editor { + min-height: 0; + overflow: hidden; +} +.shader-empty { + position: absolute; + inset: 46px 0 0; + display: grid; + align-content: center; + justify-items: center; + gap: 8px; + color: #6f8799; + background: #0b1927; + text-align: center; + pointer-events: none; +} +.shader-empty > span { + display: grid; + width: 45px; + height: 34px; + place-items: center; + color: #d8bbed; + border: 1px solid #5a4770; + border-radius: 7px; + background: #2b243b; + font: + 800 9px/1 ui-monospace, + monospace; +} +.shader-empty strong { + color: #aabfce; + font-size: 10px; +} +.shader-empty small { + color: #5b7285; + font-size: 8px; +} -.stage-pane { display: grid; grid-template-rows: 37px minmax(230px, 1fr) 190px; background: #091521; } -.stage-toolbar { justify-content: space-between; } -.stage-tabs button { height: 100%; padding: 0 14px; color: #8da3b6; border: 0; border-top: 2px solid transparent; background: transparent; font-size: 10px; } -.stage-tabs button.active { color: #d4e3ee; border-top-color: #35a9ed; background: #0d1d2c; } -.stage-meta { display: flex; min-width: 0; align-items: center; gap: 10px; padding-right: 12px; color: #6e869a; font: 8px/1 ui-monospace, monospace; } -#run-status { display: flex; align-items: center; gap: 6px; } -#run-status i { width: 6px; height: 6px; border-radius: 50%; background: #4cad79; box-shadow: 0 0 0 3px rgb(76 173 121 / 10%); } -#run-status.running i { background: #36a8e8; animation: status-pulse 1s infinite; } -#run-status.failed { color: #e49a9a; } -#run-status.failed i { background: #e36d6d; } -.stage-divider { width: 1px; height: 12px; background: #294052; } -.preview { position: relative; min-width: 0; min-height: 0; overflow: hidden; background: radial-gradient(circle at 50% 45%, #102b43, #06111d 72%); } -.preview::before { position: absolute; inset: 0; background-image: linear-gradient(rgb(82 143 182 / 5%) 1px, transparent 1px), linear-gradient(90deg, rgb(82 143 182 / 5%) 1px, transparent 1px); background-size: 32px 32px; content: ""; pointer-events: none; } -.preview canvas { position: relative; display: block; width: 100%; height: 100%; object-fit: contain; touch-action: none; } -.preview-message { position: absolute; top: 50%; left: 50%; display: grid; justify-items: center; gap: 7px; transform: translate(-50%, -50%); color: #93aabd; text-align: center; pointer-events: none; } -.preview-message[hidden] { display: none; } -.preview-message-icon { display: grid; width: 43px; height: 43px; margin-bottom: 7px; place-items: center; color: #54bdf6; border: 1px solid #25577a; border-radius: 12px; background: #0b2941; box-shadow: 0 0 30px rgb(46 171 238 / 12%); font-size: 16px; font-weight: 800; } -.preview-message strong { color: #b9cddd; font-size: 11px; } -.preview-message small { color: #627a8f; font-size: 9px; } +.rg-node-menu { + position: fixed; + z-index: 250; + width: 226px; + padding: 7px; + visibility: hidden; + color: #bcd0de; + border: 1px solid #2d4c61; + border-radius: 8px; + background: #0d2030; + box-shadow: 0 18px 50px rgb(0 0 0 / 40%); + font-size: 9px; +} +.rg-node-menu::before { + position: absolute; + inset: 0; + border-radius: inherit; + box-shadow: inset 0 1px rgb(255 255 255 / 4%); + content: ""; + pointer-events: none; +} +.rg-menu-search { + display: flex; + height: 31px; + align-items: center; + gap: 7px; + margin-bottom: 6px; + padding: 0 8px; + color: #5d8098; + border: 1px solid #28465b; + border-radius: 5px; + background: #091927; +} +.rg-menu-search input { + min-width: 0; + flex: 1; + color: #cce1ed; + border: 0; + outline: 0; + background: transparent; + font-size: 9px; +} +.rg-menu-search input::placeholder { + color: #557186; +} +.rg-menu-groups { + display: grid; + gap: 2px; +} +.rg-menu-group { + position: relative; +} +.rg-menu-group-trigger { + display: grid; + width: 100%; + min-height: 34px; + grid-template-columns: 24px 1fr auto; + align-items: center; + gap: 7px; + padding: 0 7px; + color: #a6bfce; + text-align: left; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + cursor: default; +} +.rg-menu-group-trigger:hover, +.rg-menu-group.open > .rg-menu-group-trigger { + color: #d9effb; + border-color: #294b62; + background: #153246; +} +.rg-menu-group-icon { + display: grid; + width: 21px; + height: 21px; + place-items: center; + color: #60c3f2; + border: 1px solid #2a5a75; + border-radius: 4px; + background: #0e2a3e; + font-size: 10px; +} +.rg-menu-group-trigger small { + color: #557287; + font-size: 7px; +} +.rg-menu-chevron { + color: #56788f; +} +.rg-menu-submenu { + position: absolute; + top: -7px; + left: calc(100% + 7px); + display: none; + width: 252px; + max-height: min(390px, calc(100vh - 20px)); + padding: 7px; + overflow-y: auto; + border: 1px solid #2d4c61; + border-radius: 8px; + background: #0d2030; + box-shadow: 0 18px 50px rgb(0 0 0 / 38%); +} +.rg-node-menu.open-left .rg-menu-submenu { + right: calc(100% + 7px); + left: auto; +} +.rg-menu-group.open > .rg-menu-submenu { + display: grid; + gap: 2px; +} +.rg-menu-group:nth-child(n + 4) .rg-menu-submenu { + top: auto; + bottom: -7px; +} +.rg-menu-submenu::before { + position: absolute; + top: 0; + right: 100%; + width: 8px; + height: 42px; + content: ""; +} +.rg-menu-submenu > strong { + padding: 5px 7px 7px; + color: #5f8198; + font: + 700 7px/1 ui-monospace, + monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} +.rg-menu-option { + display: grid; + min-height: 42px; + grid-template-columns: 1fr auto; + gap: 3px 8px; + padding: 6px 8px; + color: #a9c0cf; + text-align: left; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + cursor: pointer; +} +.rg-menu-option:hover, +.rg-menu-option.active { + color: #def3ff; + border-color: #2b526a; + background: #15364c; +} +.rg-menu-option span { + font-weight: 650; +} +.rg-menu-option small { + grid-column: 1 / 3; + color: #607b8f; + font-size: 7px; + line-height: 1.3; +} +.rg-menu-option b { + color: #48b6eb; + font-size: 11px; + font-weight: 400; +} +.rg-menu-results { + display: grid; + gap: 2px; + max-height: min(390px, calc(100vh - 75px)); + overflow-y: auto; +} +.rg-menu-empty { + padding: 17px 9px; + color: #657e91; + text-align: center; + font-size: 8px; +} +.resize-handle { + position: relative; + z-index: 3; + cursor: col-resize; + background: #172a3b; +} +.resize-handle::after { + position: absolute; + top: 50%; + left: 1px; + width: 3px; + height: 40px; + transform: translateY(-50%); + border-radius: 3px; + background: #355067; + content: ""; + opacity: 0.65; +} +.resize-handle:hover, +.resize-handle.dragging { + background: #218ed2; +} -.inspector { min-height: 0; border-top: 1px solid #22384b; background: #0a1825; } -.inspector.collapsed { display: none; } -.stage-pane:has(.inspector.collapsed) { grid-template-rows: 37px minmax(230px, 1fr) 0; } -.inspector-tabs { display: flex; height: 31px; align-items: stretch; border-bottom: 1px solid #1c3041; background: #0e1e2c; } -.inspector-tabs button { padding: 0 13px; color: #71889b; border: 0; border-bottom: 1px solid transparent; background: transparent; cursor: pointer; font-size: 9px; } -.inspector-tabs button.active { color: #c0d2df; border-bottom-color: #32a8eb; } -.inspector-tabs button span { margin-left: 4px; padding: 1px 5px; border-radius: 5px; background: #1a3245; font-size: 7px; } -.inspector-tabs .clear-console { margin-left: auto; color: #61788b; } -.inspector-panel { display: none; height: calc(100% - 31px); overflow: auto; } -.inspector-panel.active { display: block; } -.console-empty, .profile-empty { display: flex; height: 100%; align-items: center; justify-content: center; gap: 10px; color: #718a9f; font: 9px/1 ui-monospace, monospace; } -.console-empty > span { color: #318fc7; font-size: 12px; } -.console-empty code { color: #90aabd; } -#console-lines { margin: 0; padding: 5px 0; color: #9cb1c1; list-style: none; font: 9px/1.6 ui-monospace, monospace; } -#console-lines li { display: flex; min-height: 22px; align-items: flex-start; gap: 9px; padding: 3px 10px; border-bottom: 1px solid rgb(36 57 73 / 45%); white-space: pre-wrap; word-break: break-word; } -#console-lines li::before { color: #399ed9; content: "›"; } -#console-lines li.error { color: #e19a9a; } -#console-lines li.error::before { color: #dd6b6b; content: "×"; } -.profile-empty { font-family: inherit; } -.profile-empty .pulse-icon { display: grid; width: 30px; height: 30px; place-items: center; color: #48ace4; border: 1px solid #24516f; border-radius: 50%; background: #0e2940; } -.profile-empty > div { display: grid; gap: 4px; } -.profile-empty strong { color: #91a9ba; font-size: 10px; } -.profile-empty small { color: #526b7e; font-size: 8px; } -.profile-results { padding: 10px 12px; color: #8fa6b8; } -.profile-summary { display: grid; grid-template-columns: .7fr .8fr 1.5fr; gap: 8px; margin-bottom: 9px; } -.profile-summary > div { display: grid; gap: 3px; padding: 7px 9px; border: 1px solid #1e384b; border-radius: 5px; background: #0e2130; } -.profile-summary small { color: #587185; font-size: 7px; text-transform: uppercase; } -.profile-summary strong { overflow: hidden; color: #9fc7de; font: 8px/1.3 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; } -.profile-results table { width: 100%; border-collapse: collapse; font: 8px/1.4 ui-monospace, monospace; } -.profile-results th, .profile-results td { padding: 5px 7px; border-bottom: 1px solid #1b3041; text-align: left; } -.profile-results th { color: #506b80; font-weight: 600; } -.profile-results td:last-child, .profile-results th:last-child { color: #56b4e8; text-align: right; } +.stage-pane { + display: grid; + grid-template-rows: 37px minmax(230px, 1fr) 190px; + background: #091521; +} +.stage-toolbar { + justify-content: space-between; +} +.stage-tabs button { + height: 100%; + padding: 0 14px; + color: #8da3b6; + border: 0; + border-top: 2px solid transparent; + background: transparent; + font-size: 10px; +} +.stage-tabs button.active { + color: #d4e3ee; + border-top-color: #35a9ed; + background: #0d1d2c; +} +.stage-meta { + display: flex; + min-width: 0; + align-items: center; + gap: 10px; + padding-right: 12px; + color: #6e869a; + font: + 8px/1 ui-monospace, + monospace; +} +#run-status { + display: flex; + align-items: center; + gap: 6px; +} +#run-status i { + width: 6px; + height: 6px; + border-radius: 50%; + background: #4cad79; + box-shadow: 0 0 0 3px rgb(76 173 121 / 10%); +} +#run-status.running i { + background: #36a8e8; + animation: status-pulse 1s infinite; +} +#run-status.failed { + color: #e49a9a; +} +#run-status.failed i { + background: #e36d6d; +} +.stage-divider { + width: 1px; + height: 12px; + background: #294052; +} +.preview { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: radial-gradient(circle at 50% 45%, #102b43, #06111d 72%); +} +.preview::before { + position: absolute; + inset: 0; + background-image: + linear-gradient(rgb(82 143 182 / 5%) 1px, transparent 1px), + linear-gradient(90deg, rgb(82 143 182 / 5%) 1px, transparent 1px); + background-size: 32px 32px; + content: ""; + pointer-events: none; +} +.preview canvas { + position: relative; + display: block; + width: 100%; + height: 100%; + object-fit: contain; + touch-action: none; +} +.preview-message { + position: absolute; + top: 50%; + left: 50%; + display: grid; + justify-items: center; + gap: 7px; + transform: translate(-50%, -50%); + color: #93aabd; + text-align: center; + pointer-events: none; +} +.preview-message[hidden] { + display: none; +} +.preview-message-icon { + display: grid; + width: 43px; + height: 43px; + margin-bottom: 7px; + place-items: center; + color: #54bdf6; + border: 1px solid #25577a; + border-radius: 12px; + background: #0b2941; + box-shadow: 0 0 30px rgb(46 171 238 / 12%); + font-size: 16px; + font-weight: 800; +} +.preview-message strong { + color: #b9cddd; + font-size: 11px; +} +.preview-message small { + color: #627a8f; + font-size: 9px; +} -.snippets-dialog { width: min(580px, calc(100% - 30px)); padding: 0; color: #18354f; border: 1px solid #cadce8; border-radius: 14px; background: #fff; box-shadow: 0 30px 90px rgb(5 25 42 / 28%); } -.snippets-dialog::backdrop { background: rgb(6 23 38 / 48%); backdrop-filter: blur(3px); } -.dialog-heading { display: flex; align-items: flex-start; justify-content: space-between; padding: 25px 26px 17px; } -.dialog-heading span { color: #1b8ed8; font-size: 8px; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } -.dialog-heading h2 { margin: 5px 0 3px; font-size: 23px; letter-spacing: -.04em; } -.dialog-heading p { margin: 0; color: #798b9c; font-size: 10px; } -.dialog-heading > button { color: #7c8fa0; border: 0; background: transparent; cursor: pointer; font-size: 22px; } -.snippet-search { display: flex; height: 35px; align-items: center; gap: 8px; margin: 0 26px 14px; padding: 0 10px; color: #8a9aaa; border: 1px solid #d8e5ed; border-radius: 7px; background: #f9fbfd; } -.snippet-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; font-size: 10px; } -.snippet-grid { display: grid; gap: 1px; padding: 0 16px 18px; } -.snippet-grid > button { display: grid; min-height: 66px; grid-template-columns: 40px 1fr auto; align-items: center; gap: 12px; padding: 8px 10px; text-align: left; border: 1px solid transparent; border-radius: 8px; background: transparent; cursor: pointer; } -.snippet-grid > button:hover { border-color: #cee5f4; background: #f3faff; } -.snippet-icon { display: grid; width: 36px; height: 36px; place-items: center; color: #168bd3; border: 1px solid #c6e4f6; border-radius: 9px; background: #edf9ff; font-size: 14px; } -.snippet-grid button > div { display: grid; grid-template-columns: auto 1fr; align-items: baseline; gap: 3px 8px; } -.snippet-grid strong { color: #294861; font-size: 11px; } -.snippet-grid small { grid-column: 1; color: #7b8e9f; font-size: 9px; } -.snippet-grid code { grid-column: 2; grid-row: 1 / 3; justify-self: end; padding: 4px 6px; color: #3b82ac; border: 1px solid #d8e8f1; border-radius: 4px; background: #f9fcfe; font: 8px/1 ui-monospace, monospace; } -.snippet-grid b { color: #329cdc; font-size: 16px; } -.toast { position: fixed; z-index: 100; right: 18px; bottom: 18px; max-width: 360px; padding: 11px 15px; transform: translateY(20px); color: #d8e9f4; border: 1px solid #2d5875; border-radius: 7px; background: #102d42; box-shadow: 0 12px 35px rgb(2 17 29 / 24%); font-size: 10px; opacity: 0; pointer-events: none; transition: opacity 180ms ease, transform 180ms ease; } -.toast.visible { transform: none; opacity: 1; } -@keyframes status-pulse { 50% { opacity: .35; } } +.inspector { + min-height: 0; + border-top: 1px solid #22384b; + background: #0a1825; +} +.inspector.collapsed { + display: none; +} +.stage-pane:has(.inspector.collapsed) { + grid-template-rows: 37px minmax(230px, 1fr) 0; +} +.inspector-tabs { + display: flex; + height: 31px; + align-items: stretch; + border-bottom: 1px solid #1c3041; + background: #0e1e2c; +} +.inspector-tabs button { + padding: 0 13px; + color: #71889b; + border: 0; + border-bottom: 1px solid transparent; + background: transparent; + cursor: pointer; + font-size: 9px; +} +.inspector-tabs button.active { + color: #c0d2df; + border-bottom-color: #32a8eb; +} +.inspector-tabs button span { + margin-left: 4px; + padding: 1px 5px; + border-radius: 5px; + background: #1a3245; + font-size: 7px; +} +.inspector-tabs .clear-console { + margin-left: auto; + color: #61788b; +} +.inspector-panel { + display: none; + height: calc(100% - 31px); + overflow: auto; +} +.inspector-panel.active { + display: block; +} +.console-empty, +.profile-empty { + display: flex; + height: 100%; + align-items: center; + justify-content: center; + gap: 10px; + color: #718a9f; + font: + 9px/1 ui-monospace, + monospace; +} +.console-empty > span { + color: #318fc7; + font-size: 12px; +} +.console-empty code { + color: #90aabd; +} +#console-lines { + margin: 0; + padding: 5px 0; + color: #9cb1c1; + list-style: none; + font: + 9px/1.6 ui-monospace, + monospace; +} +#console-lines li { + display: flex; + min-height: 22px; + align-items: flex-start; + gap: 9px; + padding: 3px 10px; + border-bottom: 1px solid rgb(36 57 73 / 45%); + white-space: pre-wrap; + word-break: break-word; +} +#console-lines li::before { + color: #399ed9; + content: "›"; +} +#console-lines li.error { + color: #e19a9a; +} +#console-lines li.error::before { + color: #dd6b6b; + content: "×"; +} +.profile-empty { + font-family: inherit; +} +.profile-empty .pulse-icon { + display: grid; + width: 30px; + height: 30px; + place-items: center; + color: #48ace4; + border: 1px solid #24516f; + border-radius: 50%; + background: #0e2940; +} +.profile-empty > div { + display: grid; + gap: 4px; +} +.profile-empty strong { + color: #91a9ba; + font-size: 10px; +} +.profile-empty small { + color: #526b7e; + font-size: 8px; +} +.profile-results { + padding: 10px 12px; + color: #8fa6b8; +} +.profile-summary { + display: grid; + grid-template-columns: 0.7fr 0.8fr 1.5fr; + gap: 8px; + margin-bottom: 9px; +} +.profile-summary > div { + display: grid; + gap: 3px; + padding: 7px 9px; + border: 1px solid #1e384b; + border-radius: 5px; + background: #0e2130; +} +.profile-summary small { + color: #587185; + font-size: 7px; + text-transform: uppercase; +} +.profile-summary strong { + overflow: hidden; + color: #9fc7de; + font: + 8px/1.3 ui-monospace, + monospace; + text-overflow: ellipsis; + white-space: nowrap; +} +.profile-results table { + width: 100%; + border-collapse: collapse; + font: + 8px/1.4 ui-monospace, + monospace; +} +.profile-results th, +.profile-results td { + padding: 5px 7px; + border-bottom: 1px solid #1b3041; + text-align: left; +} +.profile-results th { + color: #506b80; + font-weight: 600; +} +.profile-results td:last-child, +.profile-results th:last-child { + color: #56b4e8; + text-align: right; +} + +.snippets-dialog { + width: min(580px, calc(100% - 30px)); + padding: 0; + color: #18354f; + border: 1px solid #cadce8; + border-radius: 14px; + background: #fff; + box-shadow: 0 30px 90px rgb(5 25 42 / 28%); +} +.snippets-dialog::backdrop { + background: rgb(6 23 38 / 48%); + backdrop-filter: blur(3px); +} +.dialog-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 25px 26px 17px; +} +.dialog-heading span { + color: #1b8ed8; + font-size: 8px; + font-weight: 800; + letter-spacing: 0.13em; + text-transform: uppercase; +} +.dialog-heading h2 { + margin: 5px 0 3px; + font-size: 23px; + letter-spacing: -0.04em; +} +.dialog-heading p { + margin: 0; + color: #798b9c; + font-size: 10px; +} +.dialog-heading > button { + color: #7c8fa0; + border: 0; + background: transparent; + cursor: pointer; + font-size: 22px; +} +.snippet-search { + display: flex; + height: 35px; + align-items: center; + gap: 8px; + margin: 0 26px 14px; + padding: 0 10px; + color: #8a9aaa; + border: 1px solid #d8e5ed; + border-radius: 7px; + background: #f9fbfd; +} +.snippet-search input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + background: transparent; + font-size: 10px; +} +.snippet-grid { + display: grid; + gap: 1px; + padding: 0 16px 18px; +} +.snippet-grid > button { + display: grid; + min-height: 66px; + grid-template-columns: 40px 1fr auto; + align-items: center; + gap: 12px; + padding: 8px 10px; + text-align: left; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + cursor: pointer; +} +.snippet-grid > button:hover { + border-color: #cee5f4; + background: #f3faff; +} +.snippet-icon { + display: grid; + width: 36px; + height: 36px; + place-items: center; + color: #168bd3; + border: 1px solid #c6e4f6; + border-radius: 9px; + background: #edf9ff; + font-size: 14px; +} +.snippet-grid button > div { + display: grid; + grid-template-columns: auto 1fr; + align-items: baseline; + gap: 3px 8px; +} +.snippet-grid strong { + color: #294861; + font-size: 11px; +} +.snippet-grid small { + grid-column: 1; + color: #7b8e9f; + font-size: 9px; +} +.snippet-grid code { + grid-column: 2; + grid-row: 1 / 3; + justify-self: end; + padding: 4px 6px; + color: #3b82ac; + border: 1px solid #d8e8f1; + border-radius: 4px; + background: #f9fcfe; + font: + 8px/1 ui-monospace, + monospace; +} +.snippet-grid b { + color: #329cdc; + font-size: 16px; +} +.toast { + position: fixed; + z-index: 100; + right: 18px; + bottom: 18px; + max-width: 360px; + padding: 11px 15px; + transform: translateY(20px); + color: #d8e9f4; + border: 1px solid #2d5875; + border-radius: 7px; + background: #102d42; + box-shadow: 0 12px 35px rgb(2 17 29 / 24%); + font-size: 10px; + opacity: 0; + pointer-events: none; + transition: + opacity 180ms ease, + transform 180ms ease; +} +.toast.visible { + transform: none; + opacity: 1; +} +@keyframes status-pulse { + 50% { + opacity: 0.35; + } +} @media (max-width: 800px) { - .playground-header { gap: 8px; padding-inline: 10px; } - .header-divider, .playground-nav, #share-button, .project-name #revision-label { display: none; } - .project-name { min-width: 0; flex: 1; } - .project-name input { width: 100%; } - .tool-button { padding-inline: 8px; } - .run-button { min-width: auto; } - .run-button kbd { display: none; } - .tool-context { display: none; } - .tool-group-right { margin-left: auto; } - .workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: max(560px, 55vh) minmax(500px, 1fr); } - .resize-handle { display: none; } - .stage-pane { min-height: 500px; } - .render-graph-toolbar { overflow-x: auto; } - .loadout-name-field { display: none !important; } - .render-graph-empty-hint { display: none; } - .rg-menu-submenu { position: fixed; top: auto; right: 8px; bottom: 8px; left: 8px; width: auto; max-height: 42vh; } + .playground-header { + gap: 8px; + padding-inline: 10px; + } + .header-divider, + .playground-nav, + #share-button, + .project-name #revision-label { + display: none; + } + .project-name { + min-width: 0; + flex: 1; + } + .project-name input { + width: 100%; + } + .tool-button { + padding-inline: 8px; + } + .run-button { + min-width: auto; + } + .run-button kbd { + display: none; + } + .tool-context { + display: none; + } + .tool-group-right { + margin-left: auto; + } + .workbench { + display: grid; + overflow: auto; + grid-template-columns: 1fr; + grid-template-rows: max(560px, 55vh) minmax(500px, 1fr); + } + .resize-handle { + display: none; + } + .stage-pane { + min-height: 500px; + } + .render-graph-toolbar { + overflow-x: auto; + } + .loadout-name-field { + display: none !important; + } + .rg-menu-submenu { + position: fixed; + top: auto; + right: 8px; + bottom: 8px; + left: 8px; + width: auto; + max-height: 42vh; + } } @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } + *, + *::before, + *::after { + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } } diff --git a/server/web/assets/playground.js b/server/web/assets/playground.js index a71eaae..e299748 100644 --- a/server/web/assets/playground.js +++ b/server/web/assets/playground.js @@ -1,6 +1,8 @@ import { + buildFxNodeRenderGraph, compileFxNodeRenderGraph, createRenderGraphEditor, + materializeRenderGraph, renderGraphNodeGroups, renderGraphPresets, } from "@yawn/fxnode-rg"; @@ -167,7 +169,6 @@ const elements = Object.fromEntries( "project-title", "render-graph-canvas", "render-graph-editor", - "render-graph-hint", "reset-button", "resize-handle", "resolution-status", @@ -175,6 +176,11 @@ const elements = Object.fromEntries( "run-button", "run-status", "save-button", + "shader-editor", + "shader-empty", + "shader-node-name", + "shader-tab", + "shader-workspace", "share-button", "snippets-button", "snippets-dialog", @@ -243,6 +249,8 @@ const packages = Promise.all([ let monaco; let editor; let model; +let shaderEditor; +let shaderModel; let current; let currentID = ""; let currentRevision = 0; @@ -264,9 +272,14 @@ let renderGraphMenuOutsideListener; let graphDirty = false; let loadoutsDirty = false; let suppressGraphChanges = false; +let suppressShaderChanges = false; let customLoadouts = []; +let preparedLoadouts = []; let currentLoadoutID = "beauty"; let compiledRenderGraph = ""; +let selectedShaderNodeID = ""; +let shaderSelectionGeneration = 0; +let shaderWrite = Promise.resolve(); localStorage.removeItem("yawn:playground:loadouts"); localStorage.removeItem("yawn:playground:active-loadout"); @@ -403,6 +416,86 @@ async function createEditor(source) { "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 = ""; model = monaco.editor.createModel( source, @@ -443,6 +536,36 @@ async function createEditor(source) { monaco.editor.onDidChangeMarkers(([resource]) => { 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"; updateDiagnostics(); } @@ -467,6 +590,61 @@ function updateDiagnostics() { : "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") { dirty = value; elements["dirty-indicator"].classList.toggle("visible", value); @@ -483,7 +661,7 @@ function markDirty(value, target = "code") { } function allLoadouts() { - return [...renderGraphPresets, ...customLoadouts]; + return [...preparedLoadouts, ...customLoadouts]; } function activeLoadout() { @@ -495,12 +673,28 @@ function loadoutLayout(loadout = activeLoadout()) { 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() { const builtIn = document.createElement("optgroup"); const saved = document.createElement("optgroup"); builtIn.label = "Prepared loadouts"; saved.label = "Playground loadouts"; - for (const loadout of renderGraphPresets) { + for (const loadout of preparedLoadouts) { const option = document.createElement("option"); option.value = loadout.id; option.textContent = loadout.name; @@ -541,32 +735,34 @@ function stageActiveLoadout(layout) { const currentLoadout = activeLoadout(); if (!currentLoadout) throw new Error("The selected render graph loadout is missing"); - const name = + let name = elements["loadout-name"].value.trim() || `${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; if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`; - const changed = - currentLoadout.builtIn || graphDirty || currentLoadout.name !== name; - const staged = changed - ? { - id, - name: name.slice(0, 64), - builtIn: false, - layout, - } - : currentLoadout; - if (changed) { - const index = customLoadouts.findIndex((loadout) => loadout.id === id); - if (index < 0) customLoadouts.push(staged); - else customLoadouts[index] = staged; - loadoutsDirty = true; - } + const staged = { + id, + name: name.slice(0, 64), + builtIn: false, + layout, + }; + const index = customLoadouts.findIndex((loadout) => loadout.id === id); + if (index < 0) customLoadouts.push(staged); + else customLoadouts[index] = staged; + loadoutsDirty = true; currentLoadoutID = id; graphDirty = false; renderLoadoutOptions(); - if (changed) markDirty(true, "graph"); - else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty); + markDirty(true, "graph"); return staged; } @@ -731,9 +927,15 @@ function openRenderGraphMenu(request) { } async function switchLoadout(id) { - const next = allLoadouts().find((loadout) => loadout.id === id); + let next = allLoadouts().find((loadout) => loadout.id === id); if (!next || !renderGraphEditor) return; + await flushShaderEdits(); 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; try { currentLoadoutID = next.id; @@ -753,6 +955,7 @@ async function applyLoadout() { if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return; elements["apply-loadout-button"].disabled = true; try { + await flushShaderEdits(); const layout = await renderGraphEditor.save(); const staged = stageActiveLoadout(layout); if (model.getValue() !== lastRunSource && !(await run())) @@ -788,6 +991,7 @@ async function copyLoadoutCode() { if (!renderGraphEditor || button.disabled) return; button.disabled = true; try { + await flushShaderEdits(); const layout = await renderGraphEditor.save(); compiledRenderGraph = await compileLoadout(layout); const name = elements["loadout-name"].value.trim() || activeLoadout().name; @@ -805,12 +1009,16 @@ async function copyLoadoutCode() { } async function initializeRenderGraphWorkspace() { + await refreshPreparedLoadouts(); renderLoadoutOptions(); renderGraphEditor = await createRenderGraphEditor({ canvas: elements["render-graph-canvas"], initialLayout: loadoutLayout(), workerUrl: "/pkg/fxnode-worker.js", onChange: markGraphChanged, + onSelectionChange(nodeIds) { + void updateShaderSelection(nodeIds).catch(reportShaderError); + }, onAddNodeMenu: openRenderGraphMenu, onError(error) { const message = error instanceof Error ? error.message : String(error); @@ -820,7 +1028,7 @@ async function initializeRenderGraphWorkspace() { window.__yawnRenderGraph = { editor: renderGraphEditor, layout: loadoutLayout(), - presets: renderGraphPresets.map(({ id, name }) => ({ id, name })), + presets: preparedLoadouts.map(({ id, name }) => ({ id, name })), selected: currentLoadoutID, serialized: compiledRenderGraph, switchLoadout, @@ -838,6 +1046,7 @@ function activateCodingTab(tab) { } elements.editor.hidden = tab !== "scene"; elements["render-graph-editor"].hidden = tab !== "render-graph"; + elements["shader-workspace"].hidden = tab !== "shader"; for (const target of document.querySelectorAll("[data-code-tool]")) target.hidden = tab !== "scene"; for (const target of document.querySelectorAll("[data-graph-tool]")) @@ -852,6 +1061,21 @@ function activateCodingTab(tab) { " Graph valid on save"; elements["editor-diagnostics"].classList.remove("has-errors"); 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 { elements["workspace-language"].textContent = "TypeScript"; const position = editor?.getPosition(); @@ -1104,6 +1328,7 @@ async function save() { if (!editor || elements["save-button"].disabled) return; elements["save-button"].disabled = true; try { + await flushShaderEdits(); const hadLoadoutChanges = loadoutsDirty; let stagedLoadout; if (graphDirty && renderGraphEditor) @@ -1285,16 +1510,23 @@ addEventListener("pagehide", (event) => { cancelAnimationFrame(frameRequest); closeRenderGraphMenu(); void renderGraphEditor?.destroy(); + shaderEditor?.dispose(); + shaderModel?.dispose(); void disposeCurrent(); }); try { const source = await loadInitialPlayground(); await createEditor(source); - await initializeRenderGraphWorkspace(); markDirty(false); sampleFPS(); - await run(); + if (await run()) await initializeRenderGraphWorkspace(); + else + setLoadoutStatus( + "Run a Scene to inspect its physical render graph", + "failed", + ); + markDirty(false); } catch (error) { const message = error instanceof Error ? error.message : String(error); elements.editor.textContent = message; diff --git a/server/web/playground.html b/server/web/playground.html index 0beadad..f97f752 100644 --- a/server/web/playground.html +++ b/server/web/playground.html @@ -125,6 +125,20 @@ render graph +
-
- + - Add another pass - Right click anywhere to browse the node catalog -
+
+ +