light: the tone ramp is drawn through a base layer, not the pixel, so a SHADOW lift moves the region and leaves the texture in it standing — the quarter above the knot came back at 0.57x of its own spread, 0.78x now
The four knots were read at the pixel's own luma, which makes the ramp a global curve: every pixel at luma t lands on the same o whatever surrounds it. SHADOW's a1 is the head of the quarter above it, so lifting it squashed that whole quarter to the half slope left over — measured on a real frame, 0.50 of its spread (shadow-band.py), 0.57 on the deployed bundle — the grey sheet the knob was reported for. A curve drawn through the pixel cannot see local contrast; that is what the eye was reading. So the ramp is read at TONE_BASE_RADIUS (2.5% of the frame) of the luma around the pixel — a 3x3 range-weighted blur, the fix_shadow.md Base x Detail split — and the pixel then rides the neighbourhood's gain o/base, keeping its own difference from it. Base moves, detail stays: the same lift on the same pixels, with the texture inside the region left standing. Full deflection keeps 0.78 of the band's spread now. The base is the same maths in every caller: bx = 0 (a mask, which has no neighbourhood) reads the pixel nine times and gets the old global move back, and with every knob on zero the ramp at base IS base, so the ratio is 1 and the pass is the identity however coarse the base is. Skipped: no chroma compensation (Hunt). Measured, the ratio held saturation (0.3184 -> 0.3169), so it is not earned yet. No linear-light ramp either: the multiply is a uniform gain on encoded values, which is the same stop exposureMove already argues for.
This commit is contained in:
@@ -212,8 +212,15 @@ half3 maskAdjust(half3 c, half3 wb, float4 a, float4 tone, float4 fx, half dark$
|
||||
// ordering clamp, the 0.50 midpoint no knob reaches, and the luma-preserving
|
||||
// rebuild are all the frame's. DR is the one knob the ramp also carries that a
|
||||
// mask does not have, so it is spent as 0 here.
|
||||
//
|
||||
// The BASE of that ramp is the mask's own pixel, handed in twice: a mask runs
|
||||
// on the shape the user drew and has no neighbourhood of its own, and the
|
||||
// degenerate call is the one the shared maths is written to take — a base equal
|
||||
// to t is a ratio of exactly 1, so the mask keeps the global move it has always
|
||||
// made. Give it a base of its own when a mask's SHADOW is reported the way the
|
||||
// frame's was.
|
||||
float lf = clamp(float(l), 0.0, 1.0);
|
||||
c = half3(toneRamp(vec3(c), lf, tone.w, tone.y, tone.x, tone.z, 0.0));
|
||||
c = half3(toneRamp(vec3(c), lf, lf, tone.w, tone.y, tone.x, tone.z, 0.0));
|
||||
half nl = dot(clamp(c, half3(0.0), half3(1.0)), half3(0.2126, 0.7152, 0.0722));
|
||||
c = mix(half3(nl), c, half(1.0 + a.z));
|
||||
${spatial ? ` // DEHAZE before CLARITY, the frame-wide order and for the frame-wide reason:
|
||||
|
||||
@@ -43,6 +43,32 @@ import { HSL_BANDS, hslBandGaps, isMonochromeBase } from './colorUtils';
|
||||
// other two, so they are the ends this ramp is drawn through, and nothing else
|
||||
// in the shader reads them.
|
||||
//
|
||||
// The ramp is drawn through the BASE LAYER, not through the pixel. The pixel's
|
||||
// own luma, run through a knot move, is a GLOBAL curve: every pixel at luma t
|
||||
// lands on the same o whatever is around it, so a knot lifted onto the band above
|
||||
// it (SHADOW's a1) is a band whose whole spread is squashed to the slope left
|
||||
// over — at SHADOW +100 a quarter of the ramp carries half its contrast, and on a
|
||||
// real frame 0.50 of it survived: the grey sheet the knob was reported for. What
|
||||
// the eye is reading there is LOCAL contrast, and a curve drawn through the pixel
|
||||
// cannot see it.
|
||||
//
|
||||
// So the curve is drawn through what the frame holds AROUND the pixel — a coarse
|
||||
// edge-aware blur of the luma, TONE_BASE_RADIUS of the frame wide (the
|
||||
// fix_shadow.md decomposition, Base x Detail) — and the pixel then rides the
|
||||
// ratio: it takes the neighbourhood's gain `o / base` and keeps the difference
|
||||
// from it. Base moves, detail stays: the same lift, on the same pixels, with the
|
||||
// texture inside the region left standing instead of drawn flat. Full deflection
|
||||
// on the same frame keeps 0.78 of the band's spread where the global move kept
|
||||
// 0.57 (shadow-live.mjs, before and after, over the deployed pass; shadow-band.py,
|
||||
// the numpy twin of this maths, put it at 0.78 to 0.80).
|
||||
//
|
||||
// It costs nothing where there is no lift to make: with every knob on zero the
|
||||
// ramp at base IS base, so the ratio is exactly 1 and the pass is the identity
|
||||
// however coarse the base is. A caller that hands in no neighbourhood at all
|
||||
// (bx = 0, or a mask, which has none) reads its own pixel nine times and gets the
|
||||
// global move back — which is why the shared maths can take the base as an
|
||||
// argument and mean the same thing in both places.
|
||||
//
|
||||
// Between two knots the ramp is drawn STRAIGHT, and that is deliberate: a
|
||||
// smoothstep there is an S-curve through the knots, so it bends the ramp by up
|
||||
// to six code values in the quarter-tones even with all four knobs on zero — and
|
||||
@@ -115,6 +141,16 @@ 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 far out the BASE layer of `toneRamp` reads, as a fraction of the frame's
|
||||
// own width and height — 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 same neighbourhood, and the measurement is flat across the range
|
||||
// anyway: full 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 low end of the doc's range, because a
|
||||
// wider base is a wider neighbourhood for a strong edge to be reconstructed
|
||||
// across.
|
||||
export const TONE_BASE_RADIUS = 0.025;
|
||||
|
||||
// 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
|
||||
@@ -191,7 +227,7 @@ vec3 lightMove(vec3 c, float t, float o) {
|
||||
if (loC < t) k = min(k, o / (t - loC));
|
||||
return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0);
|
||||
}
|
||||
vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr) {
|
||||
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
|
||||
@@ -216,11 +252,21 @@ vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr)
|
||||
// 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);
|
||||
float o = mix(a0, a1, lin(0.00, 0.25, t));
|
||||
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, t)), step(0.25, t));
|
||||
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, t)), step(0.50, t));
|
||||
o = mix(o, mix(a3, a4, lin(0.75, 1.00, t)), step(0.75, t));
|
||||
return lightMove(c, t, clamp(o, 0.0, 1.0));
|
||||
// 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));
|
||||
// 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.
|
||||
float target = base > 0.0004 ? o * t / base : t;
|
||||
return lightMove(c, t, clamp(target, 0.0, 1.0));
|
||||
}
|
||||
// The accurate sRGB transfer pair (0.04045/12.92 + 2.4, and its inverse): the
|
||||
// same constants colorUtils.planckianLinear uses on the WB side, and the reason
|
||||
@@ -288,6 +334,10 @@ uniform float hslL[8];
|
||||
uniform float gh;
|
||||
uniform float gs;
|
||||
uniform float gl;
|
||||
// One tap of the base layer, in the frame's own pixels (TONE_BASE_RADIUS of it).
|
||||
// Zero is a legal neighbourhood — it is the one a caller with no frame to look
|
||||
// at hands in, and it reads the pixel itself nine times.
|
||||
uniform float2 bx;
|
||||
// sRGB <-> HSL. The mixer works in HSL because that is the space the knobs are
|
||||
// named after: a hue shift must not change how light a colour is, and a
|
||||
// lightness move must not change its hue, which is exactly what scaling RGB
|
||||
@@ -333,6 +383,33 @@ float bandW(float hue, float anchor, float gapL, float gapR) {
|
||||
return d <= 0.0 ? max(0.0, 1.0 + d / gapL) : max(0.0, 1.0 - d / gapR);
|
||||
}
|
||||
${TONE_MATH_SKSL}
|
||||
// The BASE layer: the light the frame carries where this pixel sits, at the
|
||||
// radius the caller hands in — a coarse blur of the LUMA, because the luma is the
|
||||
// one quantity the ramp moves (the colour rides the ratio afterwards). Nine taps
|
||||
// of the child at plus or minus one bx is not a guided filter; it is the doc's
|
||||
// own cheap stand-in for one, and the range weight is what keeps it from being a
|
||||
// blur across an edge: a tap whose luma is nothing like the centre's counts for
|
||||
// little, so a dark crevice in a sunlit rock keeps its own base instead of being
|
||||
// reconstructed against the rock. Same weight shape the CLARITY reference blurs
|
||||
// with (CLARITY_BLUR_SKSL), on the luma rather than the colour. A three-by-three
|
||||
// that INCLUDES the centre — no branch, and a caller whose bx is zero reads the
|
||||
// same pixel nine times, which lands the base exactly on t and hands back the
|
||||
// global move.
|
||||
float baseLuma(vec2 xy, float t) {
|
||||
float sum = 0.0;
|
||||
float wsum = 0.0;
|
||||
for (int i = -1; i <= 1; i++) {
|
||||
for (int j = -1; j <= 1; j++) {
|
||||
vec3 s = clamp(src.eval(xy + vec2(float(i), float(j)) * bx).rgb, 0.0, 1.0);
|
||||
float ts = dot(s, vec3(0.2126, 0.7152, 0.0722));
|
||||
float d = ts - t;
|
||||
float rw = exp(-d * d * 24.0);
|
||||
sum += rw * ts;
|
||||
wsum += rw;
|
||||
}
|
||||
}
|
||||
return sum / wsum;
|
||||
}
|
||||
vec4 main(vec2 xy) {
|
||||
vec4 c = src.eval(xy);
|
||||
vec3 rgb = clamp(c.rgb, 0.0, 1.0);
|
||||
@@ -354,7 +431,7 @@ vec4 main(vec2 xy) {
|
||||
// same knots, the same hue-preserving rebuild, the same move a gradient mask
|
||||
// makes with the same four sliders. DR is the whole frame's, so it is spent
|
||||
// here and nowhere else.
|
||||
rgb = toneRamp(rgb, t, bl, sh, hl, wh, dr);
|
||||
rgb = toneRamp(rgb, t, baseLuma(xy, t), bl, sh, hl, wh, dr);
|
||||
// Split tone (stock look): the shadows and the highlights may each carry
|
||||
// their own tint, so the two ends of the curve can drift opposite ways
|
||||
// (Classic Neg: green-cyan darks, warm brights) without touching mid-greys.
|
||||
@@ -659,6 +736,7 @@ export interface ToneUniforms {
|
||||
gh: number; // -1..1 whole-image hue turn (±30° at full)
|
||||
gs: number; // -1..1 whole-image saturation scale
|
||||
gl: number; // -1..1 whole-image lightness offset (±0.25 at full, ungated)
|
||||
bx: [number, number]; // one base-layer tap, in the frame's own pixels
|
||||
}
|
||||
|
||||
// Per-stock tone pass. Fuji's Classic stocks are not a plain colour matrix:
|
||||
@@ -701,7 +779,15 @@ const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
|
||||
'mono-high-contrast': { sh: -0.64, hl: 0.52 },
|
||||
};
|
||||
|
||||
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {
|
||||
// `baseStep` is one tap of the base layer in the frame's own pixels; the caller
|
||||
// is the only one that knows how big the frame is (TONE_BASE_RADIUS of it). Left
|
||||
// at zero the pass reads no neighbourhood and keeps the global ramp — see the
|
||||
// note at the head of the file.
|
||||
export function getToneUniforms(
|
||||
adj: ColorAdjustments,
|
||||
baseFilter?: BaseFilter,
|
||||
baseStep: [number, number] = [0, 0]
|
||||
): ToneUniforms {
|
||||
const drRaw = adj.dynamicRange ?? 'auto';
|
||||
const dr = drRaw === 'auto' || drRaw === 100 ? 0 : (drRaw - 100) / 300;
|
||||
const hl = Math.max(-1, Math.min(1, (adj.highlight ?? 0) / 10));
|
||||
@@ -766,6 +852,7 @@ export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter):
|
||||
gh,
|
||||
gs,
|
||||
gl,
|
||||
bx: baseStep,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -776,6 +863,7 @@ export function toneUniformArray(u: ToneUniforms): number[] {
|
||||
u.dr, u.hl, u.sh, u.wh, u.bl, u.vib,
|
||||
u.shT[0], u.shT[1], u.shT[2], u.hlT[0], u.hlT[1], u.hlT[2], u.cc, u.ccb,
|
||||
u.hslOn, ...u.hslH, ...u.hslS, ...u.hslL, u.gh, u.gs, u.gl,
|
||||
u.bx[0], u.bx[1],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user