From 851563ed73aa0e2adb1aab9798c6482d003c6516 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 30 Sep 2026 16:26:42 +0700 Subject: [PATCH] =?UTF-8?q?light:=20the=20pixel=20rides=20o/t,=20not=20a?= =?UTF-8?q?=20held=20chroma=20=E2=80=94=20SHADOW=20and=20BLACK=20drained?= =?UTF-8?q?=20a=20dark=20red=20to=200.505=20of=20its=20saturation,=20it=20?= =?UTF-8?q?is=200.742=20now?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/scripts/highlight-knee-check.mjs | 45 ++++++++++++------- docker/frontend/shared/utils/toneShader.ts | 44 +++++++++++------- 2 files changed, 55 insertions(+), 34 deletions(-) diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index c8db976..2ce85ff 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -101,19 +101,21 @@ assert.doesNotMatch(sksl, /mix\(a0, a1, smoothstep/, 'the ramp is smoothstepped assert.doesNotMatch(tone, /if \(hl < 0\.0\) \{/, 'the linear-light recovery came back'); assert.doesNotMatch(sksl, /max\(hl, 0\.0\)/, 'the additive lift came back'); assert.doesNotMatch(sksl, /bl \* 0\.18 \* dk|wh \* 0\.18 \* rgb/, 'WHITE/BLACK are per-channel again'); -// The rebuild after the ramp: the chroma difference rides the new luma, scaled -// by one number that only the cube can pull back. The doc's ratio is NOT it any -// more — scaling all three channels by Luma_new / Luma_old is exact until a -// channel clips, and a clipped channel is a moved hue (a skin tone at 24.0° came -// back at 48.0° at HIGHLIGHT +100, scratchpad hl-variants.mjs), and under L=0.5 -// it multiplies a near-black pixel's cast by up to x30. -assert.match(maths, /float k = 1\.0;/); +// The rebuild after the ramp: the doc's ratio (R_new = R_old * Luma_new / +// Luma_old), as ONE shared scale o / t, so the differences move with the light +// and neither the hue nor the saturation goes with them. The caps are what make +// it fit — applying the ratio past the ceiling clips a channel outright and the +// hue goes with it (a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, +// scratchpad hl-variants.mjs) — and the scale is held at 1.0 only below +// t = 0.0004, where the ratio would multiply a near-black pixel's cast by +// whatever pedestal BLACK has just lifted. +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.doesNotMatch(tone, /0\.55, 1\.35/, 'the arbitrary saturation clamp came back'); -assert.doesNotMatch(sksl, /max\(t, 0\.0004\)/, 'the luma ratio 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 // exposure pass), or that pass is drawn in a space that is not linear at all. assert.match(tone, /return mix\(c \/ 12\.92, pow\(\(c \+ 0\.055\) \/ 1\.055, vec3\(2\.4\)\), step\(vec3\(0\.04045\), c\)\);/); @@ -272,7 +274,7 @@ for (const bl of [-1, 1]) } // The colour rebuild, as the shader emits it: the ramp's luma, the pixel's own -// chroma difference, and the one scale the cube allows. +// chroma difference, and the one scale o / t the cube then gets to pull back. const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]); // lightMove, as TONE_MATH_SKSL emits it — the one move every brightness change in // the pass goes through (a tone knob, a mask's tone knob, the exposure knob). @@ -280,7 +282,7 @@ const lumaOf = (c) => clamp01(0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]); // alone already landed the pixel inside the cube, and a silent clamp would hide // the case where it did not. function lightMove(rgb, t, o) { - let k = 1; + let k = t > 0.0004 ? o / t : 1; const hiC = Math.max(...rgb); const loC = Math.min(...rgb); if (hiC > t) k = Math.min(k, (1 - o) / (hiC - t)); @@ -362,13 +364,22 @@ for (const rgb of [...colourCases, ...greyCases]) { const { clamped } = rebuild(rgb, {}); for (let i = 0; i < 3; i++) close(clamped[i], rgb[i], 'the colour rebuild is not the identity at zero'); } -// The chroma is CARRIED, not re-scaled: where the cube has room, the channel -// differences come out as they went in (this is where the doc's ratio diverged -// — it multiplied them by Luma_new / Luma_old, which over-saturates a lift and -// drains a pull in the half of the ramp above L = 0.5, the highlight zone). -const carried = rebuild([0.7, 0.55, 0.45], { hl: 0.5 }).clamped; -const grew = (carried[0] - carried[1]) / (0.7 - 0.55); -close(grew, 1, 'the chroma was re-scaled on a highlight lift'); +// The chroma RIDES THE RATIO: where the cube has room the channel differences +// come out multiplied by the one scale o / t. That is what keeps the saturation — +// an HSL saturation is a ratio of differences and a common scale never touches it +// — and the hue along with it, which is the report behind this move: held at +// k = 1.0 (the chroma carried unchanged) a dark red came back at 0.505 of +// saturation from 0.746 with SHADOW at +100, and at 0.370 with SHADOW and BLACK +// both, which is a colour going grey under a lift. +for (const [rgb, knobs] of [ + [[0.7, 0.55, 0.45], { hl: 0.5 }], + [[0.35, 0.12, 0.08], { sh: 1, bl: 1 }], +]) { + 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)}`); +} // 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. diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 4ea26ec..0bc334e 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -139,24 +139,34 @@ float lin(float e0, float e1, float x) { // between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through that // stretch, which is a fold at 0.238. // -// Lightness takes the curve; the colour rides the difference. The pixel moves to -// its new luma and carries its own chroma with it — the three channel -// differences are scaled by ONE number, so the hue cannot move and a grey cannot -// pick up a cast (a neutral has no difference to carry, and lands on o exactly). +// Lightness takes the curve; the colour rides the ratio. The pixel moves to its +// new luma, and it gets there by scaling its three channels by ONE number, +// k = o / t: the doc's R_new = R_old * Luma_new / Luma_old. The differences move +// with the light, so the hue cannot turn, the saturation cannot be drained by a +// lift — an HSL saturation is a ratio of differences and never sees a common +// scale — and a neutral, with no difference to carry, lands on o exactly. // -// The doc's ratio (R_new = R_old * Luma_new / Luma_old) is the other reading of -// the same sentence, and it is what this pass used to do. It is exact — until -// the result stops fitting. Past 1.0 a channel clips, the differences stop being -// scaled together, and the hue goes with them: measured on the scratchpad probe -// (hl-variants.mjs), a skin tone at 24.0° came back at 48.0° at HIGHLIGHT +100, -// and a warm white at 37° at 57.4°. Under L = 0.5 the same ratio also multiplies -// whatever cast a near-black pixel had — x30 on a shadow with a hair of warmth, -// which is colour noise amplified, the reason the old arbitrary 0.55..1.35 clamp -// was there. +// The scale used to be 1.0 instead: keep the chroma, move the luma. That holds +// the hue and loses the colour, which is the complaint the ratio answers — the +// scratchpad probe (sh-bl-hue.mjs) measured a dark red at 0.746 of saturation +// coming back at 0.505 with SHADOW at +100, and at 0.370 with SHADOW and BLACK +// both at +100. A chroma held under a rising luma IS a colour going grey, and it +// is the two sliders that lift a shadow (SHADOW, BLACK) that raise the luma of a +// dark pixel the furthest. // -// So the scale is the chroma's own (1.0) and the only thing that pulls it back -// is the cube: a pixel with no room left gives up saturation instead of hue, and -// one that the curve has actually driven to 1.0 arrives at white. +// What the ratio cannot do on its own is fit, and that is what the caps below are +// for: past the ceiling a channel clips outright, the three stop being scaled +// together and the hue goes with them (measured on the other probe, a skin tone +// at 24.0° came back at 48.0° at HIGHLIGHT +100, a warm white at 37° at 57.4°). +// Pulling the ONE scale back while the cube has room for no more costs saturation +// instead: a pixel the cube is against arrives neutral before it arrives wrong, +// and one the curve has driven all the way to 1.0 arrives at white. +// +// Below t = 0.0004 there is no ratio worth the name: dividing by what is left of +// a pixel that has almost no light on it takes whatever cast the last code value +// of 8-bit noise left there and multiplies it by the pedestal the BLACK knob just +// lifted — colour noise, amplified to the size of the lift. The scale stays 1.0 +// down there and the pixel takes the pedestal as the flat grey it is. // // ONE move of the light, and everything in this file that changes how bright a // pixel is goes through it: a tone knob, a mask's tone knob, and the exposure @@ -166,7 +176,7 @@ float lin(float e0, float e1, float x) { // a channel reaches the ceiling, where the three clip by different amounts and // the hue goes with them. vec3 lightMove(vec3 c, float t, float o) { - float k = 1.0; + float k = t > 0.0004 ? o / t : 1.0; float hiC = max(max(c.r, c.g), c.b); float loC = min(min(c.r, c.g), c.b); if (hiC > t) k = min(k, (1.0 - o) / (hiC - t));