From ccc6dc5b6e81c73fd3612ed793c413d38f3ecd6f Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 12:26:53 +0200 Subject: [PATCH] Shader graph: Number node, a typed constant like Color (#72) No input, one Value param (step 0.05, drawn on the node), output `value` as a float literal via floatLiteral (2 -> 2.0, NaN -> 0.0). No uniform, no sprite row - that is the difference to Property. The e2e negative check for "no behavior node in the palette" now uses Boolean as its marker, and step 28 types the palette search first because the palette scrolls from twelve entries on. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- src/core/shader/nodes.ts | 13 +++++++++++ src/editor/debugHook.ts | 1 + tests/core/shaders.test.ts | 30 ++++++++++++++++++++++++ tools/e2e/28-shader.mjs | 47 +++++++++++++++++++++++++++++++++++--- 5 files changed, 89 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 553c38f4..cd4e35ad 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, 717 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 720 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/nodes.ts b/src/core/shader/nodes.ts index 916b2228..0c3edd1e 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -36,6 +36,7 @@ export const SHADER_TEXTURE_COLOR = 'ShaderTextureColor'; export const SHADER_TEXTURE_ALPHA = 'ShaderTextureAlpha'; export const SHADER_OUTPUT = 'ShaderOutput'; export const SHADER_COLOR = 'ShaderColor'; +export const SHADER_NUMBER = 'ShaderNumber'; /** What the fragment prelude binds: the texel's straight (unpremultiplied) colour. */ export const TEXEL_COLOR: Expr = { code: 'rgb', type: 'vec3' }; @@ -160,6 +161,17 @@ export const ShaderColor: ShaderNodeDef = { }), }; +export const ShaderNumber: ShaderNodeDef = { + type: SHADER_NUMBER, + label: 'Number', + category: 'shader', + description: 'A constant number.', + inputs: [], + outputs: [{ key: 'value', label: 'value', type: 'number' }], + params: [{ key: 'value', label: 'Value', kind: 'number', default: 0, step: NUMBER_STEP }], + emit: (_inputs, params) => ({ value: { code: floatLiteral(Number(params.value ?? 0)), type: 'float' } }), +}; + export const ShaderAdd = arithmetic('ShaderAdd', 'Add', '+', 'a + b, per channel for colours.', 0); export const ShaderSubtract = arithmetic( 'ShaderSubtract', @@ -211,6 +223,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderOutput, ShaderProperty, ShaderColor, + ShaderNumber, ShaderAdd, ShaderSubtract, ShaderMultiply, diff --git a/src/editor/debugHook.ts b/src/editor/debugHook.ts index 96313184..99e7f6c5 100644 --- a/src/editor/debugHook.ts +++ b/src/editor/debugHook.ts @@ -278,6 +278,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E nodeLabel: (nodeId: string) => editor.shaderEditor.canvas.nodeLabel(nodeId), nodeRect: (nodeId: string) => editor.shaderEditor.canvas.nodeRect(nodeId), nodeProblem: (nodeId: string) => editor.shaderEditor.canvas.nodeProblem(nodeId), + nodeSettings: (nodeId: string) => editor.shaderEditor.canvas.nodeSettings(nodeId), canvasRect: () => editor.shaderEditor.canvas.canvasRect(), emptySpot: () => editor.shaderEditor.canvas.emptySpotGlobal(), center: () => centreOf(editor.shaderEditor.canvas), diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index e6b54efd..76de49af 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -25,6 +25,7 @@ import { inlineInputs } from '../../src/core/graph/NodeRegistry'; import { compileShader } from '../../src/core/shader/compile'; import { SHADER_NODES, + SHADER_NUMBER, SHADER_OUTPUT, SHADER_TEXTURE_ALPHA, SHADER_TEXTURE_COLOR, @@ -227,6 +228,35 @@ describe('compileShader', () => { expect(tinted).toMatch(/vec3 n1_result = \(vec3\(1\.0\) \* n0_color\);/); }); + it('turns a Number node into one float literal: decimal point kept, sign kept, NaN made 0', () => { + const f = makeProject(); + const s = shaderEditor(f); + const half = s.add(SHADER_NUMBER, { value: 0.5 }); + const two = s.add(SHADER_NUMBER, { value: 2 }); + const minus = s.add(SHADER_NUMBER, { value: -0.25 }); + const broken = s.add(SHADER_NUMBER, { value: Number.NaN }); + const sum = s.add('ShaderAdd'); + const times = s.add('ShaderMultiply'); + const diff = s.add('ShaderSubtract'); + const output = s.add(SHADER_OUTPUT); + s.link(half, 'value', sum, 'a'); + s.link(two, 'value', sum, 'b'); + s.link(sum, 'result', times, 'a'); + s.link(minus, 'value', times, 'b'); + s.link(times, 'result', diff, 'a'); + s.link(broken, 'value', diff, 'b'); + s.link(diff, 'result', output, 'color'); + const { fragment } = compileShader(s.asset().graph).compiled!; + // Locals are numbered in visiting order; the literal's form is what matters here. + expect(fragment).toMatch(/float n\d+_value = 0\.5;/); + expect(fragment).toMatch(/float n\d+_value = 2\.0;/); + expect(fragment).toMatch(/float n\d+_value = -0\.25;/); + expect(fragment).toMatch(/float n\d+_value = 0\.0;/); + expect(fragment).toMatch(/float n\d+_result = \(n\d+_value \+ n\d+_value\);/); + expect(fragment).toMatch(/clamp\(vec3\(n\d+_result\), 0\.0, 1\.0\)/); + expect(fragment).not.toContain('uniform float u_'); + }); + it('keeps the texture’s alpha until the Output’s alpha is fed, then clamps what feeds it', () => { const f = makeProject(); const s = shaderEditor(f); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index ed63de43..1da50b32 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -86,10 +86,14 @@ export async function shader(t) { const offered = await json('__gm.overlayTexts()'); check( 'right-click offers shader nodes and no behavior node', - ['Texture color', 'Output', 'Property', 'Mix', 'Luminance'].every((text) => offered.includes(text)) && - !offered.some((text) => /On start|Number|Branch|Set state/.test(text)), + ['Texture color', 'Output', 'Property', 'Number', 'Mix', 'Luminance'].every((text) => + offered.includes(text), + ) && !offered.some((text) => /On start|Boolean|Branch|Set state/.test(text)), JSON.stringify(offered), ); + // A dozen entries scroll; searched, the row is the only one and in view. + await typeText('Texture color'); + await sleep(150); await clickLast('Texture color'); await sleep(300); await click(canvas.x + 420, canvas.y + 60, 'right'); @@ -323,7 +327,44 @@ export async function shader(t) { await sleep(300); check('opacity back at 1 draws the pixel from before, byte for byte', (await centre()) === red); - // 28i. Back to None: no filter at all, the plain colour returns bit for bit. + // 28i. A Number node is a constant in the fragment: red times 0.5 is half red; typed back to 1, + // the pixel is the red from before. Its value is a setting row on the node. + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + const out = a.graph.nodes.find((n) => n.type === 'ShaderOutput'); + a.graph.nodes.push( + { id: 'sh-half', type: 'ShaderNumber', x: 200, y: 200, params: { value: 0.5 } }, + { id: 'sh-dim', type: 'ShaderMultiply', x: 400, y: 200, params: {} }); + a.graph.edges = a.graph.edges.filter((e) => e.id !== 'sh-red-edge'); + a.graph.edges.push( + { id: 'sh-dim-a', from: { node: 'sh-red', port: 'color' }, to: { node: 'sh-dim', port: 'a' } }, + { id: 'sh-dim-b', from: { node: 'sh-half', port: 'value' }, to: { node: 'sh-dim', port: 'b' } }, + { id: 'sh-dim-out', from: { node: 'sh-dim', port: 'result' }, to: { node: out.id, port: 'color' } });`); + await sleep(400); + const dimmed = await centre(); + check('a Number of 0.5 into a Multiply halves the red', colourDistance(dimmed, '#800000') < 6, { + red, + dimmed, + }); + await evaluate("__gm.showTab('shader')"); + await sleep(300); + check( + 'the Number node draws its value as a setting row', + (await json("__gm.shader.nodeSettings('sh-half')"))?.join() === 'Value: 0.500', + JSON.stringify(await json("__gm.shader.nodeSettings('sh-half')")), + ); + await shot('28-number-node'); + await evaluate("__gm.showTab('scene')"); + await mutate(` + const a = __gm.state.project.assets.find((x) => x.id === ${JSON.stringify(made.id)}); + a.graph.nodes.find((n) => n.id === 'sh-half').params.value = 1`); + await sleep(400); + check('the Number typed to 1 gives the red from before, byte for byte', (await centre()) === red, { + red, + now: await centre(), + }); + + // 28j. Back to None: no filter at all, the plain colour returns bit for bit. await assetProps({ shader: '' }); await sleep(300); const back = await centre();