light: the tone base is a real blur, not nine point samples of one — the ring aliased the luma and the ramp painted the alias back as mottle

The base layer shipped last commit was nine POINT SAMPLES of the child, one
ring out at TONE_BASE_RADIUS. A ring is not an average, and on a frame with
texture at the ring's own scale it is worse than one: the nine lumas differ,
the sample pattern beats against the texture, and the base field comes out
aliased. The gain the pixel then rides, o(base)/base, is a function of the
base with a kink at every knot — so each alias of the base becomes an alias
of the gain, and the reconstruction paints it straight back over the detail
it was supposed to leave standing. Reported on a waterfall: grey patches
loose on a mountainside, a smear across the face of the falls, and plateaus
in cloud and sky (the pass runs whole-frame, so its base is read in the
bright end too).

Measured, 1160x774 at SHADOW +100, the high-frequency part of the gain field
(9px high-pass, where a real base can hold none): 0.0627 for the ring,
0.0048 for a blur of the same radius. Thirteen times. The fix is not more
taps — a 7x7 at a third of the step is still point samples, only smaller —
it is to stop sampling: the caller blurs.

So the base is now a second CHILD of the tone pass, the frame blurred by
Skia's own MakeBlur (blurredBase in exportEngine.ts, the draw drawBlurred
already made for the sharpening pass) at TONE_BASE_RADIUS of the frame width
and sigma TONE_BASE_SIGMA of that radius — a box's equivalent at the radius,
so the neighbourhood is the one the radius always named and the cuts are
smooth instead of hard. The shader reads it ONCE per pixel and baseLuma
loses its loop and its `bx`. That is also the cheaper pass: nine child evals
walked the exposure/matrix chain nine times, one eval does not.

A caller with no frame to blur hands in the image it is already shading as
the base. The tap then lands exactly on t and the ramp is the global move
again — a mask's degenerate bx of zero, spelled as a child that IS the
source, which is what gradientMask.ts's own call (base == t) already meant.

Checks: tone-base-check.mjs is new — SkSL is only compiled at runtime and
nothing here compiled TONE_SKSL whole, so the pass is compiled and rendered
for real, with the frame and the base held at two flat values a little
apart: the pixel has to land on the value the ramp over THAT base predicts
(171 for a 128 pixel over a 76 base), and a base equal to the pixel has to
be the identity. highlight-knee-check now pins the one tap, the absence of
`bx`, the blur, and the two-child wiring. 9/9 pass, build clean.

Skipped: no guided filter proper — the base is a plain blur, so a strong
edge is no longer held out of it the way the range weight held it (a dark
rock a blur's width from the water reads a lifted base and keeps its own
darkness). Add when a frame shows the halo; the doc asks for a plain blur
and this is one.
This commit is contained in:
2026-09-30 19:03:55 +07:00
parent e61dccc784
commit 097e383b86
4 changed files with 254 additions and 70 deletions
+53 -8
View File
@@ -35,6 +35,7 @@ import {
dehazeUniformArray,
getToneUniforms,
TONE_BASE_RADIUS,
TONE_BASE_SIGMA,
toneIsActive,
toneUniformArray,
glowUniformArray,
@@ -310,6 +311,40 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num
disposeAll([paint, filter, snap]);
}
// The BASE layer of the tone ramp: the frame blurred out to TONE_BASE_RADIUS, as
// an image the tone shader takes as its second child and reads once per pixel.
// Blurred with Skia's own MakeBlur — the same draw drawBlurred makes for the
// sharpening pass — because what the ramp needs is an AVERAGE of the
// neighbourhood and a ring of point samples is not one: sampled on a fine ring
// the luma aliases, the gain o(base)/base inherits the alias, and the
// reconstruction paints it back as mottle (a 1160x774 frame at SHADOW +100 came
// out with 0.063 of high-frequency gain against 0.005 for this blur — see
// TONE_BASE_RADIUS in toneShader.ts). `shaderOf` is left owned by the caller: the
// tone pass reads the same shader as its first child.
function blurredBase(
shaderOf: () => any,
w: number,
h: number,
sigma: number
): any | null {
const surf = createSurface(w, h);
if (!surf) return null;
try {
const shader = shaderOf();
if (!shader) return null;
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
const paint = Skia.Paint();
paint.setImageFilter(filter);
paint.setShader(shader);
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
flush(surf);
disposeAll([paint, filter]);
return surf.makeImageSnapshot() ?? null;
} finally {
surf.dispose();
}
}
// --- spatial passes: CLARITY and DEHAZE -------------------------------------
//
// Both of the scratchpad docs' algorithms (raw_parameter_processing... §3 and
@@ -797,13 +832,14 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
const matrixIsIdentity = matrix.every((v, i) => v === (i % 6 === 0 ? 1 : 0));
// 3b. Tone shader.
// The base layer the ramp is drawn through reads a fraction of the FRAME, so
// the preview and the file look at the same neighbourhood — this is the one
// place that knows how big the frame is (TONE_BASE_RADIUS, see toneShader.ts).
const tone = getToneUniforms(adjustments, recipe.baseFilter, [
width * TONE_BASE_RADIUS,
height * TONE_BASE_RADIUS,
]);
// TONE_BASE_RADIUS of the FRAME is the neighbourhood the ramp is drawn
// through, so the preview and the file look at the same size of one — this is
// the one place that knows how big the frame is. The base itself is a blurred
// copy of the tone shader's own child (blurredBase), not a ring of samples
// inside it: see TONE_BASE_RADIUS for why a ring was the mottle bug. A frame
// too small or a surface that will not come up leaves the sharp child as the
// base, which lands the base on the pixel and keeps the global ramp.
const tone = getToneUniforms(adjustments, recipe.baseFilter);
let toneShader: any = null;
// 3c. Cinema seasonal grade (cinema → tone → image).
const cinema = getCinemaUniforms(recipe.cinema);
@@ -844,7 +880,16 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
if (toneIsActive(tone)) {
const { toneEffect: effect } = effects();
if (effect) {
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [baseShaderOf()]));
// The base is built HERE and not earlier: it draws the same shader the
// tone pass reads, and both have to see the pixels after the exposure.
const sigma = width * TONE_BASE_RADIUS * TONE_BASE_SIGMA;
const base = own(blurredBase(baseShaderOf, width, height, sigma));
toneShader = own(
effect.makeShaderWithChildren(toneUniformArray(tone), [
baseShaderOf(),
base ? own(imageShaderChild(base)) : baseShaderOf(),
])
);
}
}
if (cinemaOn) {