light: the tone ramp is four bumps on the identity, not straight segments between five knots — a knot is an angle, an angle in a tone curve is a Mach band, and the wedge reads the seams: BLACK +100 broke at 0.030 with 105 of second difference, HIGHLIGHT -100 at 0.747 with 72

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.
This commit is contained in:
2026-09-30 21:24:01 +07:00
parent 097e383b86
commit 166a677590
3 changed files with 242 additions and 112 deletions
+117 -62
View File
@@ -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');
+32 -6
View File
@@ -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}); ` +