light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now

The four knots were read at the pixel's own luma, which makes the ramp a
global curve: every pixel at luma t lands on the same o whatever surrounds
it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed
that whole quarter to the half slope left over — measured on a real frame,
0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey
sheet the knob was reported for. A curve drawn through the pixel cannot see
local contrast; that is what the eye was reading.

So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma
around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x
Detail split — and the pixel then rides the neighbourhood's gain o/base,
keeping its own difference from it. Base moves, detail stays: the same lift
on the same pixels, with the texture inside the region left standing. Full
deflection keeps 0.78 of the band's spread now.

The base is the same maths in every caller: bx = 0 (a mask, which has no
neighbourhood) reads the pixel nine times and gets the old global move back,
and with every knob on zero the ramp at base IS base, so the ratio is 1 and
the pass is the identity however coarse the base is.

Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation
(0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either:
the multiply is a uniform gain on encoded values, which is the same stop
exposureMove already argues for.
This commit is contained in:
2026-09-30 18:46:36 +07:00
parent 25b1312e0a
commit e61dccc784
4 changed files with 222 additions and 23 deletions
+110 -13
View File
@@ -60,7 +60,43 @@ const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1];
assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths');
assert.equal((mathTmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again');
assert.match(tmpl, /rgb = toneRamp\(rgb, t, bl, sh, hl, wh, dr\);/);
assert.match(tmpl, /rgb = toneRamp\(rgb, t, baseLuma\(xy, t\), bl, sh, hl, wh, dr\);/);
// The BASE layer the ramp is drawn through, and the radius the caller spends on
// it. It reads the CHILD nine times — a caller whose bx is zero reads its own
// pixel nine times and gets the global move back, which is what makes the shared
// maths safe for a mask (see the mask's own call below).
assert.match(tmpl, /float baseLuma\(vec2 xy, float t\) \{/);
assert.match(tmpl, /vec3 s = clamp\(src\.eval\(xy \+ vec2\(float\(i\), float\(j\)\) \* bx\)\.rgb, 0\.0, 1\.0\);/);
assert.match(tmpl, /float rw = exp\(-d \* d \* 24\.0\);/);
assert.doesNotMatch(tmpl, /if \(i == 0 && j == 0\) continue;/, 'the 3x3 grew a branch — bx = 0 no longer returns exactly t');
assert.match(tmpl, /uniform float2 bx;/);
// One tap of the base is a FRACTION of the frame, so the preview and the file
// look at the same neighbourhood: the pass has the frame size, the shader has a
// step in its own pixels.
assert.match(tone, /export const TONE_BASE_RADIUS = ([0-9.]+);/);
const baseRadius = Number(tone.match(/export const TONE_BASE_RADIUS = ([0-9.]+);/)[1]);
assert.ok(baseRadius >= 0.02 && baseRadius <= 0.05, `the base reads ${baseRadius} of the frame — the doc asks for 2%..5%`);
const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8');
assert.match(
engine,
/getToneUniforms\(adjustments, recipe\.baseFilter, \[\s*width \* TONE_BASE_RADIUS,\s*height \* TONE_BASE_RADIUS,\s*\]\)/,
'the tone pass no longer hands the base a frame-sized step'
);
// The uniform block: the shader's declarations, arrays expanded and in
// declaration order, have to be the numbers `toneUniformArray` writes — a
// mismatch is a silent off-by-one down the whole block.
const declared = [...tmpl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce(
(n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1),
0
);
const arrayFn = tone.match(/export function toneUniformArray\(u: ToneUniforms\): number\[\] \{\n return \[([\s\S]*?)\n \];/)?.[1];
assert.ok(arrayFn, 'toneUniformArray is gone');
const written = arrayFn
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.reduce((n, s) => n + (s.startsWith('...u.hsl') ? 8 : 1), 0);
assert.equal(written, declared, `toneUniformArray writes ${written} floats, the pass declares ${declared}`);
const resolve = (s) => s.replace('${TONE_MATH_SKSL}', mathTmpl).replaceAll('${TONE_ANCHOR}', String(A));
const sksl = resolve(tmpl);
@@ -69,7 +105,11 @@ assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — o
const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8');
assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths');
assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/);
assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/);
// The mask hands the ramp its OWN pixel as the base, twice over: a shape has no
// neighbourhood of its own, and base == t is a ratio of exactly 1, so what a mask
// does with SHADOW is what it always did. The knob means the same thing on both
// sides of the call; what differs is the neighbourhood, and a mask has none.
assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/);
assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary saturation clamp');
assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula');
@@ -93,10 +133,17 @@ assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again —
// knots bends the ramp by six code values in the quarter-tones with every knob
// on zero, and this pass also runs for the stock split tones and for DR alone.
assert.match(sksl, /float lin\(float e0, float e1, float x\) \{\n return clamp\(\(x - e0\) \/ \(e1 - e0\), 0\.0, 1\.0\);\n\}/);
assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, t\)\), step\(0\.25, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, t\)\), step\(0\.50, t\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, t\)\), step\(0\.75, t\)\);/);
assert.match(sksl, /float o = mix\(a0, a1, lin\(0\.00, 0\.25, base\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, base\)\), step\(0\.25, base\)\);/);
assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, base\)\), step\(0\.50, base\)\);/);
assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, base\)\), step\(0\.75, base\)\);/);
// ...and the pixel rides the ratio of that: the DETAIL layer, kept whole. The
// ramp's own luma is not what is handed to the rebuild any more — the pixel's is,
// scaled by the neighbourhood's gain — or the move would be global again and the
// band above SHADOW would be drawn flat, which is the whole bug.
assert.match(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/);
assert.match(sksl, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/);
assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve');
assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again');
// The linear-light knee that used to run ahead of all this is GONE from the tone
// pass: HIGHLIGHT is one zone move in both directions now, and a second pass over
@@ -116,7 +163,7 @@ assert.match(maths, /float k = t > 0\.0004 \? o \/ t : 1\.0;/);
assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/);
assert.match(maths, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/);
assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/);
assert.match(maths, /return lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\);/);
assert.match(maths, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/);
assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
assert.doesNotMatch(maths, /float k = 1\.0;/, 'the chroma-constant scale came back — a shadow lift drains the colour');
// The transfer pair has to be the accurate one where it is still used (the
@@ -353,11 +400,17 @@ function lightMove(rgb, t, o) {
if (loC < t) k = Math.min(k, o / (t - loC));
return rgb.map((c) => o + (c - t) * k);
}
function rebuild(rgb, k) {
// The rebuild, with the base layer the shader now draws the ramp through. `base`
// defaults to the pixel itself — the degenerate call, and the one a mask makes —
// which lands `target` back on `o` and is the move this function had before there
// was a base at all.
function rebuild(rgb, k, base) {
const t = lumaOf(rgb);
const o = ramp(t, k).o;
const out = lightMove(rgb, t, o);
return { out, clamped: out.map((c) => clamp01(c)), o, t };
const b = base ?? t;
const o = ramp(b, k).o;
const target = clamp01(b > 0.0004 ? (o * t) / b : t);
const out = lightMove(rgb, t, target);
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
}
// The transfer pair the exposure pass crosses into linear light with, and back.
const srgbToLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
@@ -441,10 +494,54 @@ for (const [rgb, knobs] of [
]) {
const lifted = rebuild(rgb, knobs);
const grew = (lifted.clamped[0] - lifted.clamped[1]) / (rgb[0] - rgb[1]);
assert.ok(Math.abs(lifted.o / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`);
close(grew, lifted.o / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`);
assert.ok(Math.abs(lifted.target / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`);
close(grew, lifted.target / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`);
}
// THE BASE LAYER. A band with texture in it — SHADOW's own quarter, 0.26 to
// 0.50, at the deflection this was reported at (the full +100, which is +1 here):
//
// read at the pixel every pixel lands on its own o, so the band's spread
// comes out multiplied by the HALF slope that quarter has
// left under the knot — the grey sheet, drawn flat, 0.50 of
// its own spread.
// read at the base every pixel of ONE neighbourhood takes the same gain,
// o(base)/base, so the texture inside it rides out whole,
// and the same lift lands on the pixels either way.
//
// Those are the two numbers the live probe reads off the deployed bundle (0.57
// before, 0.78 after, over this frame); this is the same claim in arithmetic.
const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31));
const spread = (xs) => Math.max(...xs) - Math.min(...xs);
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]);
const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]);
close(spread(movedGlobally) / spread(band), 0.5, 'the pixel-read ramp no longer draws its own band at half slope');
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
close(spread(movedLocally) / spread(band), bandGain, 'the band did not keep its texture under the lift');
assert.ok(
spread(movedLocally) / spread(movedGlobally) > 1.5,
`the base is not earning its keep: ${spread(movedLocally) / spread(movedGlobally)}x the global move's spread`
);
// The gain belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base
// take the same one however far apart they sit, which is exactly what leaves the
// difference between them standing. (Read at the pixel, the gain would be the
// pixel's own o / t — the slope of the curve where the pixel happens to be.)
for (const [lo, hi] of [[0.28, 0.44], [0.30, 0.48]]) {
const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] / lo;
const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] / hi;
close(a, b, 'the gain is the pixel’s again, not the neighbourhood’s');
}
// Every knob on zero is the identity through the base path too, whatever base is
// handed in — the ramp at b IS b, so the ratio is 1 — and so is a caller whose
// base is its own pixel (bx = 0, the mask, the nine identical taps).
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])
for (const rgb of [...colourCases, ...greyCases]) {
const g = rebuild(rgb, {}, b);
for (let i = 0; i < 3; i++) close(g.clamped[i], rgb[i], `the base path is not the identity at zero, base ${b}`);
}
// THE EXPOSURE KNOB, the same move on a different input. Behind it: -2..+2 EV in
// half stops, on the frame and inside a gradient mask.
// A grey is the knob it always was — a stop on a neutral is a stop on its light,