fix(tone): read the ramp's base from the pixel's own neighbourhood, so highlights and shadows move a region and keep the detail in it

This commit is contained in:
2026-10-15 00:55:09 +07:00
parent 8809eb84b0
commit ba181da86b
5 changed files with 1017 additions and 90 deletions
@@ -69,6 +69,10 @@ const {
TONE_BLACK_EDGE,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
TONE_BASE_TAPS,
TONE_BASE_RANGE_EV,
TONE_BASE_SKSL,
toneBaseStep,
toneUniformArray,
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
@@ -187,11 +191,24 @@ assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
assert.doesNotMatch(sksl, /float lin\(/, 'the linear-light knee ahead of the knobs came back');
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
assert.match(sksl, /float target = clamp\(o, 0\.0, 1\.0\);/);
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
// The ramp is read at the BASE (Base' + Detail), and the pixel's own difference
// from it rides through un-scaled. These pin the two lines the whole tone pass
// hangs on: read at `t`, a knob is a global curve again (the HIGHLIGHT/SHADOW
// local-contrast report), and scaled by the neighbourhood's gain, a knob softens
// the frame's texture into its level (the report before that).
assert.match(sksl, /float o = toneCurve\(base, blVal, shVal, hlVal, whVal\);/);
assert.match(sksl, /float target = clamp\(o \+ \(t - base\), 0\.0, 1\.0\);/);
assert.doesNotMatch(sksl, /float o = toneCurve\(t, /, 'the ramp is read at the pixel again — a global curve');
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
assert.doesNotMatch(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/, 'the gain-scaled detail came back — a tone knob softens again');
assert.match(sksl, /vec3 res = lightMove\(c, t, target\);/);
// The base layer itself: an edge-aware pass, not the gaussian the ramp fell back
// to (a gaussian average lands ACROSS an edge, and the halo follows the knob).
assert.match(TONE_BASE_SKSL, /uniform float2 dir;/);
assert.match(TONE_BASE_SKSL, /float ref = toneBaseMed3\(/);
assert.match(TONE_BASE_SKSL, /float d1 = \(a1 - ref\) \/ \(ref \+ TONE_BASE_PED\) \/ TONE_BASE_RANGE;/);
assert.doesNotMatch(TONE_BASE_SKSL, /toneBaseMed3\(l0, ref, /, 'the reference is the median of the wrong triple');
assert.ok(toneBaseStep(1200, 600) === 4 && toneBaseStep(400, 300) === 1 && toneBaseStep(4000, 3000) === 13,
`the base step drifted: ${toneBaseStep(1200, 600)}, ${toneBaseStep(400, 300)}, ${toneBaseStep(4000, 3000)}`);
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
@@ -447,8 +464,8 @@ function lightMove(rgb, t, o) {
function rebuild(rgb, k, base) {
const t = lumaOf(rgb);
const b = base ?? t;
const o = ramp(t, k).o;
const target = clamp01(o);
const o = ramp(b, k).o;
const target = clamp01(o + (t - b));
const out = lightMove(rgb, t, target);
return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target };
}
@@ -543,8 +560,9 @@ for (const [rgb, knobs] of [
//
// read at the pixel every pixel lands on its own o, so the band's spread
// comes out multiplied by whatever slope the curve has
// over it — 1.2347 of its own spread at this deflection,
// the SHADOW +100 table steepening through 0.26..0.50.
// over it — 0.779 of its own spread at this deflection,
// the SHADOW +100 table flattening through 0.26..0.50
// (its +0.25 lift saturates and the knots bend over).
// read at the base every pixel of ONE neighbourhood takes the same move,
// o(base) - base, and its own difference from the base is
// added to it, so the texture inside the region comes out
@@ -560,6 +578,14 @@ 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]);
// ...and measured, so the two readings cannot drift apart in the source while
// the numbers above stay prose: the global one scales the band by the curve's
// slope (0.78 at SHADOW +100 — the lift saturates over this quarter and the
// texture shrinks with it), the base one moves it and leaves its spread alone.
const movedOnBase = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]);
const slopeOverBand = spread(movedGlobally) / spread(band);
assert.ok(slopeOverBand < 0.9, `the SHADOW +100 table stopped flattening through 0.26..0.50: ${slopeOverBand}`);
near4(spread(movedOnBase), spread(band));
// Every knob on zero is the identity, whatever base is handed in
for (const b of [0.01, 0.1, 0.35, 0.7, 0.99])