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();