light: the SHADOW knob rides half its anchor, so a lift stops drawing the band above it flat — a waterfall's spray came back at 0.10 of its own contrast, 0.55 now

This commit is contained in:
2026-09-30 17:36:29 +07:00
parent 3e3045d912
commit 36fb93658b
2 changed files with 97 additions and 28 deletions
@@ -16,13 +16,15 @@
// THE TONE PASS builds the luma a new ramp instead. The four knobs
// (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc —
// one tent each, one per quarter of the ramp — and each knob moves the knot it
// owns by TONE_ANCHOR of the ramp, held inside the knot before it. The 0.50
// midpoint is the one value all four leave where it was.
// owns by TONE_ANCHOR of the ramp, held inside the knot before it; SHADOW, whose
// knot is the head of the band above it rather than an end of the ramp, moves
// half that. The 0.50 midpoint is the one value all four leave where it was.
//
// Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then
// checked against the source's own constants, and the ramp re-run here as a twin
// so monotonicity, the neutral identity and the partition of the four masks are
// checked rather than asserted in a comment.
// so monotonicity, the neutral identity, the partition of the four masks and the
// slope SHADOW leaves the band above it are checked rather than asserted in a
// comment.
//
// node scripts/highlight-knee-check.mjs
import assert from 'node:assert/strict';
@@ -83,7 +85,7 @@ assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask
// knots next to them.
assert.match(sksl, /float a4 = 1\.0 \+ 0\.25 \* wh - dr \* 0\.18;/);
assert.match(sksl, /float a3 = clamp\(0\.75 \+ 0\.25 \* hl - dr \* 0\.09, 0\.5, a4\);/);
assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/);
assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* 0\.5 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/);
assert.match(sksl, /float a0 = clamp\(0\.25 \* bl \+ dr \* 0\.12, 0\.0, a1\);/);
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
// Straight between the knots, and NOT a smoothstep: an S-curve through the
@@ -173,7 +175,7 @@ function ramp(t, k) {
const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask);
const a4 = 1 + A * wh - dr * 0.18;
const a3 = Math.min(a4, Math.max(0.5, 0.75 + A * hl - dr * 0.09));
const a1 = Math.min(0.5, Math.max(0, 0.25 + A * sh + dr * 0.06));
const a1 = Math.min(0.5, Math.max(0, 0.25 + A * 0.5 * sh + dr * 0.06));
const a0 = Math.min(a1, Math.max(0, A * bl + dr * 0.12));
let o = mix(a0, a1, lin(0, 0.25, t));
o = mix(o, mix(a1, 0.5, lin(0.25, 0.5, t)), step(0.25, t));
@@ -235,12 +237,15 @@ for (const k of combos) {
}
assert.ok(worst > -1e-12, `worst step ${worst} — the ramp is folded`);
// A knob moves its own quarter, and only its own: +BLACK takes the toe off the
// floor, +SHADOW puts the 0.25 knot on the midpoint, -HIGHLIGHT pulls the 0.75
// knot onto it, and WHITE - rolls the head under 1.0. That is the reach a
// tonal-range slider has — a quarter of the ramp, so the middle stays a middle.
// floor, -HIGHLIGHT pulls the 0.75 knot onto the midpoint, and WHITE - rolls the
// head under 1.0. That is the reach a tonal-range slider has — a quarter of the
// ramp, so the middle stays a middle. SHADOW is the exception and moves HALF of
// it: its knot is the HEAD of the 0.25..0.50 band, so a whole quarter would draw
// that band flat, which is the wash-out the knob was reported for.
close(ramp(0, {}).o, 0, 'a neutral toe moved');
close(ramp(0, { bl: 1 }).o, A, 'BLACK no longer reaches a quarter of the ramp');
close(ramp(0.25, { sh: 1 }).o, 0.5, 'SHADOW no longer reaches the midpoint');
close(ramp(0.25, { sh: 1 }).o, 0.375, 'SHADOW no longer stops halfway to the midpoint');
close(ramp(0.25, { sh: -1 }).o, 0.125, 'SHADOW no longer stops halfway to the floor');
close(ramp(0.75, { hl: -1 }).o, 0.5, 'HIGHLIGHT no longer reaches the midpoint');
close(ramp(1, { wh: -1 }).o, 0.75, 'WHITE no longer rolls the head under 1.0');
close(ramp(0.25, {}).o, 0.25, 'a neutral knot moved');
@@ -259,11 +264,48 @@ close(ramp(1, { dr: 1 }).o, 0.82, 'DR no longer rolls the head the way it did');
close(ramp(0.5, { dr: 1 }).o, 0.5, 'DR moved the midpoint');
// Black and shadow both at -1 are the flat stretch DR used to fold: the toe is
// held on the floor by the ordering clamp (BLACK's -0.25 cancels DR's +0.12),
// the 0.25 knot is DR's own +0.06, and the stretch between them is a straight
// line up to the midpoint — never a step down.
// the 0.25 knot is DR's own +0.06 over SHADOW's half-anchor -0.125, and the
// stretch between them is a straight line up to the midpoint — never a step
// down.
assert.equal(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0);
assert.equal(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.06);
close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28, 'DR folded the flat stretch');
assert.equal(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.185);
close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.3425, 'DR folded the flat stretch');
// The band above SHADOW is the one that pays for its lift, and HALF its slope is
// the floor the fix is. Measured on a real frame (DSCF1701, 21% of its pixels in
// that band) with SHADOW +90, a whole anchor left 0.10 of the band's own spread
// where half leaves 0.55 — see the note on a1 in toneShader.ts and the sweep in
// scratchpad sh-band.mjs. Swept here over the knob's whole travel: neither
// quarter of the ramp SHADOW touches may be drawn flatter than half its slope,
// and neither may be stretched past one and a half, which is the same defect
// upside down (a crush that flattens the darks into one black).
for (let sh = -1; sh <= 1.0001; sh += 1 / 64) {
const below = (ramp(0.25, { sh }).o - ramp(0, { sh }).o) / 0.25;
const band = (ramp(0.5, { sh }).o - ramp(0.25, { sh }).o) / 0.25;
assert.ok(band >= 0.5 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`);
assert.ok(below >= 0.5 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`);
assert.ok(band <= 1.5 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`);
}
// The film stocks ride the same knot, so a halved SHADOW would have halved their
// crush with it. They are written at DOUBLE for that reason, and the look they
// were tuned to is the knot, not the unit: these three land where they always
// did (0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros HC).
const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0];
assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all');
// The keys are quoted or not depending on whether they are identifiers, so the
// quotes come off before the lookup.
const filmFlat = filmTone.replace(/['"]/g, '');
for (const [name, sh, knot] of [
['classic-chrome', -0.56, 0.18],
['classic-vivid', -0.56, 0.18],
['monochrome', -0.24, 0.22],
['mono-high-contrast', -0.64, 0.17],
]) {
assert.ok(
filmFlat.includes(`${name}: { sh: ${sh}`),
`${name} is not on the doubled ${sh} — the stock's crush moved with the knob's reach`
);
close(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`);
}
// The two ends stay ordered even at full deflection against each other: the toe
// can never climb past the head.
for (const bl of [-1, 1])