From a56581c75727ece469350bf00efd1ffb28013eae Mon Sep 17 00:00:00 2001 From: 3dtours Date: Wed, 30 Sep 2026 17:59:57 +0700 Subject: [PATCH] =?UTF-8?q?light:=20the=20HIGHLIGHT=20knob=20rides=20half?= =?UTF-8?q?=20its=20anchor=20too,=20so=20a=20lift=20stops=20drawing=20a=20?= =?UTF-8?q?cloud=20to=20paper=20and=20a=20pull=20stops=20flattening=20the?= =?UTF-8?q?=20quarter=20under=20it=20=E2=80=94=20the=20top=20quarter=20cam?= =?UTF-8?q?e=20back=20at=200.26x=20of=20its=20own=20contrast,=200.62x=20no?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../frontend/scripts/highlight-knee-check.mjs | 62 +++++++++++++------ docker/frontend/shared/utils/toneShader.ts | 43 ++++++++----- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/docker/frontend/scripts/highlight-knee-check.mjs b/docker/frontend/scripts/highlight-knee-check.mjs index ff51860..257e557 100644 --- a/docker/frontend/scripts/highlight-knee-check.mjs +++ b/docker/frontend/scripts/highlight-knee-check.mjs @@ -16,15 +16,16 @@ // THE TONE PASS builds the luma a new ramp instead. The four knobs // (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc — // one tent each, one per quarter of the ramp — and each knob moves the knot it -// owns by TONE_ANCHOR of the ramp, held inside the knot before it; SHADOW, whose -// knot is the head of the band above it rather than an end of the ramp, moves -// half that. The 0.50 midpoint is the one value all four leave where it was. +// owns by TONE_ANCHOR of the ramp, held inside the knot before it; SHADOW and +// HIGHLIGHT, whose knots are the HEAD of a quarter rather than an end of the +// ramp, move half that. The 0.50 midpoint is the one value all four leave where +// it was. // // Both are SkSL, so the SHAPE is pinned on the source; the arithmetic is then // checked against the source's own constants, and the ramp re-run here as a twin // so monotonicity, the neutral identity, the partition of the four masks and the -// slope SHADOW leaves the band above it are checked rather than asserted in a -// comment. +// slopes SHADOW and HIGHLIGHT leave the quarters around them are checked rather +// than asserted in a comment. // // node scripts/highlight-knee-check.mjs import assert from 'node:assert/strict'; @@ -84,7 +85,7 @@ assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask // pair of masked terms (0.12 at t = 0, 0.18 at t = 1) and half of each at the // knots next to them. assert.match(sksl, /float a4 = 1\.0 \+ 0\.25 \* wh - dr \* 0\.18;/); -assert.match(sksl, /float a3 = clamp\(0\.75 \+ 0\.25 \* hl - dr \* 0\.09, 0\.5, a4\);/); +assert.match(sksl, /float a3 = clamp\(0\.75 \+ 0\.25 \* 0\.5 \* hl - dr \* 0\.09, 0\.5, a4\);/); assert.match(sksl, /float a1 = clamp\(0\.25 \+ 0\.25 \* 0\.5 \* sh \+ dr \* 0\.06, 0\.0, 0\.5\);/); assert.match(sksl, /float a0 = clamp\(0\.25 \* bl \+ dr \* 0\.12, 0\.0, a1\);/); assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238'); @@ -174,7 +175,7 @@ function ramp(t, k) { const whMask = smoothstep(0.75, 1, t); const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask); const a4 = 1 + A * wh - dr * 0.18; - const a3 = Math.min(a4, Math.max(0.5, 0.75 + A * hl - dr * 0.09)); + const a3 = Math.min(a4, Math.max(0.5, 0.75 + A * 0.5 * hl - dr * 0.09)); const a1 = Math.min(0.5, Math.max(0, 0.25 + A * 0.5 * sh + dr * 0.06)); const a0 = Math.min(a1, Math.max(0, A * bl + dr * 0.12)); let o = mix(a0, a1, lin(0, 0.25, t)); @@ -237,16 +238,18 @@ for (const k of combos) { } assert.ok(worst > -1e-12, `worst step ${worst} — the ramp is folded`); // A knob moves its own quarter, and only its own: +BLACK takes the toe off the -// floor, -HIGHLIGHT pulls the 0.75 knot onto the midpoint, and WHITE - rolls the -// head under 1.0. That is the reach a tonal-range slider has — a quarter of the -// ramp, so the middle stays a middle. SHADOW is the exception and moves HALF of -// it: its knot is the HEAD of the 0.25..0.50 band, so a whole quarter would draw -// that band flat, which is the wash-out the knob was reported for. +// floor, -HIGHLIGHT rolls the 0.75 knot down a quarter of the way to the +// midpoint, and WHITE - rolls the head under 1.0. That is the reach a +// tonal-range slider has — a quarter of the ramp, so the middle stays a middle. +// SHADOW and HIGHLIGHT are the exceptions and move HALF of it: their knots (a1, +// a3) are the HEAD of the quarter in front of them, so a whole quarter would draw +// that quarter flat, which is the wash-out both knobs were reported for. close(ramp(0, {}).o, 0, 'a neutral toe moved'); close(ramp(0, { bl: 1 }).o, A, 'BLACK no longer reaches a quarter of the ramp'); close(ramp(0.25, { sh: 1 }).o, 0.375, 'SHADOW no longer stops halfway to the midpoint'); close(ramp(0.25, { sh: -1 }).o, 0.125, 'SHADOW no longer stops halfway to the floor'); -close(ramp(0.75, { hl: -1 }).o, 0.5, 'HIGHLIGHT no longer reaches the midpoint'); +close(ramp(0.75, { hl: -1 }).o, 0.625, 'HIGHLIGHT no longer stops halfway to the midpoint'); +close(ramp(0.75, { hl: 1 }).o, 0.875, 'HIGHLIGHT no longer stops halfway to the white knot'); close(ramp(1, { wh: -1 }).o, 0.75, 'WHITE no longer rolls the head under 1.0'); close(ramp(0.25, {}).o, 0.25, 'a neutral knot moved'); close(ramp(0.75, {}).o, 0.75, 'a neutral knot moved'); @@ -285,26 +288,45 @@ for (let sh = -1; sh <= 1.0001; sh += 1 / 64) { assert.ok(below >= 0.5 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`); assert.ok(band <= 1.5 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`); } -// The film stocks ride the same knot, so a halved SHADOW would have halved their -// crush with it. They are written at DOUBLE for that reason, and the look they -// were tuned to is the knot, not the unit: these three land where they always -// did (0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros HC). +// HIGHLIGHT pays the same way and at BOTH ends, which is why its knot is the +// worse of the two: a3 is the head of the top quarter, so lifting it draws +// 0.75..1.00 flat (a cloud to paper) and pulling it draws 0.50..0.75 flat. At the +// whole anchor the sweep in scratchpad/knob-sweep.mjs read the quarter slopes as +// 1.00 1.00 2.00 0.00 at +100 and 1.00 1.00 0.00 2.00 at -100 — a quarter flat +// either way. Half draws neither, over the whole travel. +for (let hl = -1; hl <= 1.0001; hl += 1 / 64) { + const mid = (ramp(0.75, { hl }).o - ramp(0.5, { hl }).o) / 0.25; + const top = (ramp(1, { hl }).o - ramp(0.75, { hl }).o) / 0.25; + assert.ok(mid >= 0.5 - 1e-12, `HIGHLIGHT drew the quarter below it flat at ${hl}: slope ${mid}`); + assert.ok(top >= 0.5 - 1e-12, `HIGHLIGHT drew the top quarter flat at ${hl}: slope ${top}`); + assert.ok(mid <= 1.5 + 1e-12, `HIGHLIGHT stretched the quarter below it at ${hl}: slope ${mid}`); + assert.ok(top <= 1.5 + 1e-12, `HIGHLIGHT stretched the top quarter at ${hl}: slope ${top}`); +} +// The film stocks ride the same knots, so a halved SHADOW or HIGHLIGHT would have +// halved their crush and their shoulder with it. They are written at DOUBLE for +// that reason, and the look they were tuned to is the knot, not the unit: these +// land where they always did (0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros +// HC; the head 0.7375 on Acros and 0.815 on Acros HC). const filmTone = tone.match(/const FILM_TONE[\s\S]*?\n};/)?.[0]; assert.ok(filmTone, 'FILM_TONE is gone — the stocks no longer shape the ramp at all'); // The keys are quoted or not depending on whether they are identifiers, so the // quotes come off before the lookup. const filmFlat = filmTone.replace(/['"]/g, ''); -for (const [name, sh, knot] of [ +for (const [name, sh, knot, hlSrc, hl, head] of [ ['classic-chrome', -0.56, 0.18], ['classic-vivid', -0.56, 0.18], - ['monochrome', -0.24, 0.22], - ['mono-high-contrast', -0.64, 0.17], + ['monochrome', -0.24, 0.22, '-0.10', -0.1, 0.7375], + ['mono-high-contrast', -0.64, 0.17, '0.52', 0.52, 0.815], ]) { assert.ok( filmFlat.includes(`${name}: { sh: ${sh}`), `${name} is not on the doubled ${sh} — the stock's crush moved with the knob's reach` ); close(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`); + if (hlSrc) { + assert.ok(filmFlat.includes(`hl: ${hlSrc}`), `${name} is not on the doubled ${hlSrc} — its shoulder moved with the knob's reach`); + close(ramp(0.75, { hl }).o, head, `${name}'s highlight knot moved`); + } } // The two ends stay ordered even at full deflection against each other: the toe // can never climb past the head. diff --git a/docker/frontend/shared/utils/toneShader.ts b/docker/frontend/shared/utils/toneShader.ts index 4302578..2f95bed 100644 --- a/docker/frontend/shared/utils/toneShader.ts +++ b/docker/frontend/shared/utils/toneShader.ts @@ -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>> = { '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>> = { // 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 {