From a399795772e532064a5500884a236cc513a8e50a Mon Sep 17 00:00:00 2001 From: root Date: Sun, 13 Sep 2026 18:29:04 +0200 Subject: [PATCH] Shader graph: Pow node, base held at 0, exponent broadcast for colours (#80) Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- src/core/shader/nodes.ts | 17 +++++++++++++++++ tests/core/shaders.test.ts | 19 +++++++++++++++++++ tools/e2e/28-shader.mjs | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e0b9cad..58ca89f0 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, 760 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 762 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 f4988f49..377b5ec6 100644 --- a/src/core/shader/nodes.ts +++ b/src/core/shader/nodes.ts @@ -355,6 +355,22 @@ export const ShaderStep: ShaderNodeDef = { }), }; +/** GLSL leaves `pow` undefined below 0, so the base is held at 0 there. */ +export const ShaderPow: ShaderNodeDef = { + type: 'ShaderPow', + label: 'Pow', + category: 'shader', + description: + 'value to the power of exponent: 2 squares it, 0.5 is the square root. Below 0 the value counts as 0. Per channel for colours.', + inputs: [anyIn('value', 0), floatIn('exponent', 2)], + outputs: [anyOut('result')], + params: [], + emit: ({ value, exponent }) => { + const power = value!.type === 'vec3' ? `vec3(${exponent!.code})` : exponent!.code; + return { result: { code: `pow(max(${value!.code}, 0.0), ${power})`, type: value!.type } }; + }, +}; + /** Every node a shader graph offers. Registered by `graph/nodes`, offered by no behavior palette. */ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderTextureColor, @@ -377,6 +393,7 @@ export const SHADER_NODES: readonly ShaderNodeDef[] = [ ShaderFloor, ShaderFract, ShaderStep, + ShaderPow, ]; /** A GLSL float literal: always with a decimal point, so `1` does not become an int. */ diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 7ab58654..9b2c327a 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -451,6 +451,25 @@ describe('compileShader', () => { expect(fragment).toMatch(/vec3 n\d+_result = floor\(n\d+_color\);/); expect(fragment).toMatch(/float alpha = clamp\(n\d+_result, 0\.0, 1\.0\);/); }); + + it('writes Pow as the GLSL call with the base held at 0, the exponent broadcast for a colour', () => { + const f = makeProject(); + const s = shaderEditor(f, 'Gamma'); + const uv = s.add(SHADER_UV); + const eased = s.add('ShaderPow', { exponent: 2 }); + const tint = s.add('ShaderColor', { value: 0xff8000 }); + const gamma = s.add('ShaderPow', { exponent: 2.2 }); + const output = s.add(SHADER_OUTPUT); + s.link(uv, 'u', eased, 'value'); + s.link(eased, 'result', output, 'alpha'); + s.link(tint, 'color', gamma, 'value'); + s.link(gamma, 'result', output, 'color'); + const build = compileShader(s.asset().graph); + expect(build.problems).toEqual({}); + const { fragment } = build.compiled!; + 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\)\);/); + }); }); describe('a sprite wearing a shader', () => { diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 0ad01587..1c52edbf 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -470,6 +470,39 @@ export async function shader(t) { { vTop, vBottom }, ); + // 28k-ii. Pow bends the cut: u squared crosses 0.5 at u = 0.707, its square root at u = 0.25. + const cutAfterPow = (exponent) => + redOrBlue( + [ + node('uv', 'ShaderUV'), + node('pow', 'ShaderPow', { exponent }, 260, 0), + node('cut', 'ShaderStep', { edge: 0.5 }, 260, 120), + ], + [ + edge('uv', 'u', 'pow', 'value'), + edge('pow', 'result', 'cut', 'value'), + edge('cut', 'result', 'mix', 't'), + ], + ); + await cutAfterPow(2); + await sleep(400); + const squaredNear = await pixel(0.1, -0.3); + const squaredFar = await pixel(0.3, -0.3); + check( + 'Pow 2 moves the cut to u = 0.707: still red at u = 0.6, blue at 0.8', + colourDistance(squaredNear, '#ff0000') < 6 && colourDistance(squaredFar, '#0000ff') < 6, + { squaredNear, squaredFar }, + ); + await cutAfterPow(0.5); + await sleep(400); + const rootNear = await pixel(-0.3, -0.3); + const rootFar = await pixel(-0.15, -0.3); + check( + 'Pow 0.5 pulls it to u = 0.25: red at u = 0.2, blue at 0.35', + colourDistance(rootNear, '#ff0000') < 6 && colourDistance(rootFar, '#0000ff') < 6, + { rootNear, rootFar }, + ); + // 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.