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.