From 28c0789b19cecf32c9578d97421c92bda201d213 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 18:46:20 +0200 Subject: [PATCH] Shader graph: Abs node, the distance from 0 per channel Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- src/core/shader/nodes.ts | 7 +++++++ tests/core/shaders.test.ts | 21 +++++++++++++++++++++ tools/e2e/28-shader.mjs | 30 ++++++++++++++++++++++++++++++ 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 58ca89f0..65284d40 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, 762 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 763 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 377b5ec6..1b2d3d82 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -341,6 +341,12 @@ export const ShaderFract = unary( 'fract', 'The part after the decimal point: 2.75 becomes 0.75. A rising value becomes a sawtooth from 0 to 1.', ); +export const ShaderAbs = unary( + 'ShaderAbs', + 'Abs', + 'abs', + 'The distance from 0, sign dropped: -0.3 becomes 0.3. A value crossing 0 becomes a V. Per channel for colours.', +); export const ShaderStep: ShaderNodeDef = { type: 'ShaderStep', @@ -392,6 +398,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderSin, ShaderFloor, ShaderFract, + ShaderAbs, ShaderStep, ShaderPow, ]; diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 9b2c327a..f3214687 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -470,6 +470,27 @@ describe('compileShader', () => { expect(fragment).toMatch(/float n\d+_result = pow\(max\(n\d+_u, 0\.0\), 2\.0\);/); expect(fragment).toMatch(/vec3 n\d+_result = pow\(max\(n\d+_color, 0\.0\), vec3\(2\.2\)\);/); }); + + it('writes Abs as the GLSL call, per channel for a colour', () => { + const f = makeProject(); + const s = shaderEditor(f, 'Fold'); + const uv = s.add(SHADER_UV); + const centred = s.add('ShaderSubtract', { b: 0.5 }); + const folded = s.add('ShaderAbs'); + const tint = s.add('ShaderColor', { value: 0xff8000 }); + const magnitude = s.add('ShaderAbs'); + const output = s.add(SHADER_OUTPUT); + s.link(uv, 'u', centred, 'a'); + s.link(centred, 'result', folded, 'value'); + s.link(folded, 'result', output, 'alpha'); + s.link(tint, 'color', magnitude, 'value'); + s.link(magnitude, 'result', output, 'color'); + const build = compileShader(s.asset().graph); + expect(build.problems).toEqual({}); + const { fragment } = build.compiled!; + expect(fragment).toMatch(/float n\d+_result = abs\(n\d+_result\);/); + expect(fragment).toMatch(/vec3 n\d+_result = abs\(n\d+_color\);/); + }); }); describe('a sprite wearing a shader', () => { diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 1c52edbf..6145f73c 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -127,6 +127,9 @@ export async function shader(t) { // 28c. A Property node: its name is the row a sprite shows; a bad name is a problem on the node. await click(canvas.x + 80, canvas.y + 320, 'right'); await sleep(300); + // Property is the twelfth entry, below the palette's scroll mask: search it into view first. + await typeText('Property'); + await sleep(150); await clickLast('Property'); await sleep(300); const propertyNode = (await madeNodes()).find((n) => n.type === 'ShaderProperty'); @@ -503,6 +506,33 @@ export async function shader(t) { { rootNear, rootFar }, ); + // 28k-iii. Abs folds u around 0.5 into a V: |u - 0.5| is below 0.25 only in the middle half. + await redOrBlue( + [ + node('uv', 'ShaderUV'), + node('centred', 'ShaderSubtract', { b: 0.5 }, 260, 0), + node('fold', 'ShaderAbs', {}, 260, 120), + node('cut', 'ShaderStep', { edge: 0.25 }, 260, 240), + ], + [ + edge('uv', 'u', 'centred', 'a'), + edge('centred', 'result', 'fold', 'value'), + edge('fold', 'result', 'cut', 'value'), + edge('cut', 'result', 'mix', 't'), + ], + ); + await sleep(400); + const foldLeft = await pixel(-0.4, -0.3); + const foldMiddle = await pixel(0, -0.3); + const foldRight = await pixel(0.4, -0.3); + check( + 'Abs folds the cut into a V: red in the middle, blue at both edges', + colourDistance(foldMiddle, '#ff0000') < 6 && + colourDistance(foldLeft, '#0000ff') < 6 && + colourDistance(foldRight, '#0000ff') < 6, + { foldLeft, foldMiddle, foldRight }, + ); + // 28l. Sample texture reads the picture where the graph says. On a pasted two-colour picture: // flipped u swaps the halves; u + 0.5 past the border shows the Output's Edge – Repeat continues // with the far half, Mirror with the near one.