fix(tone): read the ramp's base from the pixel's own neighbourhood, so highlights and shadows move a region and keep the detail in it

This commit is contained in:
2026-10-15 00:55:09 +07:00
parent 8809eb84b0
commit ba181da86b
5 changed files with 1017 additions and 90 deletions
+38 -13
View File
@@ -1,6 +1,8 @@
// The tone ramp is drawn through a BASE layer, and that base is now a blurred
// CHILD of the pass rather than a ring of point samples inside it — see
// TONE_BASE_RADIUS in toneShader.ts for the mottle that ring caused. Two things
// The tone ramp is drawn through a BASE layer, and that base is a CHILD of the
// pass — the edge-aware pass of TONE_BASE_SKSL, run by the caller — rather than a
// ring of point samples inside it or a blur of it: see TONE_BASE_RADIUS in
// toneShader.ts for the mottle a ring caused and for the halo a gaussian one
// does. Two things
// can go wrong with that and neither is a crash: the shader stops compiling once
// it takes a second child (SkSL is only checked at runtime, and no other check
// here compiles TONE_SKSL as a whole), or the second child is wired to the wrong
@@ -34,12 +36,16 @@ writeFileSync(
),
);
const {
TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
TONE_KNOBS,
TONE_SKSL, TONE_BASE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, TONE_BASE_TAPS, toneBaseStep,
getToneUniforms, toneUniformArray, toneIsActive, TONE_KNOBS,
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
// The shader has to NAME a base child — a ring of taps would not need one.
// The shader has to NAME a base child — a ring of taps would not need one — and
// the base pass has to take its own two, one per axis.
assert.match(TONE_SKSL, /uniform shader base;/);
assert.match(TONE_BASE_SKSL, /uniform shader src;/);
assert.match(TONE_BASE_SKSL, /uniform float2 dir;/);
assert.ok(TONE_BASE_TAPS > 0 && toneBaseStep(1200, 600) * TONE_BASE_TAPS >= 1200 * TONE_BASE_RADIUS - 0.5);
const CanvasKit = await CanvasKitInit({
locateFile: () => fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
@@ -118,17 +124,34 @@ const t = srcValue / 255;
const b = baseValue / 255;
// The neighbourhood's new luma, plus the pixel's own DIFFERENCE from the base —
// Base' + Detail, the reconstruction the shader emits (see the note in
// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and
// it is what took the texture out of the frame at BLACK -100.
const diff = Math.abs(t - b);
const smooth = (x) => x * x * (3 - 2 * x);
const expected = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
// toneRamp). Two other reconstructions were here before it: the ramp read at the
// PIXEL (a global curve, and the local-contrast report), and Base' * (Input /
// Base), the gain-weighted detail that took the texture out of the frame at
// BLACK -100.
const detail = (pixel, base) => clamp01(ramp(base, { sh: 1 }).o + (pixel - base));
const expected = Math.round(255 * detail(t, b));
const got = render(srcValue, baseValue);
assert.ok(
Math.abs(got - expected) <= 2,
`a base of ${b.toFixed(4)} under a pixel of ${t.toFixed(4)} gave ${got}, the ramp predicts ${expected}`,
);
// The same instruction on the other side of the pixel: a base BRIGHTER than it
// takes the neighbourhood's move and hands the pixel its own difference back, so
// the pixel lands BELOW the neighbourhood's new luma. One move per
// neighbourhood, not one per pixel — which is exactly what a global curve
// cannot say.
const brighterValue = 180;
const brighter = render(srcValue, brighterValue);
const brighterWant = Math.round(255 * detail(t, brighterValue / 255));
assert.ok(
Math.abs(brighter - brighterWant) <= 2,
`a base of ${(brighterValue / 255).toFixed(4)} over a pixel of ${t.toFixed(4)} gave ${brighter}, Base'+Detail predicts ${brighterWant}`,
);
assert.ok(
brighter < got,
`a brighter base did not pull the pixel down (${brighter} against ${got}) — the base is not in the move`,
);
// With the base handed in as the sharp image the difference is exactly zero and
// the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no
// neighbourhood of its own) needs it to be. It is NOT the identity any more:
@@ -161,6 +184,8 @@ assert.equal(
console.log(
`tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` +
`(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
`radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`,
`(Base'+Detail predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
`over a base of ${brighterValue} -> ${brighter} (predicts ${brighterWant}); ` +
`radius ${TONE_BASE_RADIUS} of the frame, ${TONE_BASE_TAPS} taps per side, step ` +
`${toneBaseStep(1200, 600)} px at 1200, sigma ${TONE_BASE_SIGMA} of the radius`,
);