From e61dccc7846e3ae6be40faff98137a64b0130a92 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 30 Sep 2026 18:46:36 +0700 Subject: [PATCH] =?UTF-8?q?light:=20the=20tone=20ramp=20is=20drawn=20throu?= =?UTF-8?q?gh=20a=20base=20layer,=20not=20the=20pixel,=20so=20a=20SHADOW?= =?UTF-8?q?=20lift=20moves=20the=20region=20and=20leaves=20the=20texture?= =?UTF-8?q?=20in=20it=20standing=20=E2=80=94=20the=20quarter=20above=20the?= =?UTF-8?q?=20knot=20came=20back=20at=200.57x=20of=20its=20own=20spread,?= =?UTF-8?q?=200.78x=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../frontend/scripts/highlight-knee-check.mjs | 123 ++++++++++++++++-- docker/frontend/shared/utils/gradientMask.ts | 9 +- docker/frontend/shared/utils/toneShader.ts | 104 +++++++++++++-- docker/frontend/src/engine/exportEngine.ts | 9 +- 4 files changed, 222 insertions(+), 23 deletions(-) diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index 257e557..ac1fcb4 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -60,7 +60,43 @@ const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1]; assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths'); assert.equal((mathTmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR'); assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again'); -assert.match(tmpl, /rgb = toneRamp\(rgb, t, bl, sh, hl, wh, dr\);/); +assert.match(tmpl, /rgb = toneRamp\(rgb, t, baseLuma\(xy, t\), bl, sh, hl, wh, dr\);/); +// The BASE layer the ramp is drawn through, and the radius the caller spends on +// it. It reads the CHILD nine times — a caller whose bx is zero reads its own +// pixel nine times and gets the global move back, which is what makes the shared +// maths safe for a mask (see the mask's own call below). +assert.match(tmpl, /float baseLuma\(vec2 xy, float t\) \{/); +assert.match(tmpl, /vec3 s = clamp\(src\.eval\(xy \+ vec2\(float\(i\), float\(j\)\) \* bx\)\.rgb, 0\.0, 1\.0\);/); +assert.match(tmpl, /float rw = exp\(-d \* d \* 24\.0\);/); +assert.doesNotMatch(tmpl, /if \(i == 0 && j == 0\) continue;/, 'the 3x3 grew a branch — bx = 0 no longer returns exactly t'); +assert.match(tmpl, /uniform float2 bx;/); +// One tap of the base is a FRACTION of the frame, so the preview and the file +// look at the same neighbourhood: the pass has the frame size, the shader has a +// step in its own pixels. +assert.match(tone, /export const TONE_BASE_RADIUS = ([0-9.]+);/); +const baseRadius = Number(tone.match(/export const TONE_BASE_RADIUS = ([0-9.]+);/)[1]); +assert.ok(baseRadius >= 0.02 && baseRadius <= 0.05, `the base reads ${baseRadius} of the frame — the doc asks for 2%..5%`); +const engine = readFileSync(new URL('../src/engine/exportEngine.ts', import.meta.url), 'utf8'); +assert.match( + engine, + /getToneUniforms\(adjustments, recipe\.baseFilter, \[\s*width \* TONE_BASE_RADIUS,\s*height \* TONE_BASE_RADIUS,\s*\]\)/, + 'the tone pass no longer hands the base a frame-sized step' +); +// The uniform block: the shader's declarations, arrays expanded and in +// declaration order, have to be the numbers `toneUniformArray` writes — a +// mismatch is a silent off-by-one down the whole block. +const declared = [...tmpl.matchAll(/uniform (float2|float) (\w+)(?:\[(\d+)\])?;/g)].reduce( + (n, [, kind, , len]) => n + (len ? Number(len) : kind === 'float2' ? 2 : 1), + 0 +); +const arrayFn = tone.match(/export function toneUniformArray\(u: ToneUniforms\): number\[\] \{\n return \[([\s\S]*?)\n \];/)?.[1]; +assert.ok(arrayFn, 'toneUniformArray is gone'); +const written = arrayFn + .split(',') + .map((s) => s.trim()) + .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)); const sksl = resolve(tmpl); @@ -69,7 +105,11 @@ assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — o const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8'); assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths'); assert.match(mask, /c = half3\(exposureMove\(vec3\(c\), a\.x\)\);/); -assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/); +// The mask hands the ramp its OWN pixel as the base, twice over: a shape has no +// neighbourhood of its own, and base == t is a ratio of exactly 1, so what a mask +// does with SHADOW is what it always did. The knob means the same thing on both +// sides of the call; what differs is the neighbourhood, and a mask has none. +assert.match(mask, /c = half3\(toneRamp\(vec3\(c\), lf, lf, tone\.w, tone\.y, tone\.x, tone\.z, 0\.0\)\);/); assert.doesNotMatch(mask, /0\.55, 1\.35/, 'the mask kept its own arbitrary saturation clamp'); assert.doesNotMatch(mask, /cg = clamp\(lifted/, 'the mask is back on its own tone formula'); @@ -93,10 +133,17 @@ assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — // 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, t\)\);/); -assert.match(sksl, /o = mix\(o, mix\(a1, 0\.5, lin\(0\.25, 0\.50, t\)\), step\(0\.25, t\)\);/); -assert.match(sksl, /o = mix\(o, mix\(0\.5, a3, lin\(0\.50, 0\.75, t\)\), step\(0\.50, t\)\);/); -assert.match(sksl, /o = mix\(o, mix\(a3, a4, lin\(0\.75, 1\.00, t\)\), step\(0\.75, t\)\);/); +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\)\);/); +// ...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 +// band above SHADOW would be drawn flat, which is the whole bug. +assert.match(sksl, /float target = base > 0\.0004 \? o \* t \/ base : t;/); +assert.match(sksl, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/); +assert.doesNotMatch(sksl, /lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\)/, 'the ramp is read at the pixel again — a global curve'); assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped again'); // The linear-light knee that used to run ahead of all this is GONE from the tone // pass: HIGHLIGHT is one zone move in both directions now, and a second pass over @@ -116,7 +163,7 @@ assert.match(maths, /float k = t > 0\.0004 \? o \/ t : 1\.0;/); assert.match(maths, /if \(hiC > t\) k = min\(k, \(1\.0 - o\) \/ \(hiC - t\)\);/); assert.match(maths, /if \(loC < t\) k = min\(k, o \/ \(t - loC\)\);/); assert.match(maths, /return clamp\(vec3\(o\) \+ \(c - vec3\(t\)\) \* k, 0\.0, 1\.0\);/); -assert.match(maths, /return lightMove\(c, t, clamp\(o, 0\.0, 1\.0\)\);/); +assert.match(maths, /return lightMove\(c, t, clamp\(target, 0\.0, 1\.0\)\);/); assert.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back'); assert.doesNotMatch(maths, /float k = 1\.0;/, 'the chroma-constant scale came back — a shadow lift drains the colour'); // The transfer pair has to be the accurate one where it is still used (the @@ -353,11 +400,17 @@ function lightMove(rgb, t, o) { if (loC < t) k = Math.min(k, o / (t - loC)); return rgb.map((c) => o + (c - t) * k); } -function rebuild(rgb, k) { +// The rebuild, with the base layer the shader now draws the ramp through. `base` +// defaults to the pixel itself — the degenerate call, and the one a mask makes — +// which lands `target` back on `o` and is the move this function had before there +// was a base at all. +function rebuild(rgb, k, base) { const t = lumaOf(rgb); - const o = ramp(t, k).o; - const out = lightMove(rgb, t, o); - return { out, clamped: out.map((c) => clamp01(c)), o, t }; + const b = base ?? t; + const o = ramp(b, k).o; + const target = clamp01(b > 0.0004 ? (o * t) / b : t); + const out = lightMove(rgb, t, target); + return { out, clamped: out.map((c) => clamp01(c)), o, t, base: b, target }; } // The transfer pair the exposure pass crosses into linear light with, and back. const srgbToLin = (c) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4); @@ -441,10 +494,54 @@ for (const [rgb, knobs] of [ ]) { const lifted = rebuild(rgb, knobs); const grew = (lifted.clamped[0] - lifted.clamped[1]) / (rgb[0] - rgb[1]); - assert.ok(Math.abs(lifted.o / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`); - close(grew, lifted.o / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`); + assert.ok(Math.abs(lifted.target / lifted.t - 1) > 1e-3, `the lift left the ramp alone on ${rgb} — the case proves nothing`); + close(grew, lifted.target / lifted.t, `the chroma did not ride the ratio on ${rgb} at ${JSON.stringify(knobs)}`); } +// THE BASE LAYER. A band with texture in it — SHADOW's own quarter, 0.26 to +// 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. +// 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. +// +// Those are the two numbers the live probe reads off the deployed bundle (0.57 +// before, 0.78 after, over this frame); this is the same claim in arithmetic. +const band = Array.from({ length: 32 }, (_, i) => 0.26 + 0.24 * (i / 31)); +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'); +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'); +assert.ok( + spread(movedLocally) / spread(movedGlobally) > 1.5, + `the base is not earning its keep: ${spread(movedLocally) / spread(movedGlobally)}x the global move's spread` +); +// The gain belongs to the NEIGHBOURHOOD, not to the pixel: two pixels of one base +// take the same one however far apart they sit, which is exactly what leaves the +// difference between them standing. (Read at the pixel, the gain would be the +// pixel's own o / t — the slope of the curve where the pixel happens to be.) +for (const [lo, hi] of [[0.28, 0.44], [0.30, 0.48]]) { + const a = rebuild([lo, lo, lo], { sh: 1 }, 0.38).clamped[0] / lo; + const b = rebuild([hi, hi, hi], { sh: 1 }, 0.38).clamped[0] / hi; + close(a, b, 'the gain is the pixel’s again, not the neighbourhood’s'); +} +// Every knob on zero is the identity through the base path too, whatever base is +// handed in — the ramp at b IS b, so the ratio is 1 — and so is a caller whose +// base is its own pixel (bx = 0, the mask, the nine identical taps). +for (const b of [0.01, 0.1, 0.35, 0.7, 0.99]) + for (const rgb of [...colourCases, ...greyCases]) { + const g = rebuild(rgb, {}, b); + for (let i = 0; i < 3; i++) close(g.clamped[i], rgb[i], `the base path is not the identity at zero, base ${b}`); + } + // THE EXPOSURE KNOB, the same move on a different input. Behind it: -2..+2 EV in // half stops, on the frame and inside a gradient mask. // A grey is the knob it always was — a stop on a neutral is a stop on its light, diff --git a/docker/frontend/shared/utils/gradientMask.ts b/docker/frontend/shared/utils/gradientMask.ts index a9eed5d..0ade7ec 100644 --- a/docker/frontend/shared/utils/gradientMask.ts +++ b/docker/frontend/shared/utils/gradientMask.ts @@ -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: diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 2f95bed..cd38156 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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>> = { '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], ]; } diff --git a/docker/frontend/src/engine/exportEngine.ts b/docker/frontend/src/engine/exportEngine.ts index 21a8d7c..b9eac28 100644 --- a/docker/frontend/src/engine/exportEngine.ts +++ b/docker/frontend/src/engine/exportEngine.ts @@ -34,6 +34,7 @@ import { DEHAZE_SKSL, dehazeUniformArray, getToneUniforms, + TONE_BASE_RADIUS, toneIsActive, toneUniformArray, glowUniformArray, @@ -796,7 +797,13 @@ export async function renderPhoto(input: RenderInput): Promise v === (i % 6 === 0 ? 1 : 0)); // 3b. Tone shader. - const tone = getToneUniforms(adjustments, recipe.baseFilter); + // 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, + ]); let toneShader: any = null; // 3c. Cinema seasonal grade (cinema → tone → image). const cinema = getCinemaUniforms(recipe.cinema);