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:
@@ -244,26 +244,95 @@ const KNOB_SKSL = (['bl', 'sh', 'hl', 'wh'] as const)
|
||||
// deflection on the sample frame keeps 0.77 of the band's spread at 0.7%, 0.80 at
|
||||
// 2.5%, 0.81 at 4.8%.
|
||||
//
|
||||
// The caller turns this into a BLUR, and it took a bug to make that a blur in
|
||||
// fact and not only in name. The base used to be nine point samples of the child
|
||||
// out at plus or minus this radius, and point samples are not an average: on a
|
||||
// frame with texture at the sampling scale — a waterfall, a mountainside — the
|
||||
// nine-tap luma aliases, the gain o(base)/base inherits the alias, and the
|
||||
// reconstruction paints it as mottle. Measured on a 1160x774 frame at
|
||||
// SHADOW +100, the high-frequency (9px high-pass) part of that gain field was
|
||||
// 0.063 against 0.005 for a real blur of the same radius — 13x. So the base is
|
||||
// now a real gaussian blur of the child, made by the caller (Skia's own
|
||||
// MakeBlur, see blurredBase() in exportEngine.ts) and read here as ONE tap. That
|
||||
// is also the cheaper pass: nine child evals walk the whole exposure/matrix
|
||||
// chain nine times, one eval does not.
|
||||
// The base used to be nine point samples of the child out at plus or minus this
|
||||
// radius, and point samples are not an average: on a frame with texture at the
|
||||
// sampling scale — a waterfall, a mountainside — the nine-tap luma aliases, the
|
||||
// gain o(base)/base inherits the alias, and the reconstruction paints it as
|
||||
// mottle. Measured on a 1160x774 frame at SHADOW +100, the high-frequency (9px
|
||||
// high-pass) part of that gain field was 0.063 against 0.005 for a real blur of
|
||||
// the same radius — 13x.
|
||||
//
|
||||
// So the base is made by the caller (TONE_BASE_SKSL, see toneBase() in
|
||||
// exportEngine.ts): one bilateral pass per axis, the same 1xN/Nx1 frame
|
||||
// CLARITY_BLUR_SKSL runs (`spatialReference`), read here as ONE tap. A GAUSSIAN —
|
||||
// which is what the caller drew next — averages the neighbourhood whatever is in
|
||||
// it, so at a hard edge it lands halfway across: the pixels beside a bright sky
|
||||
// take the shadow side's move and the halo the doc reports
|
||||
// (docker/halo_highlight_shadow_white_black.md) comes back with the knob. The
|
||||
// range weight is what keeps a bright field's move off the dark side of it: a tap
|
||||
// counts while its luma is within TONE_BASE_RANGE_EV of the base's own, and an
|
||||
// edge of ~0.6 EV is three of those out, so the base stops there.
|
||||
//
|
||||
// A bilateral alone still is not enough, and the second half of the pass answers
|
||||
// it: a range-weighted average PRESERVES a 1 px line (the line's own taps carry
|
||||
// all the weight; its neighbours are cut), so a 1 px twig lands in the base and
|
||||
// every pixel of it takes its own move — the ramp then compresses the twig with
|
||||
// the sky exactly as a global curve would, which is the local contrast this pass
|
||||
// exists to keep. The reference filter cuts the base of the MEDIAN of the
|
||||
// neighbourhood for that reason (lightcraft #632). The centre reference here is
|
||||
// that median — med3 along the pass's own axis, three taps a step apart — so a
|
||||
// line one or two pixels wide is detail and not tone: its own base comes out at
|
||||
// its surroundings' level, it takes the neighbourhood's move, and its own
|
||||
// difference from the base is added back whole (Base' + Detail, TONE_MATH_SKSL).
|
||||
export const TONE_BASE_RADIUS = 0.025;
|
||||
|
||||
// One gaussian sigma of the base blur, as a fraction of TONE_BASE_RADIUS. A box
|
||||
// Taps per side of each axis pass. The step follows from the radius, so the
|
||||
// neighbourhood is still the one the radius names at 2 * TAPS + 1 taps.
|
||||
export const TONE_BASE_TAPS = 8;
|
||||
|
||||
// One gaussian sigma of the base pass, as a fraction of TONE_BASE_RADIUS. A box
|
||||
// of the same radius and a gaussian of this sigma carry the same weight at the
|
||||
// radius, so the neighbourhood is the one the radius has always named while the
|
||||
// cuts are smooth instead of hard.
|
||||
// radius, so the spatial falloff still reaches the neighbourhood the radius
|
||||
// names while the cuts are smooth instead of hard — 2 * 0.35 * TONE_BASE_RADIUS,
|
||||
// the gaussian the base used to be.
|
||||
export const TONE_BASE_SIGMA = 0.35;
|
||||
|
||||
// The range sigma of the base's bilateral weight, in stops of the pixel's own
|
||||
// luma: a tap counts while log2(tap / reference) is inside it. 0.25 EV puts an
|
||||
// edge of 0.6 EV (the reference's "an edge of > ~0.6 EV is preserved") three
|
||||
// sigmas out and cuts it; a texture of 0.05..0.1 EV is inside one sigma and is
|
||||
// averaged away, which is what a base has to do with texture.
|
||||
export const TONE_BASE_RANGE_EV = 0.25;
|
||||
|
||||
// The base's own pedestal, in luma: the luma a ratio is taken against, so a pixel
|
||||
// with almost no light on it still divides by something. The same 0.005 floor
|
||||
// lightMove reads, at the one place a base can be read on a black frame.
|
||||
const TONE_BASE_PEDESTAL = 0.02;
|
||||
|
||||
// One sample step of the base pass, in px, for a surface this size: TONE_BASE_TAPS
|
||||
// of them per side reach TONE_BASE_RADIUS of the LONG edge. A whole number of
|
||||
// pixels, so a tap lands ON a pixel — the pass then reads the same pixels on the
|
||||
// preview and on the file, and a harness's twin can add the same step.
|
||||
export function toneBaseStep(width: number, height: number): number {
|
||||
return Math.max(1, Math.round((Math.max(width, height) * TONE_BASE_RADIUS) / TONE_BASE_TAPS));
|
||||
}
|
||||
|
||||
// The base pass runs on a surface this fraction of the frame per axis, because
|
||||
// the base IS the level of a neighbourhood TONE_BASE_RADIUS of the long edge
|
||||
// wide (toneBaseStep of the half above): half the samples are all of the detail
|
||||
// that level can carry, and the pass costs a quarter of what it does on the
|
||||
// frame. Measured on the 1067x1600 preview with the same drag: the two axis
|
||||
// passes at 8 taps made a 715 ms render on the FRAME and a 410 ms one on this
|
||||
// half — against 405 ms for the MakeBlur they replaced (scratchpad
|
||||
// preview-render-measure.mjs, timed in one session), which is what the quarter
|
||||
// buys. The base is unmoved to the eye at that scale
|
||||
// (tone-local-contrast-check.mjs measures the base and the halo).
|
||||
export const TONE_BASE_SCALE = 0.5;
|
||||
|
||||
// The surface the base pass draws on, and the tap step that goes with it: the
|
||||
// step is taken from the RADIUS and the half's own long edge, so the
|
||||
// neighbourhood is the one TONE_BASE_RADIUS names of the FRAME either way. The
|
||||
// caller scales the pass's draw to reach it (see toneBase in exportEngine.ts),
|
||||
// which is also what box-downscales the frame's child into it.
|
||||
export function toneBaseSurface(
|
||||
width: number,
|
||||
height: number
|
||||
): { width: number; height: number; step: number } {
|
||||
const w = Math.max(1, Math.round(width * TONE_BASE_SCALE));
|
||||
const h = Math.max(1, Math.round(height * TONE_BASE_SCALE));
|
||||
return { width: w, height: h, step: toneBaseStep(w, h) };
|
||||
}
|
||||
|
||||
// The tone and exposure maths, in ONE copy, because two passes ask it: the
|
||||
// whole-frame passes here and a gradient mask, which moves the same knobs on the
|
||||
// shape the user drew. What "HIGHLIGHT" or "EXPOSURE" means must not depend on
|
||||
@@ -323,10 +392,21 @@ vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float w
|
||||
float hlVal = clamp(hl - dr * 0.09, -1.0, 1.0);
|
||||
float whVal = clamp(wh - dr * 0.18, -1.0, 1.0);
|
||||
|
||||
// Evaluate the tone curve at pixel luma t so every pixel gets a spatially uniform
|
||||
// global move across the entire frame without any halo or aura ring!
|
||||
float o = toneCurve(t, blVal, shVal, hlVal, whVal);
|
||||
float target = clamp(o, 0.0, 1.0);
|
||||
// BASE' + DETAIL: the ramp is read at the neighbourhood's luma and the pixel's
|
||||
// own difference from it is ADDED to the result, never scaled by it — the move
|
||||
// belongs to the neighbourhood (which is what stops a knob turning one region
|
||||
// of the frame into a different exposure from the one next to it) and the
|
||||
// detail belongs to the pixel (which is what keeps a twig a twig). Read at the
|
||||
// pixel, the move is read off the pixel itself and a hole in the sky is only in the
|
||||
// statistics, not in the picture; read at the base, the same pixels take the
|
||||
// same move and the 1 px lines inside the region keep their own contrast.
|
||||
//
|
||||
// The base lands ON the pixel when the caller has no neighbourhood to speak of
|
||||
// — a gradient mask hands in the pixel's own luma (see gradientMask.ts) — and there
|
||||
// two readings are the same instruction, so the degenerate call is the global
|
||||
// move this used to do and not a second opinion about it.
|
||||
float o = toneCurve(base, blVal, shVal, hlVal, whVal);
|
||||
float target = clamp(o + (t - base), 0.0, 1.0);
|
||||
|
||||
vec3 res = lightMove(c, t, target);
|
||||
|
||||
@@ -395,11 +475,93 @@ vec3 exposureMove(vec3 rgb, float ev) {
|
||||
}
|
||||
`;
|
||||
|
||||
// THE BASE LAYER of the tone ramp, ONE AXIS PER PASS. The caller runs it twice
|
||||
// (horizontal, then vertical, both on surfaces the size of the frame) and hands
|
||||
// the result to TONE_SKSL as its `base` child; it returns the neighbourhood's
|
||||
// LUMA, so the second pass and the ramp read it without re-weighting anything.
|
||||
//
|
||||
// Two properties, and the pass is the two of them:
|
||||
//
|
||||
// EDGE-AWARE. The spatial falloff is a gaussian of TONE_BASE_SIGMA out to
|
||||
// TONE_BASE_RADIUS (see those), and each tap is weighted again by how far its
|
||||
// luma is from the centre's, in stops — exp(-ev²) with TONE_BASE_RANGE_EV the
|
||||
// sigma — so a tap across an edge of more than ~0.6 EV is worth nothing and the
|
||||
// base is the level of THIS side of the edge. That is what a gaussian base
|
||||
// cannot do and the reason the ramp had to fall back to a global curve: at a
|
||||
// hard edge a gaussian hands the dark pixels a share of the bright side's move
|
||||
// and the halo (halo_highlight_shadow_white_black.md) follows the knob.
|
||||
//
|
||||
// THE CENTRE IS A MEDIAN. The range weight is read against med3 along the
|
||||
// pass's own axis — the centre and its two neighbours a step out — and not
|
||||
// against the centre pixel. A range-weighted average PRESERVES a 1 px line (at
|
||||
// the line, every tap but the line's own is cut, so the line IS the base and
|
||||
// the ramp then compresses it against the sky like any global curve: the local
|
||||
// contrast this whole pass exists to keep). A median is blind to a line one or
|
||||
// two pixels wide, so at a twig the reference is the sky, the twig takes the
|
||||
// sky's move, and its own difference from the base rides through the ramp
|
||||
// untouched (Base' + Detail, TONE_MATH_SKSL). The two passes together are the
|
||||
// reference filter's median_hv + guided filter (lightcraft local.rs, #632).
|
||||
//
|
||||
// The luma weights are the ramp's own, and the difference is taken as
|
||||
// (tap - reference) / (reference + TONE_BASE_PEDESTAL): a ratio, so the range
|
||||
// sigma is the same size of edge on a dark frame as on a bright one.
|
||||
export const TONE_BASE_SKSL = `
|
||||
uniform shader src;
|
||||
// One tap step along this pass's axis, in px OF THIS PASS'S SURFACE — the half
|
||||
// of the frame toneBaseSurface names: whole, so every tap lands on a pixel (see
|
||||
// toneBaseStep). Read out at the frame's own size it is TONE_BASE_RADIUS wide.
|
||||
uniform float2 dir;
|
||||
const float3 TONE_BASE_LUM = vec3(0.2126, 0.7152, 0.0722);
|
||||
// The spatial falloff, in tap indices: a gaussian of TONE_BASE_SIGMA of the
|
||||
// radius, TAPS taps out.
|
||||
const float TONE_BASE_SPACE_SIGMA = ${(TONE_BASE_TAPS * TONE_BASE_SIGMA).toFixed(3)};
|
||||
// ln(2) * TONE_BASE_RANGE_EV: (tap - reference) / (reference + pedestal) over
|
||||
// this IS the difference in stops, which is what the range sigma counts.
|
||||
const float TONE_BASE_RANGE = ${(Math.LN2 * TONE_BASE_RANGE_EV).toFixed(6)};
|
||||
const float TONE_BASE_PED = ${TONE_BASE_PEDESTAL.toFixed(4)};
|
||||
float toneBaseLum(vec4 c) {
|
||||
return dot(clamp(c.rgb, 0.0, 1.0), TONE_BASE_LUM);
|
||||
}
|
||||
// The median of three: max(min(a, b), min(max(a, b), c)).
|
||||
float toneBaseMed3(float a, float b, float c) {
|
||||
return max(min(a, b), min(max(a, b), c));
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
float l0 = toneBaseLum(c);
|
||||
float ref = toneBaseMed3(toneBaseLum(src.eval(xy - dir)), l0, toneBaseLum(src.eval(xy + dir)));
|
||||
// The centre contributes its MEDIAN, so a line the median is blind to cannot
|
||||
// pull the base with it.
|
||||
float sum = ref;
|
||||
float total = 1.0;
|
||||
for (int i = 1; i <= ${TONE_BASE_TAPS}; i++) {
|
||||
float fi = float(i);
|
||||
float g = exp(-0.5 * (fi / TONE_BASE_SPACE_SIGMA) * (fi / TONE_BASE_SPACE_SIGMA));
|
||||
float a1 = toneBaseLum(src.eval(xy + dir * fi));
|
||||
float a2 = toneBaseLum(src.eval(xy - dir * fi));
|
||||
float d1 = (a1 - ref) / (ref + TONE_BASE_PED) / TONE_BASE_RANGE;
|
||||
float d2 = (a2 - ref) / (ref + TONE_BASE_PED) / TONE_BASE_RANGE;
|
||||
float r1 = exp(-d1 * d1);
|
||||
float r2 = exp(-d2 * d2);
|
||||
sum += g * (r1 * a1 + r2 * a2);
|
||||
total += g * (r1 + r2);
|
||||
}
|
||||
float base = sum / total;
|
||||
return vec4(base, base, base, c.a);
|
||||
}
|
||||
`;
|
||||
|
||||
export const TONE_SKSL = `
|
||||
uniform shader src;
|
||||
// The BASE layer's child: a blur of the image above, of TONE_BASE_RADIUS, made
|
||||
// by the caller. Read for its luma alone, and read ONCE per pixel — the
|
||||
// neighbourhood is the blur's, not a sampling loop's (see TONE_BASE_RADIUS).
|
||||
// The BASE layer's child: the image above run through TONE_BASE_SKSL, of
|
||||
// TONE_BASE_RADIUS, made by the caller. Read for its luma alone, and read ONCE
|
||||
// per pixel — the neighbourhood is the base pass's, not a sampling loop's (see
|
||||
// TONE_BASE_RADIUS). The caller builds it at TONE_BASE_SCALE and hands it in
|
||||
// with the local matrix that puts it back in the frame's coordinates, so the
|
||||
// reads below are the frame's own — and with a NEAREST sampler: that matrix is a
|
||||
// whole 2x for TONE_BASE_SCALE, and a linear read of a step edge at 2x leaves a
|
||||
// quarter of the far side in the pixel beside it, which is the halo the base
|
||||
// exists to not have (see baseUpscale in exportEngine.ts).
|
||||
uniform shader base;
|
||||
uniform float dr;
|
||||
uniform float hl;
|
||||
@@ -468,17 +630,17 @@ float bandW(float hue, float anchor, float gapL, float gapR) {
|
||||
}
|
||||
${TONE_MATH_SKSL}
|
||||
// The BASE layer: the light the frame carries where this pixel sits, at the
|
||||
// radius the caller handed in. One tap of a real blur of the same child the
|
||||
// pixel comes from, so it is an AVERAGE of the neighbourhood and not a handful
|
||||
// of point samples of it — that distinction is the whole bug (see
|
||||
// TONE_BASE_RADIUS). Luma, because the luma is the one quantity the ramp moves
|
||||
// and the colour rides the ratio afterwards; the blur being linear, blurring the
|
||||
// child and taking its luma is the same as blurring the luma.
|
||||
// radius the caller handed in — one tap of an edge-aware pass over the same
|
||||
// child the pixel comes from (TONE_BASE_SKSL), so it is the LEVEL of this
|
||||
// pixel's own side of an edge and not a handful of point samples of it. That
|
||||
// distinction is the whole bug (see TONE_BASE_RADIUS). The pass already returns
|
||||
// the luma in all three channels; the clamp is here for a shader that cannot be
|
||||
// built, where the caller hands in the child itself.
|
||||
//
|
||||
// A caller with no neighbourhood to speak of hands in the child itself as the
|
||||
// base (see blurredBase()): the tap then lands exactly on t and the pass falls
|
||||
// back to the global move, which is what a shape with a mask's degenerate base
|
||||
// wants and what it got from a bx of zero before.
|
||||
// base (see toneBase()): the tap then lands exactly on t and the pass falls back
|
||||
// to the global move, which is what a shape with a mask's degenerate base wants
|
||||
// and what it got from a bx of zero before.
|
||||
float baseLuma(vec2 xy) {
|
||||
vec3 s = clamp(base.eval(xy).rgb, 0.0, 1.0);
|
||||
return clamp(dot(s, vec3(0.2126, 0.7152, 0.0722)), 0.0, 1.0);
|
||||
|
||||
Reference in New Issue
Block a user