From 166a677590c5a4aef6b26e44d54c480824e61b44 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 30 Sep 2026 21:24:01 +0700 Subject: [PATCH] =?UTF-8?q?light:=20the=20tone=20ramp=20is=20four=20bumps?= =?UTF-8?q?=20on=20the=20identity,=20not=20straight=20segments=20between?= =?UTF-8?q?=20five=20knots=20=E2=80=94=20a=20knot=20is=20an=20angle,=20an?= =?UTF-8?q?=20angle=20in=20a=20tone=20curve=20is=20a=20Mach=20band,=20and?= =?UTF-8?q?=20the=20wedge=20reads=20the=20seams:=20BLACK=20+100=20broke=20?= =?UTF-8?q?at=200.030=20with=20105=20of=20second=20difference,=20HIGHLIGHT?= =?UTF-8?q?=20-100=20at=200.747=20with=2072?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ramp was a0..a4 with the pixel's base interpolated straight between them. A segment meets its neighbour at an ANGLE, and the second derivative of a tone curve is what a gradient reads as a band — so a knob left a line across the mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift inside 0.25 and the stretch from 0.25 up came back identical to the untouched frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100 folded a seam into 0.747, between the highlights it pulled and the shadow it left under them. Measured on a 1024-step luma wedge through the exported pass, second difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from 0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and 1.146 is a sign flip, not a bend. So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel (1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves without a fold at its own top; level at u = 1, so a move lands on the identity and on its neighbour without an angle; C1 everywhere between. The two bumps of a half meet on 0.50 both on zero, which is the same fixed midpoint as before, and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of each on the heads beside them). A sum of bumps can overshoot where two steep sides land on one stretch — past a slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces, and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were not (unguarded min slope -0.0141). The guard reads each pair at its own steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792, TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up together past it. A single knob never reaches it (a full BLACK is 0.77, a full SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead of folding. Guarded, the sweep over five levels of all four knobs and DR reads a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005. After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and 0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK +100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at 0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and spends it over the quarter instead of into a line. Every knob on zero is the identity to the last bit — the pass also runs for the stock split tones and for DR alone — and 0.50 is still the one value no knob moves. Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic beside the shader (with the negative control: the unguarded pair still folds, the guard is what stops it). highlight-knee-check.mjs reads the kernel and the two slope constants off the source, pins the four amplitudes and the guard, and sweeps the travel of every knob as before. All ten checks that run without a browser pass, build clean. Skipped: the guard's ceiling is a constant, not a search for the widest travel that still clears a band we cannot see. Add when a frame shows a band the deflections in hand cannot explain. --- .../frontend/scripts/highlight-knee-check.mjs | 179 ++++++++++++------ docker/frontend/scripts/tone-base-check.mjs | 38 +++- docker/frontend/shared/utils/toneShader.ts | 137 +++++++++----- 3 files changed, 242 insertions(+), 112 deletions(-) diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index a8ffd16..6d2260a 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -15,11 +15,13 @@ // // 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; SHADOW and -// HIGHLIGHT, whose knots are the HEAD of a quarter rather than an end of the -// ramp, move half that. The 0.50 midpoint is the one value all four leave where -// it was. +// one bump each, one per quarter of the ramp — and each knob moves the knot it +// owns by TONE_ANCHOR of the ramp; SHADOW and HIGHLIGHT, whose knots are the HEAD +// of a quarter rather than an end of the ramp, move half that. The kernel is +// (1-u^2)^2, level at the knot and level a half-width away, so a knob moves its +// own quarter and meets the next knob's bump on 0.50 flat; the 0.50 midpoint is +// the one value all four leave where it was. The two bumps of a half are held +// under a total slope of 1, so the sum can only add to the identity. // // 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 @@ -113,7 +115,21 @@ const written = arrayFn .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)); +// The guard's own constants are the kernel's derivative at its steepest point, +// 8/(3*sqrt(3)) = 1.5396, in units of the bump's half-width — so they are read +// off the source and checked against that number rather than taken on trust. +const halfSlope = Number(tone.match(/export const TONE_BUMP_SLOPE_HALF = ([0-9.]+);/)?.[1]); +const quarterSlope = Number(tone.match(/export const TONE_BUMP_SLOPE_QUARTER = ([0-9.]+);/)?.[1]); +assert.ok(halfSlope > 0, 'TONE_BUMP_SLOPE_HALF is gone — the guard has no ceiling to hold to'); +assert.ok(Math.abs(halfSlope - 8 / (3 * Math.sqrt(3)) / 0.5) < 1e-4, `TONE_BUMP_SLOPE_HALF ${halfSlope} is not the kernel's steepest slope over a half-width`); +assert.ok(Math.abs(quarterSlope - 8 / (3 * Math.sqrt(3)) / 0.25) < 1e-4, `TONE_BUMP_SLOPE_QUARTER ${quarterSlope} is not the same slope over a quarter-width`); +assert.equal(quarterSlope, 2 * halfSlope, 'the two slopes are no longer the same kernel at two widths'); +const resolve = (s) => + s + .replace('${TONE_MATH_SKSL}', mathTmpl) + .replaceAll('${TONE_ANCHOR}', String(A)) + .replaceAll('${TONE_BUMP_SLOPE_HALF}', String(halfSlope)) + .replaceAll('${TONE_BUMP_SLOPE_QUARTER}', String(quarterSlope)); const sksl = resolve(tmpl); const maths = resolve(mathTmpl); @@ -135,24 +151,39 @@ assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/); assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/); assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/); assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/); -// The ramp: five knots, each moved by its own knob and held inside the one -// before it. The 0.50 knot is a literal — nothing may move the midpoint. DR -// moves the same knots, on the toe and the head exactly as it did when it was a -// pair of masked terms (0.12 at t = 0, 0.18 at t = 1) and half of each at the -// 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 \* 0\.5 \* hl - dr \* 0\.09, 0\.5, a4\);/); -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\);/); +// The ramp: four bumps on the four knots, each moved by its own knob, added to +// the identity. The kernel is (1-u^2)^2 — level at the knot, so a knot moves +// without a fold at its own top, and level a half-width away, so a move lands on +// the identity and on its neighbour without an angle. The 0.50 midpoint is where +// the two bumps of a half meet, and both are on zero there: nothing may move it. +// DR moves the same bumps, on the toe and the head as it did when it was a pair +// of masked terms (0.12 at t = 0, 0.18 at t = 1) and half of each at the knots +// next to them. +assert.match(sksl, /float toneBump\(float x, float knot, float half_width\) \{\n float u = \(x - knot\) \/ half_width;\n float v = max\(0\.0, 1\.0 - u \* u\);\n return v \* v;\n\}/); +assert.match(sksl, /float blackA = 0\.25 \* bl \+ dr \* 0\.12;/); +assert.match(sksl, /float shadowA = 0\.25 \* 0\.5 \* sh \+ dr \* 0\.06;/); +assert.match(sksl, /float highA = 0\.25 \* 0\.5 \* hl - dr \* 0\.09;/); +assert.match(sksl, /float whiteA = 0\.25 \* wh - dr \* 0\.18;/); 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 -// 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, 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\)\);/); +// The sum, and NOT straight segments between the knots: an angle in a tone curve +// is a Mach band, and the measurement is what retired them — on a 1024-step luma +// wedge BLACK +100 left 105 of second difference at 0.030 with the stretch above +// it identical to the untouched frame, HIGHLIGHT -100 broke at 0.747 with 72. +// Nor a smoothstep through the knots: an S-curve 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 o = base\n \+ blackA \* toneBump\(base, 0\.00, 0\.50\)\n \+ shadowA \* toneBump\(base, 0\.25, 0\.25\)\n \+ highA \* toneBump\(base, 0\.75, 0\.25\)\n \+ whiteA \* toneBump\(base, 1\.00, 0\.50\);/); +assert.doesNotMatch(sksl, /float lin\(/, 'the straight segments are back — a knot is an angle in a tone curve'); +// The guard. Two bumps share each half of the ramp and their steep sides can land +// on the same stretch, so the two amplitudes of a half are held under a total +// slope of 1 — read at their own steepest points, which is the loosest the pair +// can be — and given up together past it. Without it a full BLACK and a full +// SHADOW fold the ramp back on itself; a single knob never reaches the ceiling. +assert.match(sksl, /float holdLo = 3\.0792 \* abs\(blackA\) \+ 6\.1584 \* abs\(shadowA\);/); +assert.match(sksl, /float holdHi = 6\.1584 \* abs\(highA\) \+ 3\.0792 \* abs\(whiteA\);/); +assert.match(sksl, /float kLo = holdLo > 1\.0 \? 1\.0 \/ holdLo : 1\.0;/); +assert.match(sksl, /float kHi = holdHi > 1\.0 \? 1\.0 \/ holdHi : 1\.0;/); +assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again'); // ...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 @@ -218,33 +249,51 @@ const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055) assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237); assert.equal(Math.round(enc(knee(1.93, T, S)) * 255), 248); -// The ramp as arithmetic — the same knots, the same lin() and the same step() -// guards the SkSL above carries, so the shape is measured and not described. +// The ramp as arithmetic — the same kernel, the same four amplitudes and the +// same guard the SkSL above carries, so the shape is measured and not described. const clamp01 = (x) => Math.min(1, Math.max(0, x)); // Float-exact comparisons are a trap once a value has been through a division // and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value. const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`); +// A guarded move has been through 1 / hold and a multiply on top of the bump, so +// it lands near its number rather than on it — a tenth of a code value is the +// tolerance, well inside the one the cube can see. +const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 4e-5, `${msg ?? ''} ${a} != ${b}`); const smoothstep = (e0, e1, x) => { const u = clamp01((x - e0) / (e1 - e0)); return u * u * (3 - 2 * u); }; -const lin = (e0, e1, x) => clamp01((x - e0) / (e1 - e0)); -const step = (edge, x) => (x < edge ? 0 : 1); -const mix = (a, b, t) => a + (b - a) * t; +const bump = (x, knot, halfWidth) => { + const u = (x - knot) / halfWidth; + const v = Math.max(0, 1 - u * u); + return v * v; +}; function ramp(t, k) { const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k; const blMask = 1 - smoothstep(0, 0.25, t); const shMask = clamp01(1 - smoothstep(0.25, 0.5, t) - blMask); const whMask = smoothstep(0.75, 1, t); 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 * 0.5 * hl - dr * 0.09)); - 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)); - o = mix(o, mix(0.5, a3, lin(0.5, 0.75, t)), step(0.5, t)); - o = mix(o, mix(a3, a4, lin(0.75, 1, t)), step(0.75, t)); + let blackA = A * bl + dr * 0.12; + let shadowA = A * 0.5 * sh + dr * 0.06; + let highA = A * 0.5 * hl - dr * 0.09; + let whiteA = A * wh - dr * 0.18; + // The two moves of a half are read at their own steepest points and held under + // a total slope of 1, together — see the note on the guard in toneShader.ts. + const holdLo = halfSlope * Math.abs(blackA) + quarterSlope * Math.abs(shadowA); + const holdHi = quarterSlope * Math.abs(highA) + halfSlope * Math.abs(whiteA); + const kLo = holdLo > 1 ? 1 / holdLo : 1; + const kHi = holdHi > 1 ? 1 / holdHi : 1; + blackA *= kLo; + shadowA *= kLo; + highA *= kHi; + whiteA *= kHi; + const o = + t + + blackA * bump(t, 0, 0.5) + + shadowA * bump(t, 0.25, 0.25) + + highA * bump(t, 0.75, 0.25) + + whiteA * bump(t, 1, 0.5); return { o: clamp01(o), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask }; } @@ -277,10 +326,12 @@ for (const k of [{ hl: 1, sh: 1, wh: 1, bl: 1 }, { hl: -1, sh: -1, wh: -1, bl: - close(ramp(0.5, k).o, 0.5, 'a knob moved the midpoint'); // Monotone under EVERY combination of the four at full deflection, DR included. -// This is the whole reason the knots exist instead of the doc's additive masks, -// which measured a slope of -5 per unit luma on BLACK +1 against SHADOW -1 (an -// inverted band at t = 0.875, scratchpad tone-proto.mjs): every knot is clamped -// inside the one before it, so the ramp cannot fold. +// This is the whole reason the ramp is bumps on the identity instead of the doc's +// additive masks, which measured a slope of -5 per unit luma on BLACK +1 against +// SHADOW -1 (an inverted band at t = 0.875, scratchpad tone-proto.mjs): the two +// moves of a half are held under a total slope of 1, so the sum can never carry +// the curve backwards — and a fold at a knot is a worse band than the seams the +// straight segments drew there. const combos = []; for (const bl of [-1, 0, 1]) for (const sh of [-1, 0, 1]) @@ -300,13 +351,14 @@ 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 +// A knob moves its own knot by its own reach: +BLACK takes the toe off the // floor, -HIGHLIGHT rolls the 0.75 knot down a quarter of the way to 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 and HIGHLIGHT are the exceptions and move HALF of it: their knots (a1, -// a3) are the HEAD of the quarter in front of them, so a whole quarter would draw -// that quarter flat, which is the wash-out both knobs were reported for. +// SHADOW and HIGHLIGHT are the exceptions and move HALF of it: their knots are +// the HEAD of the quarter in front of them, and the bump only ever ADDS to the +// identity, so the whole anchor would draw that quarter flat — the wash-out both +// knobs were 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.375, 'SHADOW no longer stops halfway to the midpoint'); @@ -320,22 +372,23 @@ close(ramp(0.75, {}).o, 0.75, 'a neutral knot moved'); // white — and the ramp still runs through a raised knot at 1.25. assert.ok(1 + A * 1 > 1, 'the white knot can no longer pass 1.0'); close(ramp(1, { wh: 1 }).o, 1, 'a raised white knot left the top of the ramp'); -// DR at full is the same curve it was: the toe on 0.12 and the head on 0.82, -// which is what the two masked terms added at t = 0 and t = 1, and the midpoint -// still untouched. Now it is a knot move, so BLACK and SHADOW both at -1 (a flat -// stretch between 0.25 and 0.5, where the old additive lift sloped down and -// folded the ramp at 0.238) stays monotone. +// DR at full is the same curve it was on the toe — 0.12, what the masked term +// added at t = 0, and half of it on the knot next to it — and on the head it +// gives up a tenth of its roll: its 0.18 alone is over the guard (holdHi 1.11), +// so the head lands on 0.8376 where the two masked terms put 0.82, and the +// midpoint is still untouched. Now it is a bump move, so BLACK and SHADOW both at +// -1 (a flat stretch between 0.25 and 0.5, where the old additive lift sloped +// down and folded the ramp at 0.238) stays monotone. close(ramp(0, { dr: 1 }).o, 0.12, 'DR no longer lifts the toe the way it did'); -close(ramp(1, { dr: 1 }).o, 0.82, 'DR no longer rolls the head the way it did'); +near(ramp(1, { dr: 1 }).o, 0.8376201610808, '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 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.185); -close(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.3425, 'DR folded the flat stretch'); +// Black and shadow both at -1 are the flat stretch DR used to fold: the toe takes +// BLACK's -0.25 against DR's +0.12 and lands under the floor (clamped there), the +// 0.25 knot takes SHADOW's -0.125 against DR's own +0.06, and the two bumps carry +// the stretch between them up to the midpoint — never a step down. +close(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0, 'the floor is not the floor'); +near(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.111875, 'DR moved the 0.25 knot'); +near(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.3135546875, '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 @@ -518,9 +571,11 @@ for (const [rgb, knobs] of [ // 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. +// comes out multiplied by the slope the quarter has left +// under the knot — the grey sheet, drawn flat, 0.48 of its +// own spread at this deflection (0.50 when the ramp was +// straight segments, the bump adds a little of the lift +// back into the band). // 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. @@ -532,7 +587,7 @@ 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'); +near(spread(movedGlobally) / spread(band), 0.480832, 'the pixel-read ramp no longer draws its own band flat'); 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'); diff --git a/docker/frontend/scripts/tone-base-check.mjs b/docker/frontend/scripts/tone-base-check.mjs index def9906..b17f9b0 100644 --- a/docker/frontend/scripts/tone-base-check.mjs +++ b/docker/frontend/scripts/tone-base-check.mjs @@ -33,8 +33,10 @@ writeFileSync( 'import { HSL_BANDS, hslBandGaps, isMonochromeBase } from "./colorUtils.mjs";', ), ); -const { TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive } = - await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); +const { + TONE_SKSL, TONE_ANCHOR, TONE_BUMP_SLOPE_HALF, TONE_BUMP_SLOPE_QUARTER, + TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive, +} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href); // The shader has to NAME a base child — a ring of taps would not need one. assert.match(TONE_SKSL, /uniform shader base;/); @@ -83,10 +85,18 @@ function render(srcValue, baseValue) { return pixels[0]; } -// The ramp, in the same arithmetic the shader runs: SHADOW +100 puts a1 (the -// 0.25 knot) on 0.375, and 0.50 is fixed, so a base of b below a half reads -// a1 + (0.5 - a1) * (b - 0.25) / 0.25. -const ramp = (b) => 0.375 + (0.5 - 0.375) * ((b - 0.25) / 0.25); +// The ramp, in the same arithmetic the shader runs: the identity plus one bump +// per knob, (1-u^2)^2 with u the distance to the knob's knot in half-widths. +// SHADOW carries half the anchor on 0.25 and its bump is the quarter-width one, +// so SHADOW +100 reads b + a1 * (1-u^2)^2 with a1 = 0.125 and u = (b-0.25)/0.25. +// (The ramp used to be straight between knots; see the note at the head of +// toneRamp for the Mach bands that retired it.) +const bump = (b, knot, halfWidth) => { + const u = (b - knot) / halfWidth; + const v = Math.max(0, 1 - u * u); + return v * v; +}; +const ramp = (b) => b + TONE_ANCHOR * 0.5 * bump(b, 0.25, 0.25); const srcValue = 128; // the pixel: 0.501961 encoded const baseValue = 76; // its neighbourhood, darker: 0.298039 @@ -113,6 +123,22 @@ assert.ok( `a base equal to the pixel must be the identity, got ${selfBase} for ${srcValue}`, ); +// The guard, in the same arithmetic. BLACK and SHADOW at +100 share the lower +// half of the ramp and their bumps are read at their own steepest points, so the +// pair is the loosest two moves of a half can be: past a total slope of 1 the sum +// carries the curve BACKWARDS, which is a worse band than the seams it replaced. +// The guard holds the two under one and gives them up together past it. +const blackA = TONE_ANCHOR; // BLACK +100 +const shadowA = TONE_ANCHOR * 0.5; // SHADOW +100 +const lift = (b, k) => b + k * (blackA * bump(b, 0, 0.5) + shadowA * bump(b, 0.25, 0.25)); +const hold = TONE_BUMP_SLOPE_HALF * blackA + TONE_BUMP_SLOPE_QUARTER * shadowA; +assert.ok(hold > 1, `BLACK and SHADOW +100 no longer reach the guard (hold ${hold})`); +const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean); +// Both controls: the pair really does fold without the guard, and the guard is +// what stops it — a guard on a sum that never folded would be dead weight. +assert.ok(!rises((b) => lift(b, 1)), `the unguarded pair no longer folds — the guard holds nothing`); +assert.ok(rises((b) => lift(b, 1 / hold)), 'BLACK and SHADOW both at +100 fold the ramp back on itself'); + console.log( `tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` + `(ramp predicts ${expected}, sharp-base identity ${selfBase}); ` + diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index faccb5e..6530cce 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -141,6 +141,15 @@ const BAND_BLOCK = hslBandGaps() // a whole quarter the band is drawn flat — see the notes on a1 and a3. export const TONE_ANCHOR = 0.25; +// How steep a bump of amplitude A gets, A excluded: the kernel (1-u^2)^2 tops out +// at |dk/du| = 8/(3*sqrt(3)) = 1.5396 on each side of its peak (u = 1/sqrt(3)), +// and u counts the distance to the knot in units of the bump's half-width. Over +// the half-width of BLACK and WHITE that is 3.08*A, over the quarter-width of +// SHADOW and HIGHLIGHT 6.16*A, and the two numbers are what the guard inside +// toneRamp holds two moves sharing a half of the ramp under. +export const TONE_BUMP_SLOPE_HALF = 3.0792; +export const TONE_BUMP_SLOPE_QUARTER = 6.1584; + // How far out the BASE layer of `toneRamp` reads, as a fraction of the frame's // own width — the fix_shadow.md neighbourhood (it asks for 2%..5% of the width). // A fraction rather than a pixel count so the preview and the export look at the @@ -179,26 +188,41 @@ export const TONE_BASE_SIGMA = 0.35; // has no such knob and hands in 0, which is what DR's terms are worth when it is // off on the frame too. export const TONE_MATH_SKSL = ` -// Fraction of the way from e0 to e1, clamped — the position on one straight -// segment of the tone ramp. -float lin(float e0, float e1, float x) { - return clamp((x - e0) / (e1 - e0), 0.0, 1.0); +// One knob's move, read at the position x on the ramp: 1 on the knob's own knot, +// falling to 0 on the knot a half-width away and staying there — (1-u^2)^2 with u +// the distance to the knot in half-widths. Level at u = 0 and at u = 1, so the +// move joins the ramp without an angle at either end. +float toneBump(float x, float knot, float half_width) { + float u = (x - knot) / half_width; + float v = max(0.0, 1.0 - u * u); + return v * v; } -// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and -// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp — SHADOW and -// HIGHLIGHT by half of that, their knots being the heads of bands — and each knot -// is held inside the one after it so the five can never cross. 0.50 is fixed: -// it is the one point all four sliders leave alone, which is what keeps a -// mid-grey a mid-grey while the ends move around it. Straight between the knots, -// so every knob on zero is exactly the identity (see the note at the head of -// this file). -// DR moves the same knots instead of adding its own masked terms on top: it +// The ramp the pixel is rebuilt through: the identity plus four moves. Each knob +// owns a bump that PEAKS on its knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT +// on 0.75, WHITE on 1.00 — and is back on zero where the next one starts, a +// quarter away (a half for the two ends, their knots being the ends of the ramp), +// so the two bumps of a half meet on 0.50 at zero height AND zero slope: a knob +// moves its own end of the ramp and nothing else, and 0.50 is fixed — the one +// point all four sliders leave alone, which is what keeps a mid-grey a mid-grey +// while the ends move around it. Every knob on zero leaves the sum empty, so the +// whole thing is exactly the identity (see the note at the head of this file). +// +// Straight segments between the knots were the first cut, and the measurement is +// what retired them: a segment meets its neighbour at an angle, and an angle in a +// tone curve is a Mach band. On a 1024-step luma wedge (scratchpad probe, JPEG +// 8-bit, second derivative through a ±1% box) BLACK +100 came back with 105 at +// 0.030 while the stretch above it was untouched to the last bit — 0.0000 from +// 0.25 up, the "transition stays grey" the knob was reported for — and HIGHLIGHT +// -100 broke at 0.747 with 72, the seam between the lifted highlights and the +// shadow under them. The kernel is (1-u^2)^2: level at the peak, so a knot moves +// without a fold at its own top, level where it lands, so the join on 0.50 and +// the toe on 0.00 stay clean, and C1 at both ends of a move and everywhere +// between, which is the whole of what a Mach band asks for. +// +// DR moves the same four bumps instead of adding its own masked terms on top: it // lifts the toe and rolls the head exactly as before at t = 0 and t = 1 — // 0.12 and 0.18 at full strength — and half of each at the knots next to them, -// but because it is a knot move the ordering clamp holds it too. Added as a -// separate term it could not: with BLACK and SHADOW both at -1 the ramp is flat -// between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through that -// stretch, which is a fold at 0.238. +// so the terms land on the two ends and on the heads beside them as one move. // // Lightness takes the curve; the colour rides the ratio. The pixel moves to its // new luma, and it gets there by scaling its three channels by ONE number, @@ -245,40 +269,65 @@ vec3 lightMove(vec3 c, float t, float o) { return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0); } vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) { - float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18; - // HIGHLIGHT rides half the anchor for the reason a1 below does, and this knot - // is the worse of the two: a3 is the HEAD of the 0.75..1.00 quarter, so a unit - // of lift is a unit of slope the top quarter loses — at the whole anchor +100 - // lands the knot ON a4 and the quarter is drawn flat (a cloud to paper, the - // quarter slopes 1.00 1.00 2.00 0.00), and the same knob the other way pins it - // on 0.50 and flattens the quarter BELOW it instead (1.00 1.00 0.00 2.00). - // Half draws neither: every quarter keeps between 0.5 and 1.5 of its own slope - // over the whole travel, and the head still rolls from 0.625 to 0.875. - float a3 = clamp(0.75 + ${TONE_ANCHOR} * 0.5 * hl - dr * 0.09, 0.5, a4); + // BLACK owns 0.00, the edge of the ramp, so its bump is the half-width one and + // the toe opens to A while the quarter above it takes the fall of it. As a + // bump the fall is a slope and never a plateau, which is what the report was + // about: at +100 the lift used to be spent inside 0.25 and the stretch above it + // came back identical to the untouched frame — the measured 0.0000 from 0.25 up + // in the note at the top of the ramp. Here the mid-tones rise a little with the + // toe and the corner that drew the seam at 0.25 is gone. + float blackA = ${TONE_ANCHOR} * bl + dr * 0.12; // SHADOW rides HALF the anchor, and that is a measured ceiling rather than a - // taste. a1 is the HEAD of the quarter above it, so the band 0.25..0.50 pays - // in slope for every unit this knot rises — the slope is (0.5 - a1) / 0.25 — - // and that band is where a waterfall's spray and a sunlit rock sit. At the - // whole anchor the knot lands ON the midpoint and draws the band flat: on a - // real frame (DSCF1701, misty valley, 21% of its pixels in the band) SHADOW - // +90 came back with 0.10 of the band's own spread and a mean of 0.392 where - // it was 0.489 — the milky white sheet the knob was reported for, bright areas - // with their contrast gone. At half: 0.55 of the spread, mean 0.441, and the - // toe still opens at 1.45x. Half also keeps the knob smooth over its whole - // travel — a clamp alone would pin it from +50 on and leave the top of the - // slider dead — which is why the RATE is halved and not just the ceiling. - float a1 = clamp(0.25 + ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06, 0.0, 0.5); - float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1); + // taste. The knot is the HEAD of the quarter above it, and the bump only ever + // ADDS to the identity, so that quarter pays in slope for every unit the knot + // rises: its mean slope is 1 - A / 0.25, and that band is where a waterfall's + // spray and a sunlit rock sit. At the whole anchor the mean is 0 and the band + // is drawn flat — on a real frame (DSCF1701, misty valley, 21% of its pixels in + // the band) SHADOW +90 came back with 0.10 of the band's own spread and a mean + // of 0.392 where it was 0.489, the milky white sheet the knob was reported for, + // bright areas with their contrast gone. At half: 0.55 of the spread, mean + // 0.441, and the toe still opens at 1.45x. Half also keeps the knob clear of + // the guard below: at a whole-anchor rate the guard would take over past ±65 + // and the top third of the slider would do nothing, which is why the RATE is + // halved and not just a ceiling. + float shadowA = ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06; + // HIGHLIGHT rides half the anchor for the reason SHADOW does, and its knot is + // the head of the 0.75..1.00 quarter: at a whole-anchor rate +100 (A = 0.25) + // draws that quarter flat against the ceiling, its whole mean slope gone, and + // the same knob the other way spends the quarter BELOW the knot instead. Half + // draws neither, and the head still rolls from 0.625 to 0.875 over the travel. + float highA = ${TONE_ANCHOR} * 0.5 * hl - dr * 0.09; + float whiteA = ${TONE_ANCHOR} * wh - dr * 0.18; + // Two bumps share each half of the ramp, and their steep sides can land on the + // same stretch: past a total slope of 1 the sum would carry the curve + // BACKWARDS — a fold, a worse band than the seams this replaced. Read at their + // own steepest points, which is the loosest the pair can be, the two amplitudes + // of a half are held under one number and given up together past it. A knob on + // its own never reaches it — a full BLACK is 0.77, a full SHADOW 0.77, and DR, + // which moves both bumps of a half at once, 0.74 below and 1.11 above for a + // steepest sum of 1.00, so a full DR gives up a tenth of its head roll — so a + // single slider has its whole travel, and only a pair pushed together gives + // anything up: BLACK and SHADOW both at +100 arrive at 0.65 of their own lift + // rather than folding. + float holdLo = ${TONE_BUMP_SLOPE_HALF} * abs(blackA) + ${TONE_BUMP_SLOPE_QUARTER} * abs(shadowA); + float holdHi = ${TONE_BUMP_SLOPE_QUARTER} * abs(highA) + ${TONE_BUMP_SLOPE_HALF} * abs(whiteA); + float kLo = holdLo > 1.0 ? 1.0 / holdLo : 1.0; + float kHi = holdHi > 1.0 ? 1.0 / holdHi : 1.0; + blackA *= kLo; + shadowA *= kLo; + highA *= kHi; + whiteA *= kHi; // The ramp is read at the BASE, so the curve's move is the neighbourhood's and // the pixel keeps its own difference from it — the ratio below is the whole of // the detail layer (fix_shadow.md's Reconstructed = Base' * (Input / Base)), // with lightMove and its caps doing the reconstruction the way every other // brightness move in this file is made. The pixel's own luma still travels as // t: it is the value that is being rebuilt, and the one the caps read. - float o = mix(a0, a1, lin(0.00, 0.25, base)); - o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, base)), step(0.25, base)); - o = mix(o, mix(0.5, a3, lin(0.50, 0.75, base)), step(0.50, base)); - o = mix(o, mix(a3, a4, lin(0.75, 1.00, base)), step(0.75, base)); + float o = base + + blackA * toneBump(base, 0.00, 0.50) + + shadowA * toneBump(base, 0.25, 0.25) + + highA * toneBump(base, 0.75, 0.25) + + whiteA * toneBump(base, 1.00, 0.50); // Below the pedestal there is no base worth a ratio (the same floor lightMove // holds its own scale under); the pixel is left where the ramp put its own // luma, which is what a caller with no neighbourhood hands in anyway.