Shader graph: Number node, a typed constant like Color (#72)
game-maker / suite (push) Canceled after 6m55s

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 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-13 12:26:53 +02:00
1 parent 4d9c4982eb
commit ccc6dc5b6e
5 files changed
+89 -4

No files matched your search

+1 -1
View File
@@ -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
+13
View File
@@ -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,
+1
View File
@@ -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),
+30
View File
@@ -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);
+44 -3
View File
@@ -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();