From a32b7c6daac1878b0e08496f47d566d576a01eaf Mon Sep 17 00:00:00 2001 From: root Date: Sat, 12 Sep 2026 22:58:50 +0200 Subject: [PATCH] Adjust colors: brightness adds instead of multiplying (#62) The shipped shader graph gains a `brightness - 1` offset node, so 1 stays neutral and brightness lifts every channel by the same amount; contrast keeps multiplying, saturation keeps mixing with the luma. Fixture, seeds, unit and e2e expectations follow (twelve nodes, 1.25 -> #80c0ff). Co-Authored-By: Claude Fable 5.1 --- tests/core/shaders.test.ts | 28 ++++++++++++++++++++++------ tools/e2e/28-shader.mjs | 14 +++++++------- tools/lib/shaders.mjs | 22 ++++++++++++---------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/tests/core/shaders.test.ts b/tests/core/shaders.test.ts index 6e73dcdf..99bd0510 100644 --- a/tests/core/shaders.test.ts +++ b/tests/core/shaders.test.ts @@ -46,15 +46,16 @@ function shaderEditor(f: ProjectFixture, name = 'Adjust colors') { const property = (name: string, value = 1, min = 0, max = 4) => ({ name, default: value, min, max }); /** - * rgb *= brightness; rgb = (rgb - 0.5) * contrast + 0.5; rgb = mix(luma, rgb, saturation) – - * eleven nodes, the same maths the built-in prototype had. + * rgb += brightness - 1; rgb = (rgb - 0.5) * contrast + 0.5; rgb = mix(luma, rgb, saturation) – + * twelve nodes, the recipe `tools/lib/shaders.mjs` ships. */ function buildAdjustColors(s: ReturnType) { const texel = s.add(SHADER_TEXTURE_COLOR); const brightness = s.add(SHADER_PROPERTY, property('brightness')); const contrast = s.add(SHADER_PROPERTY, property('contrast')); const saturation = s.add(SHADER_PROPERTY, property('saturation')); - const bright = s.add('ShaderMultiply'); + const offset = s.add('ShaderSubtract', { b: 1 }); + const bright = s.add('ShaderAdd'); const shifted = s.add('ShaderSubtract', { b: 0.5 }); const scaled = s.add('ShaderMultiply'); const centred = s.add('ShaderAdd', { b: 0.5 }); @@ -62,7 +63,8 @@ function buildAdjustColors(s: ReturnType) { const mixed = s.add('ShaderMix'); const output = s.add(SHADER_OUTPUT); s.link(texel, 'color', bright, 'a'); - s.link(brightness, 'value', bright, 'b'); + s.link(brightness, 'value', offset, 'a'); + s.link(offset, 'result', bright, 'b'); s.link(bright, 'result', shifted, 'a'); s.link(shifted, 'result', scaled, 'a'); s.link(contrast, 'value', scaled, 'b'); @@ -72,7 +74,20 @@ function buildAdjustColors(s: ReturnType) { s.link(centred, 'result', mixed, 'b'); s.link(saturation, 'value', mixed, 't'); s.link(mixed, 'result', output, 'color'); - return { texel, brightness, contrast, saturation, bright, shifted, scaled, centred, luma, mixed, output }; + return { + texel, + brightness, + contrast, + saturation, + offset, + bright, + shifted, + scaled, + centred, + luma, + mixed, + output, + }; } describe('a shader asset', () => { @@ -161,7 +176,8 @@ describe('compileShader', () => { for (const name of ['brightness', 'contrast', 'saturation']) expect(fragment).toContain(`uniform float u_${name};`); expect(fragment).toContain('vec3 rgb = texel.a > 0.0 ? texel.rgb / texel.a : texel.rgb;'); - expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_color \* vec3\(n\d+_value\)\);/); + expect(fragment).toMatch(/float n\d+_result = \(n\d+_value - 1\.0\);/); + expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_color \+ vec3\(n\d+_result\)\);/); expect(fragment).toMatch(/vec3 n\d+_result = \(n\d+_result - vec3\(0\.5\)\);/); expect(fragment).toMatch(/float n\d+_value = dot\(n\d+_result, vec3\(0\.2126, 0\.7152, 0\.0722\)\);/); expect(fragment).toMatch(/vec3 n\d+_result = mix\(vec3\(n\d+_value\), n\d+_result, n\d+_value\);/); diff --git a/tools/e2e/28-shader.mjs b/tools/e2e/28-shader.mjs index 9d647a33..0f8a1daf 100644 --- a/tools/e2e/28-shader.mjs +++ b/tools/e2e/28-shader.mjs @@ -53,8 +53,8 @@ export async function shader(t) { // 28a. The fixture ships Adjust colors as an asset; the + menu makes another and opens its tab on it. const seeded = (await shaders()).find((s) => s.id === ADJUST); check( - 'the fixture carries the Adjust colors shader as an asset of eleven nodes', - Boolean(seeded) && seeded.nodes.length === 11, + 'the fixture carries the Adjust colors shader as an asset of twelve nodes', + Boolean(seeded) && seeded.nodes.length === 12, JSON.stringify(seeded), ); await clickText('+'); @@ -226,10 +226,10 @@ export async function shader(t) { colourDistance(neutral, base) < 6, JSON.stringify({ base, neutral }), ); - await assetProps({ brightness: 0.5 }); + await assetProps({ brightness: 1.25 }); await sleep(300); - const darker = await centre(); - check('brightness 0.5 halves every channel', colourDistance(darker, '#204080') < 24, darker); + const lighter = await centre(); + check('brightness 1.25 adds a quarter to every channel', colourDistance(lighter, '#80c0ff') < 24, lighter); await assetProps({ brightness: 1, contrast: 0 }); await sleep(300); const flat = await centre(); @@ -241,14 +241,14 @@ export async function shader(t) { await shot('28-shader-desaturated'); // 28f. The game draws through the same path, so play shows the shader too. - await assetProps({ saturation: 1, brightness: 0.5 }); + await assetProps({ saturation: 1, brightness: 1.25 }); await play(); await sleep(500); const at = await json("__gm.gameScreen('oshade')"); const played = at ? await json(`__gm.pixelAt(${Math.round(at.x + 20)}, ${Math.round(at.y + 20)})`) : null; check( 'during play the sprite is drawn with its shader', - played !== null && colourDistance(played, '#204080') < 24, + played !== null && colourDistance(played, '#80c0ff') < 24, JSON.stringify({ at, played }), ); await stop(); diff --git a/tools/lib/shaders.mjs b/tools/lib/shaders.mjs index 0ddfcea5..60ba60eb 100644 --- a/tools/lib/shaders.mjs +++ b/tools/lib/shaders.mjs @@ -2,8 +2,8 @@ import { edge, node } from './builders.mjs'; /** * The "Adjust colors" shader as an asset document: brightness, contrast and saturation, 1 neutral, - * on the straight colour – rgb *= brightness; rgb = (rgb - 0.5) * contrast + 0.5; - * rgb = mix(luma, rgb, saturation). The recipe the built-in prototype of #62 had, now eleven nodes. + * on the straight colour – rgb += brightness - 1; rgb = (rgb - 0.5) * contrast + 0.5; + * rgb = mix(luma, rgb, saturation). Brightness adds (user decision, #62 follow-up), so 1 is +0. */ export function adjustColorsShader(id = 'adjust-colors', name = 'Adjust colors') { const property = (nodeId, propertyName, x, y) => @@ -19,17 +19,19 @@ export function adjustColorsShader(id = 'adjust-colors', name = 'Adjust colors') property(`${id}-brightness`, 'brightness', 0, 160), property(`${id}-contrast`, 'contrast', 0, 320), property(`${id}-saturation`, 'saturation', 0, 480), - node(`${id}-bright`, 'ShaderMultiply', 300, 0), - node(`${id}-shift`, 'ShaderSubtract', 560, 0, { b: 0.5 }), - node(`${id}-scale`, 'ShaderMultiply', 820, 0), - node(`${id}-centre`, 'ShaderAdd', 1080, 0, { b: 0.5 }), - node(`${id}-luma`, 'ShaderLuminance', 1340, 160), - node(`${id}-mix`, 'ShaderMix', 1600, 0), - node(`${id}-out`, 'ShaderOutput', 1860, 0), + node(`${id}-offset`, 'ShaderSubtract', 260, 160, { b: 1 }), + node(`${id}-bright`, 'ShaderAdd', 520, 0), + node(`${id}-shift`, 'ShaderSubtract', 780, 0, { b: 0.5 }), + node(`${id}-scale`, 'ShaderMultiply', 1040, 0), + node(`${id}-centre`, 'ShaderAdd', 1300, 0, { b: 0.5 }), + node(`${id}-luma`, 'ShaderLuminance', 1560, 160), + node(`${id}-mix`, 'ShaderMix', 1820, 0), + node(`${id}-out`, 'ShaderOutput', 2080, 0), ], edges: [ edge(`${id}-e1`, `${id}-texel`, 'color', `${id}-bright`, 'a'), - edge(`${id}-e2`, `${id}-brightness`, 'value', `${id}-bright`, 'b'), + edge(`${id}-e2`, `${id}-brightness`, 'value', `${id}-offset`, 'a'), + edge(`${id}-e12`, `${id}-offset`, 'result', `${id}-bright`, 'b'), edge(`${id}-e3`, `${id}-bright`, 'result', `${id}-shift`, 'a'), edge(`${id}-e4`, `${id}-shift`, 'result', `${id}-scale`, 'a'), edge(`${id}-e5`, `${id}-contrast`, 'value', `${id}-scale`, 'b'),