light: the HIGHLIGHT knob rides half its anchor too, so a lift stops drawing a cloud to paper and a pull stops flattening the quarter under it — the top quarter came back at 0.26x of its own contrast, 0.62x now

This commit is contained in:
2026-09-30 17:59:57 +07:00
parent 36fb93658b
commit a56581c757
2 changed files with 68 additions and 37 deletions
+26 -17
View File
@@ -110,9 +110,9 @@ const BAND_BLOCK = hslBandGaps()
// the toe can reach the quarter above it and the head can be rolled onto it —
// and never past, because each knot is clamped inside the one after it.
//
// SHADOW is the one knob that moves HALF of it. The knot it owns is the HEAD of
// the 0.25..0.50 quarter, so a unit of lift is a unit of slope that quarter
// loses, and at a whole quarter the band is drawn flat — see the note on a1.
// SHADOW and HIGHLIGHT move HALF of it. The knots they own (a1, a3) are the HEAD
// of a quarter, so a unit of lift is a unit of slope that quarter loses, and at
// a whole quarter the band is drawn flat — see the notes on a1 and a3.
export const TONE_ANCHOR = 0.25;
// The tone and exposure maths, in ONE copy, because two passes ask it: the
@@ -132,9 +132,9 @@ float lin(float e0, float e1, float x) {
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
}
// The ramp the pixel is rebuilt through. Knots on 0.00, 0.25, 0.50, 0.75 and
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp — SHADOW by
// half of that, its knot being the head of a band — and each knot is held inside
// the one after it so the five can never cross. 0.50 is fixed:
// 1.00; a knob moves the knot it owns by TONE_ANCHOR of the ramp — SHADOW and
// HIGHLIGHT by half of that, their knots being the heads of bands — and each knot
// is held inside the one after it so the five can never cross. 0.50 is fixed:
// it is the one point all four sliders leave alone, which is what keeps a
// mid-grey a mid-grey while the ends move around it. Straight between the knots,
// so every knob on zero is exactly the identity (see the note at the head of
@@ -193,7 +193,15 @@ vec3 lightMove(vec3 c, float t, float o) {
}
vec3 toneRamp(vec3 c, float t, float bl, float sh, float hl, float wh, float dr) {
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
float a3 = clamp(0.75 + ${TONE_ANCHOR} * hl - dr * 0.09, 0.5, a4);
// 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
// of lift is a unit of slope the top quarter loses — at the whole anchor +100
// lands the knot ON a4 and the quarter is drawn flat (a cloud to paper, the
// quarter slopes 1.00 1.00 2.00 0.00), and the same knob the other way pins it
// on 0.50 and flattens the quarter BELOW it instead (1.00 1.00 0.00 2.00).
// Half draws neither: every quarter keeps between 0.5 and 1.5 of its own slope
// over the whole travel, and the head still rolls from 0.625 to 0.875.
float a3 = clamp(0.75 + ${TONE_ANCHOR} * 0.5 * hl - dr * 0.09, 0.5, a4);
// SHADOW rides HALF the anchor, and that is a measured ceiling rather than a
// taste. a1 is the HEAD of the quarter above it, so the band 0.25..0.50 pays
// in slope for every unit this knot rises — the slope is (0.5 - a1) / 0.25 —
@@ -659,11 +667,12 @@ export interface ToneUniforms {
// here instead of in the 4x5 matrix, which cannot move one end of the curve
// without also moving the other.
//
// The `sh` here is written at DOUBLE what the knob's own arithmetic reads, which
// is not a taste either: SHADOW moves its knot half an anchor per unit (see a1
// in TONE_MATH_SKSL), so a stock that wants the toe on 0.18 has to ask for -0.56
// to land it there. The stock's crush is the knot it puts the ramp on; the unit
// it is written in belongs to the knob, not to the look.
// The `sh` and `hl` here are written at DOUBLE what the knob's own arithmetic
// reads, which is not a taste either: SHADOW and HIGHLIGHT each move their knot
// half an anchor per unit (see a1 and a3 in TONE_MATH_SKSL), so a stock that
// wants the toe on 0.18 has to ask for -0.56 to land it there. The stock's crush
// is the knot it puts the ramp on; the unit it is written in belongs to the
// knob, not to the look.
const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
'classic-chrome': { sh: -0.56 },
// Classic Vivid is Classic Chrome's sibling — the shadow crush belongs to the
@@ -678,18 +687,18 @@ const FILM_TONE: Partial<Record<BaseFilter, Partial<ToneUniforms>>> = {
// every step the matrix handed over — which is what 'deep black' costs in a
// colour stock and does not have to cost here.
// The values move the two end knots of the ramp: -0.24 puts the toe on 0.22
// and -0.05 rolls the head to 0.7375 (HIGHLIGHT is TONE_ANCHOR per unit, the
// doubled SHADOW half of it).
monochrome: { sh: -0.24, hl: -0.05 },
// and -0.10 rolls the head to 0.7375 (both SHADOW and HIGHLIGHT ride HALF an
// anchor per unit, so a stock written against the knot asks for double).
monochrome: { sh: -0.24, hl: -0.10 },
// B&W HIGH CONTRAST. Acros' ramp with both ends pushed hard: a deeper toe
// (-0.64 against Acros' -0.24, so 0.17 against 0.22) so the darks reach true
// black, and a shoulder that LIFTS instead of rolling (-0.05 → +0.26, the
// black, and a shoulder that LIFTS instead of rolling (-0.10 → +0.52, the
// head going to 0.815), which is the whites step of the brief. The stretch
// between the two inner knots (0.25 and 0.75) is still the identity, so the
// long smooth stretch of the greys survives — that is what keeps a hard push
// off the posterised look, and the strength the stock needs on the greys is
// its matrix slope (SIM_CONTRAST_BIAS in colorUtils), not another move here.
'mono-high-contrast': { sh: -0.64, hl: 0.26 },
'mono-high-contrast': { sh: -0.64, hl: 0.52 },
};
export function getToneUniforms(adj: ColorAdjustments, baseFilter?: BaseFilter): ToneUniforms {