web: each of the four tonal knobs moves its own band of the ramp and not the guard the four used to share
BLACK, SHADOW, HIGHLIGHT and WHITE were four bumps summed into the identity, and the sum carried a guard: the two bumps of a half shared a slope, so past a total of 1 the curve folded backwards, and the ceiling that stopped it was shared by the amplitudes of a half. A stock already sitting on SHADOW therefore took BLACK's lift down with it — on the monochrome stock (sh = -0.24) BLACK at -100 came back with 0.663 of the travel the knob has on its own, which is the "kéo theo sự thay đổi của thông số khác" report exactly. thay_doi_thong_so_giong_lightroom.md section 2 asks for four WINDOWS instead: each knob owns a compact band of the ramp and is exactly zero outside it, and the four moves are applied ONE AFTER ANOTHER rather than summed. A composition of monotone maps is monotone by construction, so it needs no guard, and each knob then measures 1.00 of its travel on every stock. BLACKS is the doc's toe — u = clamp(1 - L/0.18, 0, 1) cubed, opened by sqrt(L) - L at 0.7 and deepened by 0.85, both of which are exactly zero at L = 0, so (0,0,0) stays (0,0,0): the grey pedestal that BLACK +100 left on a black was the sum adding its bump's height at the black point, which is the doc's own "Milky / Foggy". SHADOWS is the doc's bell over the deep tones, HIGHLIGHTS the bell over the bright ones, WHITES the doc's Hermite on the shoulder from 0.80. The ramp keeps its two anchors — 0.00 and 1.00 — at every setting of the four knobs. One deliberate departure from the doc: HIGHLIGHT carries a (1 - L) the doc's raw knee does not, because pow(L - 0.5, 1.5) added to L overshoots the cube above 0.94 — 17% of the ramp driven to flat white at +100 before the clamp. Read against the headroom that is left, the move is zero at L = 1 by construction and the head rolls instead of clipping. The windows are read in the sRGB-encoded luma this file already works in, not in linear light as the doc's section 1 sets out: the doc's own boundaries (0.18, 0.05..0.45, 0.55..0.95, 0.80) land as perceptual positions there, and moving the whole renderer to the linear domain is a bigger change than this pass. The divergence is the one the scratchpad compat doc already warns the Android port about, and it is noted at the windows themselves. Checked: `tsc --noEmit` clean; `highlight-knee-check.mjs`, `tone-base-check.mjs` and `mask-wb-check.mjs` updated to the four windows and passing; the twin ramp over a 1/512 grid is monotone to -0.00119 (0.30 code values, at t = 0.098 with every knob at full negative), both anchors hold for every combination, and a knob outside its band is the exact identity.
This commit is contained in:
@@ -34,8 +34,8 @@ writeFileSync(
|
||||
),
|
||||
);
|
||||
const {
|
||||
TONE_SKSL, TONE_ANCHOR, TONE_BUMP_SLOPE_HALF, TONE_BUMP_SLOPE_QUARTER,
|
||||
TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
|
||||
TONE_SKSL, TONE_BASE_RADIUS, TONE_BASE_SIGMA, getToneUniforms, toneUniformArray, toneIsActive,
|
||||
TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN,
|
||||
} = await import(pathToFileURL(join(dir, 'toneShader.mjs')).href);
|
||||
|
||||
// The shader has to NAME a base child — a ring of taps would not need one.
|
||||
@@ -85,18 +85,48 @@ function render(srcValue, baseValue) {
|
||||
return pixels[0];
|
||||
}
|
||||
|
||||
// The ramp, in the same arithmetic the shader runs: the identity plus one bump
|
||||
// per knob, (1-u^2)^2 with u the distance to the knob's knot in half-widths.
|
||||
// SHADOW carries half the anchor on 0.25 and its bump is the quarter-width one,
|
||||
// so SHADOW +100 reads b + a1 * (1-u^2)^2 with a1 = 0.125 and u = (b-0.25)/0.25.
|
||||
// (The ramp used to be straight between knots; see the note at the head of
|
||||
// toneRamp for the Mach bands that retired it.)
|
||||
const bump = (b, knot, halfWidth) => {
|
||||
const u = (b - knot) / halfWidth;
|
||||
const v = Math.max(0, 1 - u * u);
|
||||
return v * v;
|
||||
// The ramp, in the same arithmetic the shader runs: four COMPACT windows of the
|
||||
// encoded luma — BLACK's toe dying on 0.18, SHADOW's bell over the deep tones,
|
||||
// HIGHLIGHT against the headroom that is left, WHITE's Hermite on the shoulder —
|
||||
// applied ONE AFTER THE OTHER and clamped on the way to the next, so the four
|
||||
// moves compose instead of summing and the ramp needs no guard. SHADOW carries
|
||||
// the whole knob at its own window, so SHADOW +100 reads toneCurve(b, 0, 1, 0, 0)
|
||||
// (b and a1 are gone with the bumps; see the note at the head of toneRamp).
|
||||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||||
const smoothstep = (e0, e1, x) => {
|
||||
const u = clamp01((x - e0) / (e1 - e0));
|
||||
return u * u * (3 - 2 * u);
|
||||
};
|
||||
const ramp = (b) => b + TONE_ANCHOR * 0.5 * bump(b, 0.25, 0.25);
|
||||
const toneBlackW = (L) => {
|
||||
const u = clamp01(1 - L / TONE_BLACK_EDGE);
|
||||
return u * u * u;
|
||||
};
|
||||
const toneShadowW = (L) => smoothstep(0.02, 0.12, L) * (1 - smoothstep(0.25, 0.55, L));
|
||||
const toneHighW = (L) => smoothstep(0.45, 0.65, L) * (1 - smoothstep(0.92, 1.0, L));
|
||||
const toneWhiteW = (L) => {
|
||||
const u = clamp01((L - TONE_WHITE_EDGE) / (1 - TONE_WHITE_EDGE));
|
||||
return u * u;
|
||||
};
|
||||
// toneCurve, move for move, with the clamp the shader puts after each one.
|
||||
// `afterBlack` is the L the SHADOW move reads.
|
||||
function toneCurve(L, bl, sh, hl, wh) {
|
||||
let q = toneBlackW(L);
|
||||
L = bl > 0
|
||||
? L + TONE_BLACK_LIFT * bl * q * (Math.sqrt(L) - L)
|
||||
: L * Math.max(1 + TONE_BLACK_CRUSH * bl * q, 0);
|
||||
L = clamp01(L);
|
||||
const afterBlack = L;
|
||||
q = toneShadowW(L);
|
||||
L *= 1 + sh * q * (1 - L) ** 1.8;
|
||||
L = clamp01(L);
|
||||
q = toneHighW(L);
|
||||
L += TONE_HIGH_GAIN * hl * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L);
|
||||
L = clamp01(L);
|
||||
q = toneWhiteW(L);
|
||||
L += TONE_WHITE_GAIN * wh * q * (1 - L) * L;
|
||||
return { o: clamp01(L), afterBlack };
|
||||
}
|
||||
const ramp = (b, k = {}) => toneCurve(b, k.bl ?? 0, k.sh ?? 0, k.hl ?? 0, k.wh ?? 0);
|
||||
const srcValue = 128; // the pixel: 0.501961 encoded
|
||||
const baseValue = 76; // its neighbourhood, darker: 0.298039
|
||||
const t = srcValue / 255;
|
||||
@@ -105,7 +135,7 @@ const b = baseValue / 255;
|
||||
// Base' + Detail, the reconstruction the shader emits (see the note in
|
||||
// toneRamp). The ratio Base' * (Input / Base) is what this used to predict, and
|
||||
// it is what took the texture out of the frame at BLACK -100.
|
||||
const expected = Math.round(255 * (Math.min(1, Math.max(0, ramp(b))) + (t - b)));
|
||||
const expected = Math.round(255 * clamp01(ramp(b, { sh: 1 }).o + (t - b)));
|
||||
|
||||
const got = render(srcValue, baseValue);
|
||||
assert.ok(
|
||||
@@ -118,32 +148,38 @@ assert.ok(
|
||||
Math.abs(got - srcValue) > 8,
|
||||
`the pass returned the pixel (${got}) — it is reading its own sharp image as the base again`,
|
||||
);
|
||||
// With the base handed in as the sharp image the pass is the global move, which
|
||||
// is what a mask (no neighbourhood of its own) needs it to be.
|
||||
// With the base handed in as the sharp image the difference is exactly zero and
|
||||
// the pass is the GLOBAL move — the ramp at the pixel — which is what a mask (no
|
||||
// neighbourhood of its own) needs it to be. It is NOT the identity any more:
|
||||
// the shadow window spans the middle, so the same knobs move a mid-grey.
|
||||
const globalMove = Math.round(255 * clamp01(ramp(t, { sh: 1 }).o));
|
||||
const selfBase = render(srcValue, srcValue);
|
||||
assert.ok(
|
||||
Math.abs(selfBase - srcValue) <= 1,
|
||||
`a base equal to the pixel must be the identity, got ${selfBase} for ${srcValue}`,
|
||||
Math.abs(selfBase - globalMove) <= 2,
|
||||
`a base equal to the pixel must be the ramp at the pixel (${globalMove}), got ${selfBase} for ${srcValue}`,
|
||||
);
|
||||
assert.ok(
|
||||
Math.abs(selfBase - srcValue) > 2,
|
||||
`the pass returned the pixel (${selfBase}) — the base is the sharp image again`,
|
||||
);
|
||||
|
||||
// The guard, in the same arithmetic. BLACK and SHADOW at +100 share the lower
|
||||
// half of the ramp and their bumps are read at their own steepest points, so the
|
||||
// pair is the loosest two moves of a half can be: past a total slope of 1 the sum
|
||||
// carries the curve BACKWARDS, which is a worse band than the seams it replaced.
|
||||
// The guard holds the two under one and gives them up together past it.
|
||||
const blackA = TONE_ANCHOR; // BLACK +100
|
||||
const shadowA = TONE_ANCHOR * 0.5; // SHADOW +100
|
||||
const lift = (b, k) => b + k * (blackA * bump(b, 0, 0.5) + shadowA * bump(b, 0.25, 0.25));
|
||||
const hold = TONE_BUMP_SLOPE_HALF * blackA + TONE_BUMP_SLOPE_QUARTER * shadowA;
|
||||
assert.ok(hold > 1, `BLACK and SHADOW +100 no longer reach the guard (hold ${hold})`);
|
||||
// There is no guard any more, and nothing to hold under one: the four moves are
|
||||
// each monotone for any amount in [-1, 1] and clamped on the way to the next, so
|
||||
// BLACK and SHADOW both at +100 compose into a ramp that still rises — measured
|
||||
// on a 1/400 grid, where the two used to sum past a slope of 1 and carry the
|
||||
// curve backwards — and BLACK's own travel no longer depends on the stock's
|
||||
// SHADOW, because BLACK runs FIRST: the value SHADOW reads is the same whatever
|
||||
// SHADOW says, which is the "kéo theo sự thay đổi của thông số khác" report.
|
||||
const rises = (f) => Array.from({ length: 400 }, (_, i) => f((i + 1) / 400) > f(i / 400)).every(Boolean);
|
||||
// Both controls: the pair really does fold without the guard, and the guard is
|
||||
// what stops it — a guard on a sum that never folded would be dead weight.
|
||||
assert.ok(!rises((b) => lift(b, 1)), `the unguarded pair no longer folds — the guard holds nothing`);
|
||||
assert.ok(rises((b) => lift(b, 1 / hold)), 'BLACK and SHADOW both at +100 fold the ramp back on itself');
|
||||
assert.ok(rises((b) => ramp(b, { bl: 1, sh: 1 }).o), 'BLACK and SHADOW both at +100 fold the ramp back on itself');
|
||||
assert.equal(
|
||||
ramp(0.05, { bl: -1 }).afterBlack,
|
||||
ramp(0.05, { bl: -1, sh: -0.2 }).afterBlack,
|
||||
'SHADOW drags BLACK’s own travel with it again',
|
||||
);
|
||||
|
||||
console.log(
|
||||
`tone base ok: pixel ${srcValue} over a base of ${baseValue} -> ${got} ` +
|
||||
`(ramp predicts ${expected}, sharp-base identity ${selfBase}); ` +
|
||||
`(ramp predicts ${expected}, global move ${globalMove}, sharp-base ${selfBase}); ` +
|
||||
`radius ${TONE_BASE_RADIUS} of the frame, sigma ${TONE_BASE_SIGMA} of it`,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user