From 102a649a35bb10bfde5f5fb6dae5e021e8b3dd79 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 18:00:48 +0200 Subject: [PATCH] Shader library: fingerprint without node positions, a drag no longer recompiles (#65) shaderFingerprint(graph) covers node id/type/params/ports and edge endpoints only; x/y, collapsed, folded and edge ids stay out. ShaderLibrary keys on it and counts compiles (__gm.shaderCompiles()). Measured before: one compile per preview step of a node drag (12 for a harness drag); after: 0, a param change exactly +1. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- src/core/shader/compile.ts | 12 ++++++++++++ src/editor/debugHook.ts | 2 ++ src/viewport/ShaderLibrary.ts | 9 ++++++--- tests/core/shaders.test.ts | 30 ++++++++++++++++++++++++++++-- tools/e2e/28-shader.mjs | 33 +++++++++++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a3bb4b12..1e0b9cad 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 758 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 760 checks (GM_STEPS=20 for one step) node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs node tools/seed-actor.mjs && node tools/smoke-actor.mjs diff --git a/src/core/shader/compile.ts b/src/core/shader/compile.ts index 811aaf09..f7a4791e 100644 --- a/src/core/shader/compile.ts +++ b/src/core/shader/compile.ts @@ -47,6 +47,18 @@ export interface ShaderBuild { * becomes one local per output, in dependency order, and every Property a float uniform. The texel * arrives premultiplied, so the prelude unpremultiplies and the epilogue multiplies back. */ +/** + * What the fragment depends on and nothing else: node ids, types, params and declared ports, and + * which port feeds which. Positions, collapsing and folding are the editor's; they stay out so a + * moved node does not compile again (#65). + */ +export function shaderFingerprint(graph: Graph): string { + return JSON.stringify({ + nodes: graph.nodes.map((n) => [n.id, n.type, n.params, n.ports ?? null]), + edges: graph.edges.map((e) => [e.from.node, e.from.port, e.to.node, e.to.port]), + }); +} + export function compileShader(graph: Graph): ShaderBuild { const problems: Record = {}; const problem = (node: GraphNode, text: string) => { diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index d14d18dd..38035891 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -415,6 +415,8 @@ export function installDebugHook(app: Application, state: EditorState, editor: E }, /** Holds the shader clock at `at` seconds, so a Time shader's pixels can be measured; null releases it. */ shaderTime: (at: number | null) => shaderLibrary.freezeTime(at), + /** Graphs compiled so far; a moved node must not add one (#65). */ + shaderCompiles: () => shaderLibrary.compiles, /** Which views redraw every tick because a Time shader is on screen. */ animates: () => ({ scene: viewport.animates, game: gameView.animates }), lowLatency: () => diff --git a/src/viewport/ShaderLibrary.ts b/src/viewport/ShaderLibrary.ts index 1f0a842f..0e0ffdf2 100644 --- a/src/viewport/ShaderLibrary.ts +++ b/src/viewport/ShaderLibrary.ts @@ -1,14 +1,16 @@ import type { ShaderAsset } from '../core/model/Asset'; import { findAssetOf, type Project } from '../core/model/Project'; -import { compileShader, type ShaderBuild } from '../core/shader/compile'; +import { compileShader, shaderFingerprint, type ShaderBuild } from '../core/shader/compile'; import type { ShaderSource } from './shaders'; /** * Compiled shader graphs, one per asset, recompiled when the graph's content changes. A graph is - * edited in place, so the cache keys on its serialized form rather than on its identity. + * edited in place, so the cache keys on a fingerprint of what the fragment depends on. */ export class ShaderLibrary { private readonly builds = new Map(); + /** How often a graph went through the compiler – what the checks read to prove a move did not. */ + compiles = 0; private readonly started = performance.now(); /** Set from the test hook: the clock stands at this second until released. */ private frozenAt: number | null = null; @@ -23,9 +25,10 @@ export class ShaderLibrary { } buildFor(asset: ShaderAsset): ShaderBuild { - const fingerprint = JSON.stringify(asset.graph); + const fingerprint = shaderFingerprint(asset.graph); const cached = this.builds.get(asset.id); if (cached && cached.fingerprint === fingerprint) return cached.build; + this.compiles += 1; const build = compileShader(asset.graph); this.builds.set(asset.id, { fingerprint, build }); return build; diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 8310ab5e..7ab58654 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -5,7 +5,14 @@ */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, SetAssetPropertyCommand } from '../../src/core/commands/assetCommands'; -import { AddNodeCommand, ConnectCommand } from '../../src/core/commands/graphCommands'; +import { + AddNodeCommand, + CollapseNodesCommand, + ConnectCommand, + FoldEdgeCommand, + MoveNodeCommand, + SetNodeParamCommand, +} from '../../src/core/commands/graphCommands'; import { SetOverrideCommand } from '../../src/core/commands/sceneObjectCommands'; import type { Graph } from '../../src/core/graph'; import { BUILT_IN_NODES } from '../../src/core/graph/nodes'; @@ -27,7 +34,7 @@ import { shaderParamDefs, } from '../../src/core/properties/shaders'; import { inlineInputs } from '../../src/core/graph/NodeRegistry'; -import { compileShader } from '../../src/core/shader/compile'; +import { compileShader, shaderFingerprint } from '../../src/core/shader/compile'; import { SHADER_NODES, SHADER_NUMBER, @@ -567,3 +574,22 @@ describe('a sprite wearing a shader', () => { expect(shader.graph.nodes.map((n) => n.id)).toEqual(['o']); }); }); + +describe('the fingerprint the shader library caches on', () => { + it('ignores a move, a collapse and a fold, and changes with a param or an edge', () => { + const f = makeProject(); + const s = shaderEditor(f); + const n = buildAdjustColors(s); + const before = shaderFingerprint(s.asset().graph); + f.stack.execute(new MoveNodeCommand(s.id, n.bright, 300, 200)); + f.stack.execute(new CollapseNodesCommand(s.id, [n.bright, n.luma], true)); + f.stack.execute(new FoldEdgeCommand(s.id, s.asset().graph.edges[0]!.id, true)); + expect(s.asset().graph.nodes.find((x) => x.id === n.bright)).toMatchObject({ x: 300, collapsed: true }); + expect(shaderFingerprint(s.asset().graph)).toBe(before); + f.stack.execute(new SetNodeParamCommand(s.id, n.shifted, 'b', 0.25)); + const withParam = shaderFingerprint(s.asset().graph); + expect(withParam).not.toBe(before); + s.link(n.brightness, 'value', n.output, 'alpha'); + expect(shaderFingerprint(s.asset().graph)).not.toBe(withParam); + }); +}); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 0cbbdee5..0ad01587 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -18,6 +18,7 @@ export async function shader(t) { json, evaluate, mutate, + drag, click, clickText, locate, @@ -162,6 +163,38 @@ export async function shader(t) { a.graph.nodes.find((n) => n.id === ${JSON.stringify(propertyNode?.id)}).params.name = 'glow';`); await sleep(300); + // 28c-ii. Where a node sits is the editor's business: dropping it somewhere else compiles nothing + // again, a changed param does – once (#65). + const nodeRef = JSON.stringify(propertyNode?.id); + const compiledBefore = await json('__gm.shaderCompiles()'); + const grab = await json(`__gm.shader.nodeAt(${nodeRef})`); + await drag(grab.x, grab.y, grab.x + 60, grab.y + 40); + await sleep(300); + const dropped = await json(`__gm.shader.nodeAt(${nodeRef})`); + const compiledAfterMove = await json('__gm.shaderCompiles()'); + check( + 'dropping a node somewhere else moves it without compiling the graph again', + dropped.x > grab.x + 30 && compiledAfterMove === compiledBefore, + JSON.stringify({ grab, dropped, compiledBefore, compiledAfterMove }), + ); + const defaultBefore = await json( + `__gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}).graph.nodes.find((n) => n.id === ${nodeRef}).params.default`, + ); + const setDefault = (value) => + mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + a.graph.nodes.find((n) => n.id === ${nodeRef}).params.default = ${JSON.stringify(value)};`); + await setDefault(2); + await sleep(300); + const compiledAfterParam = await json('__gm.shaderCompiles()'); + check( + 'changing a param compiles it again, once', + compiledAfterParam === compiledAfterMove + 1, + JSON.stringify({ compiledAfterMove, compiledAfterParam }), + ); + await setDefault(defaultBefore); + await sleep(300); + // 28d. A flat sprite on the camera, drawn on top of everything; its Shader dropdown lists the assets. await mutate(` const project = __gm.state.project;