light: the pixel rides o/t, not a held chroma — SHADOW and BLACK drained a dark red to 0.505 of its saturation, it is 0.742 now

This commit is contained in:
2026-09-30 16:26:42 +07:00
parent 75ff444621
commit 851563ed73
2 changed files with 55 additions and 34 deletions
@@ -101,19 +101,21 @@ assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped
assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back');
assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back');
assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again');
// The rebuild after the ramp: the chroma difference rides the new luma, scaled
// by one number that only the cube can pull back. The doc's ratio is NOT it any
// more — scaling all three channels by Luma_new / Luma_old is exact until a
// channel clips, and a clipped channel is a moved hue (a skin tone at 24.0° came
// back at 48.0° at HIGHLIGHT +100, scratchpad hl-variants.mjs), and under L=0.5
// it multiplies a near-black pixel's cast by up to x30.
assert.match(maths, /float k = 1\.0;/);
// The rebuild after the ramp: the doc's ratio (R_new = R_old * Luma_new /
// Luma_old), as ONE shared scale o / t, so the differences move with the light
// and neither the hue nor the saturation goes with them. The caps are what make
// it fit — applying the ratio past the ceiling clips a channel outright and the
// hue goes with it (a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100,
// scratchpad hl-variants.mjs) — and the scale is held at 1.0 only below
// t = 0.0004, where the ratio would multiply a near-black pixel's cast by
// whatever pedestal BLACK has just lifted.
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.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back');
assert.doesNotMatch(sksl, /max\(t, 0\.0004\)/, 'the luma ratio 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
// exposure pass), or that pass is drawn in a space that is not linear at all.
assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/);
@@ -272,7 +274,7 @@ for (const bl of [-1, 1])
}
// The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own
// chroma difference, and the one scale the cube allows.
// chroma difference, and the one scale o / t the cube then gets to pull back.
const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
// lightMove, as TONE_MATH_SKSL emits it — the one move every brightness change in
// the pass goes through (a tone knob, a mask's tone knob, the exposure knob).
@@ -280,7 +282,7 @@ const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]);
// alone already landed the pixel inside the cube, and a silent clamp would hide
// the case where it did not.
function lightMove(rgb, t, o) {
let k = 1;
let k = t > 0.0004 ? o / t : 1;
const hiC = Math.max(...rgb);
const loC = Math.min(...rgb);
if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t));
@@ -362,13 +364,22 @@ for (const rgb of [...colourCases, ...greyCases]) {
const { clamped } = rebuild(rgb, {});
for (let i = 0; i < 3; i++) close(clamped[i], rgb[i], 'the colour rebuild is not the identity at zero');
}
// The chroma is CARRIED, not re-scaled: where the cube has room, the channel
// differences come out as they went in (this is where the doc's ratio diverged
// — it multiplied them by Luma_new / Luma_old, which over-saturates a lift and
// drains a pull in the half of the ramp above L = 0.5, the highlight zone).
const carried = rebuild([0.7, 0.55, 0.45], { hl: 0.5 }).clamped;
const grew = (carried[0] - carried[1]) / (0.7 - 0.55);
close(grew, 1, 'the chroma was re-scaled on a highlight lift');
// The chroma RIDES THE RATIO: where the cube has room the channel differences
// come out multiplied by the one scale o / t. That is what keeps the saturation —
// an HSL saturation is a ratio of differences and a common scale never touches it
// — and the hue along with it, which is the report behind this move: held at
// k = 1.0 (the chroma carried unchanged) a dark red came back at 0.505 of
// saturation from 0.746 with SHADOW at +100, and at 0.370 with SHADOW and BLACK
// both, which is a colour going grey under a lift.
for (const [rgb, knobs] of [
[[0.7, 0.55, 0.45], { hl: 0.5 }],
[[0.35, 0.12, 0.08], { sh: 1, bl: 1 }],
]) {
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)}`);
}
// 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.