light: the pixel rides o/t, not a held chroma — SHADOW and BLACK drained a dark red to 0.505 of its saturation, it is 0.742 now
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user