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:
@@ -14,14 +14,18 @@
|
||||
// 251.2 / 10.0 through the knee.
|
||||
//
|
||||
// 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 bump each, one per quarter of the ramp — and each knob moves the knot it
|
||||
// owns by TONE_ANCHOR of the ramp; SHADOW and HIGHLIGHT, whose knots are the HEAD
|
||||
// of a quarter rather than an end of the ramp, move half that. The kernel is
|
||||
// (1-u^2)^2, level at the knot and level a half-width away, so a knob moves its
|
||||
// own quarter and meets the next knob's bump on 0.50 flat; the 0.50 midpoint is
|
||||
// the one value all four leave where it was. The two bumps of a half are held
|
||||
// under a total slope of 1, so the sum can only add to the identity.
|
||||
// (HIGHLIGHT, SHADOW, WHITE, BLACK) are the four zones of the tone-mapping doc,
|
||||
// and each one owns a COMPACT WINDOW of the ramp — a black toe that dies on 0.18,
|
||||
// a shadow bell over the deep tones, a highlight bell over the bright ones, a
|
||||
// white shoulder from 0.80 — so each is exactly the identity outside its own
|
||||
// stretch and the four are disjoint, which is what makes them independent
|
||||
// without a guard. The four moves are applied ONE AFTER THE OTHER
|
||||
// (BLACK -> SHADOW -> HIGHLIGHT -> WHITE), each clamped on the way to the next,
|
||||
// and every one of them lands on 0.0 and 1.0 without moving either: a composition
|
||||
// of monotone maps is monotone by construction, and the two ends of the ramp are
|
||||
// FIXED POINTS of the whole thing whatever the sliders say. There is no fixed
|
||||
// midpoint any more — the shadow window spans the middle, so a mid-grey moves
|
||||
// with SHADOW (0.510636 at +100) where the old sum of bumps left it alone.
|
||||
//
|
||||
// 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
|
||||
@@ -45,12 +49,26 @@ assert.match(dev, /float over = mx - 0\.7;/);
|
||||
assert.match(dev, /rgb \*= \(0\.7 \+ over \/ \(1\.0 \+ over \* 3\.3333\)\) \/ mx;/);
|
||||
assert.doesNotMatch(develop, /mix\(rgb \/ mx, float3\(1\.0\)/, 'the fade-to-white is back');
|
||||
|
||||
// The tone pass, read as the string it actually emits: TONE_ANCHOR is
|
||||
// interpolated, so the template has to be resolved before it can be matched.
|
||||
const anchorSrc = tone.match(/export const TONE_ANCHOR = ([0-9.]+);/)?.[1];
|
||||
assert.ok(anchorSrc, 'TONE_ANCHOR is gone — the four knots no longer share a reach');
|
||||
const A = Number(anchorSrc);
|
||||
assert.equal(A, 0.25, 'a knob no longer moves its knot a quarter of the ramp');
|
||||
// The tone pass, read as the string it actually emits: the six window edges and
|
||||
// rates are interpolated, so the template has to be resolved before it can be
|
||||
// matched — and read off the SOURCE, so the numbers here are the ones the shader
|
||||
// is built with.
|
||||
const num = (re) => {
|
||||
const m = tone.match(re);
|
||||
assert.ok(m, 'missing ' + re);
|
||||
return Number(m[1]);
|
||||
};
|
||||
const {
|
||||
TONE_BLACK_EDGE, TONE_WHITE_EDGE, TONE_BLACK_LIFT, TONE_BLACK_CRUSH, TONE_HIGH_GAIN, TONE_WHITE_GAIN,
|
||||
} = Object.fromEntries(
|
||||
['TONE_BLACK_EDGE', 'TONE_WHITE_EDGE', 'TONE_BLACK_LIFT', 'TONE_BLACK_CRUSH', 'TONE_HIGH_GAIN', 'TONE_WHITE_GAIN']
|
||||
.map((name) => [name, num(new RegExp(`export const ${name} = ([0-9.]+);`))]),
|
||||
);
|
||||
// The two edges are the doc's and the ramp is drawn in quarters of a code value
|
||||
// around them: the toe window must die inside the first quarter of the ramp and
|
||||
// the shoulder must not start below the middle, or a knob reaches past its zone.
|
||||
assert.ok(TONE_BLACK_EDGE <= 0.25, `TONE_BLACK_EDGE ${TONE_BLACK_EDGE} is wider than a quarter of the ramp`);
|
||||
assert.ok(TONE_WHITE_EDGE >= 0.5, `TONE_WHITE_EDGE ${TONE_WHITE_EDGE} starts under the middle of the ramp`);
|
||||
const tmpl = tone.match(/export const TONE_SKSL = `([\s\S]*?)`;/)?.[1];
|
||||
assert.ok(tmpl, 'TONE_SKSL is gone');
|
||||
// The ramp, the hue-preserving rebuild and the exposure move live in
|
||||
@@ -60,7 +78,7 @@ assert.ok(tmpl, 'TONE_SKSL is gone');
|
||||
// compat doc §3.3 warns the Android port about).
|
||||
const mathTmpl = tone.match(/export const TONE_MATH_SKSL = `([\s\S]*?)`;/)?.[1];
|
||||
assert.ok(mathTmpl, 'TONE_MATH_SKSL is gone — the frame and a mask no longer share the maths');
|
||||
assert.equal((mathTmpl.match(/\$\{TONE_ANCHOR\}/g) ?? []).length, 4, 'a knot is pinned to a literal, not to TONE_ANCHOR');
|
||||
assert.equal((mathTmpl.match(/\$\{TONE_[A-Z_]+\}/g) ?? []).length, 7, 'a window edge or a rate is pinned to a literal, not to its TONE_ constant');
|
||||
assert.ok(tmpl.includes('${TONE_MATH_SKSL}'), 'the frame pass carries its own copy of the ramp again');
|
||||
assert.match(tmpl, /rgb = toneRamp\(rgb, t, baseLuma\(xy\), bl, sh, hl, wh, dr\);/);
|
||||
// The BASE layer the ramp is drawn through. It is ONE tap of a blurred child,
|
||||
@@ -115,24 +133,55 @@ const written = arrayFn
|
||||
.filter(Boolean)
|
||||
.reduce((n, s) => n + (s.startsWith('...u.hsl') ? 8 : 1), 0);
|
||||
assert.equal(written, declared, `toneUniformArray writes ${written} floats, the pass declares ${declared}`);
|
||||
// The guard's own constants are the kernel's derivative at its steepest point,
|
||||
// 8/(3*sqrt(3)) = 1.5396, in units of the bump's half-width — so they are read
|
||||
// off the source and checked against that number rather than taken on trust.
|
||||
const halfSlope = Number(tone.match(/export const TONE_BUMP_SLOPE_HALF = ([0-9.]+);/)?.[1]);
|
||||
const quarterSlope = Number(tone.match(/export const TONE_BUMP_SLOPE_QUARTER = ([0-9.]+);/)?.[1]);
|
||||
assert.ok(halfSlope > 0, 'TONE_BUMP_SLOPE_HALF is gone — the guard has no ceiling to hold to');
|
||||
assert.ok(Math.abs(halfSlope - 8 / (3 * Math.sqrt(3)) / 0.5) < 1e-4, `TONE_BUMP_SLOPE_HALF ${halfSlope} is not the kernel's steepest slope over a half-width`);
|
||||
assert.ok(Math.abs(quarterSlope - 8 / (3 * Math.sqrt(3)) / 0.25) < 1e-4, `TONE_BUMP_SLOPE_QUARTER ${quarterSlope} is not the same slope over a quarter-width`);
|
||||
assert.equal(quarterSlope, 2 * halfSlope, 'the two slopes are no longer the same kernel at two widths');
|
||||
const resolve = (s) =>
|
||||
s
|
||||
.replace('${TONE_MATH_SKSL}', mathTmpl)
|
||||
.replaceAll('${TONE_ANCHOR}', String(A))
|
||||
.replaceAll('${TONE_BUMP_SLOPE_HALF}', String(halfSlope))
|
||||
.replaceAll('${TONE_BUMP_SLOPE_QUARTER}', String(quarterSlope));
|
||||
.replaceAll('${TONE_BLACK_EDGE}', String(TONE_BLACK_EDGE))
|
||||
.replaceAll('${TONE_WHITE_EDGE}', String(TONE_WHITE_EDGE))
|
||||
.replaceAll('${TONE_BLACK_LIFT}', String(TONE_BLACK_LIFT))
|
||||
.replaceAll('${TONE_BLACK_CRUSH}', String(TONE_BLACK_CRUSH))
|
||||
.replaceAll('${TONE_HIGH_GAIN}', String(TONE_HIGH_GAIN))
|
||||
.replaceAll('${TONE_WHITE_GAIN}', String(TONE_WHITE_GAIN));
|
||||
|
||||
const sksl = resolve(tmpl);
|
||||
const maths = resolve(mathTmpl);
|
||||
// The four windows, as the resolved maths emits them: each is compactly
|
||||
// supported on its own stretch and ZERO outside it — a knob is the exact
|
||||
// identity off its own band, which is what the monochrome stock's BLACK travel
|
||||
// rides on (see the note in toneShader.ts) — and the black window is zero at
|
||||
// L = 0 while the white one is zero at L = 1, so the two ends cannot move. The
|
||||
// edges are matched at the SOURCE's numbers, not at literals of their own.
|
||||
const text = (s) => new RegExp(s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
assert.match(maths, text(`float toneBlackW(float L) {\n float u = clamp(1.0 - L / ${TONE_BLACK_EDGE}, 0.0, 1.0);`));
|
||||
assert.match(maths, /return u \* u \* u;\n\}/);
|
||||
assert.match(maths, text('float toneShadowW(float L) {\n return smoothstep(0.02, 0.12, L) * (1.0 - smoothstep(0.25, 0.55, L));\n}'));
|
||||
assert.match(maths, text('float toneHighW(float L) {\n return smoothstep(0.45, 0.65, L) * (1.0 - smoothstep(0.92, 1.0, L));\n}'));
|
||||
assert.match(maths, text(`float toneWhiteW(float L) {\n float u = clamp((L - ${TONE_WHITE_EDGE}) / (1.0 - ${TONE_WHITE_EDGE}), 0.0, 1.0);\n return u * u;\n}`));
|
||||
// ...and the windows are the ONLY shape: the kernel the four bumps shared, the
|
||||
// amplitudes they were summed with and the ceiling that guard held them under are
|
||||
// gone from the CODE. The prose is not read for these — TONE_MATH_SKSL still
|
||||
// tells the story of the guard it replaced, so a check on the comments would be
|
||||
// checking the wrong thing.
|
||||
const codeOf = (s) => s.replace(/\/\/[^\n]*/g, '');
|
||||
assert.doesNotMatch(codeOf(sksl), /toneBump/, 'the summed kernel is back — the four moves must be sequential');
|
||||
assert.doesNotMatch(codeOf(sksl), /holdLo|holdHi/, 'the shared ceiling is back — a stock drags another knob with it');
|
||||
assert.doesNotMatch(tone, /export const TONE_ANCHOR|export const TONE_BUMP_SLOPE/, 'the old anchor and guard constants are still declared');
|
||||
// The composition, move for move: the black pair (lift through the doc's square
|
||||
// root, crush by the doc's rate, one or the other — both zero at L = 0, so the
|
||||
// black point stays the black point and no knob leaves a pedestal), the shadow
|
||||
// gain, the highlight knee against the headroom that is LEFT (the (1 - L) is the
|
||||
// one deliberate departure from the doc's raw pow, which overshoots the cube), and
|
||||
// the white Hermite (1 - L) * L — zero on BOTH ends, so the head cannot move.
|
||||
assert.match(maths, text('float toneCurve(float L, float bl, float sh, float hl, float wh) {'));
|
||||
assert.match(maths, text(` q = toneBlackW(L);\n L = bl > 0.0\n ? L + ${TONE_BLACK_LIFT} * bl * q * (sqrt(L) - L)\n : L * max(1.0 + ${TONE_BLACK_CRUSH} * bl * q, 0.0);\n L = clamp(L, 0.0, 1.0);`));
|
||||
assert.match(maths, text(' q = toneShadowW(L);\n L *= 1.0 + sh * q * pow(1.0 - L, 1.8);\n L = clamp(L, 0.0, 1.0);'));
|
||||
assert.match(maths, text(` q = toneHighW(L);\n L += ${TONE_HIGH_GAIN} * hl * q * pow(max(L - 0.5, 0.0), 1.5) * (1.0 - L);\n L = clamp(L, 0.0, 1.0);`));
|
||||
assert.match(maths, text(` q = toneWhiteW(L);\n L += ${TONE_WHITE_GAIN} * wh * q * (1.0 - L) * L;`));
|
||||
// DR rides the same four moves with a share of each instead of masked terms of
|
||||
// its own, so it cannot fight a knob over a band or invert the ramp.
|
||||
assert.match(maths, text('float o = toneCurve(base, clamp(bl + dr * 0.12, -1.0, 1.0), clamp(sh + dr * 0.06, -1.0, 1.0),'));
|
||||
assert.match(maths, /\n\s*clamp\(hl - dr \* 0\.09, -1\.0, 1\.0\), clamp\(wh - dr \* 0\.18, -1\.0, 1\.0\)\);/);
|
||||
assert.doesNotMatch(maths, /o = clamp\(base/, 'the ramp clamps the base before the curve again — the ends of the ramp are the anchors');
|
||||
assert.doesNotMatch(tmpl, /float a4 = /, 'the ramp is back inside the pass — one copy, not two');
|
||||
const mask = readFileSync(new URL('../shared/utils/gradientMask.ts', import.meta.url), 'utf8');
|
||||
assert.ok(mask.includes('${TONE_MATH_SKSL}'), 'the mask pass does not read the shared maths');
|
||||
@@ -151,38 +200,17 @@ assert.match(sksl, /float blMask = 1\.0 - smoothstep\(0\.00, 0\.25, t\);/);
|
||||
assert.match(sksl, /float shMask = clamp\(1\.0 - smoothstep\(0\.25, 0\.50, t\) - blMask, 0\.0, 1\.0\);/);
|
||||
assert.match(sksl, /float whMask = smoothstep\(0\.75, 1\.00, t\);/);
|
||||
assert.match(sksl, /float hlMask = clamp\(smoothstep\(0\.50, 0\.75, t\) - whMask, 0\.0, 1\.0\);/);
|
||||
// The ramp: four bumps on the four knots, each moved by its own knob, added to
|
||||
// the identity. The kernel is (1-u^2)^2 — level at the knot, so a knot moves
|
||||
// without a fold at its own top, and level a half-width away, so a move lands on
|
||||
// the identity and on its neighbour without an angle. The 0.50 midpoint is where
|
||||
// the two bumps of a half meet, and both are on zero there: nothing may move it.
|
||||
// DR moves the same bumps, on the toe and the head as it did when it was a 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 toneBump\(float x, float knot, float half_width\) \{\n float u = \(x - knot\) \/ half_width;\n float v = max\(0\.0, 1\.0 - u \* u\);\n return v \* v;\n\}/);
|
||||
assert.match(sksl, /float blackA = 0\.25 \* bl \+ dr \* 0\.12;/);
|
||||
assert.match(sksl, /float shadowA = 0\.25 \* 0\.5 \* sh \+ dr \* 0\.06;/);
|
||||
assert.match(sksl, /float highA = 0\.25 \* 0\.5 \* hl - dr \* 0\.09;/);
|
||||
assert.match(sksl, /float whiteA = 0\.25 \* wh - dr \* 0\.18;/);
|
||||
// The composition, and NOT a sum of bumps on the identity: the four moves are
|
||||
// applied in sequence through their own windows, which is what makes one knob's
|
||||
// travel independent of another's (the shared ceiling used to take 0.663 of
|
||||
// BLACK's travel off on the monochrome stock) and the ramp monotone by
|
||||
// construction rather than by a guard. Nor straight segments between knots: an
|
||||
// angle in a tone curve is a Mach band — and neither a smoothstep through the
|
||||
// knots, which bends the ramp by six code values in the quarter-tones with every
|
||||
// knob on zero, where this pass also runs for the stock split tones and DR alone.
|
||||
assert.doesNotMatch(sksl, /float blackA = |float whiteA = /, 'the amplitudes are summed again — the moves must be sequential');
|
||||
assert.doesNotMatch(sksl, /o \+= dr \* 0\.12/, 'DR is an additive term again — it folds the flat stretch at 0.238');
|
||||
// The sum, and NOT straight segments between the knots: an angle in a tone curve
|
||||
// is a Mach band, and the measurement is what retired them — on a 1024-step luma
|
||||
// wedge BLACK +100 left 105 of second difference at 0.030 with the stretch above
|
||||
// it identical to the untouched frame, HIGHLIGHT -100 broke at 0.747 with 72.
|
||||
// Nor a smoothstep through the knots: an S-curve bends the ramp by six code
|
||||
// values in the quarter-tones with every knob on zero, and this pass also runs
|
||||
// for the stock split tones and for DR alone.
|
||||
assert.match(sksl, /float o = clamp\(base\n \+ blackA \* toneBump\(base, 0\.00, 0\.50\)\n \+ shadowA \* toneBump\(base, 0\.25, 0\.25\)\n \+ highA \* toneBump\(base, 0\.75, 0\.25\)\n \+ whiteA \* toneBump\(base, 1\.00, 0\.50\), 0\.0, 1\.0\);/);
|
||||
assert.doesNotMatch(sksl, /float lin\(/, 'the straight segments are back — a knot is an angle in a tone curve');
|
||||
// The guard. Two bumps share each half of the ramp and their steep sides can land
|
||||
// on the same stretch, so the two amplitudes of a half are held under a total
|
||||
// slope of 1 — read at their own steepest points, which is the loosest the pair
|
||||
// can be — and given up together past it. Without it a full BLACK and a full
|
||||
// SHADOW fold the ramp back on itself; a single knob never reaches the ceiling.
|
||||
assert.match(sksl, /float holdLo = 3\.0792 \* abs\(blackA\) \+ 6\.1584 \* abs\(shadowA\);/);
|
||||
assert.match(sksl, /float holdHi = 6\.1584 \* abs\(highA\) \+ 3\.0792 \* abs\(whiteA\);/);
|
||||
assert.match(sksl, /float kLo = holdLo > 1\.0 \? 1\.0 \/ holdLo : 1\.0;/);
|
||||
assert.match(sksl, /float kHi = holdHi > 1\.0 \? 1\.0 \/ holdHi : 1\.0;/);
|
||||
assert.doesNotMatch(sksl, /float lin\(/, 'straight segments between the knots are back — a knot is an angle in a tone curve');
|
||||
assert.doesNotMatch(sksl, /clamp\(0\.25 \+ 0\.25/, 'a knob is clamped against a neighbour knot again');
|
||||
// ...and the pixel rides the neighbourhood's move with its own difference from
|
||||
// it: Base' + Detail, ADDED and not scaled. Multiplying by the gain o / base is
|
||||
@@ -254,52 +282,73 @@ const enc = (x) => (x <= 0.0031308 ? x * 12.92 : 1.055 * x ** (1 / 2.4) - 0.055)
|
||||
assert.equal(Math.round(enc(knee(1.0, T, S)) * 255), 237);
|
||||
assert.equal(Math.round(enc(knee(1.93, T, S)) * 255), 248);
|
||||
|
||||
// The ramp as arithmetic — the same kernel, the same four amplitudes and the
|
||||
// same guard the SkSL above carries, so the shape is measured and not described.
|
||||
// The ramp as arithmetic — the same four windows, the same rates and the same
|
||||
// sequential composition the SkSL above carries, so the shape is measured and
|
||||
// not described. Each move is monotone for any amount in [-1, 1] and lands on 0
|
||||
// and 1 without moving either, so the composition is monotone by construction
|
||||
// and the two ends of the ramp are FIXED POINTS whatever the sliders say.
|
||||
const clamp01 = (x) => Math.min(1, Math.max(0, x));
|
||||
const clamp = (x, lo, hi) => Math.min(hi, Math.max(lo, x));
|
||||
// Float-exact comparisons are a trap once a value has been through a division
|
||||
// and a multiply (x / 0.25 * 0.25 is not x) — assert to within a code value.
|
||||
const close = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-12, `${msg ?? ''} ${a} != ${b}`);
|
||||
// A guarded move has been through 1 / hold and a multiply on top of the bump, so
|
||||
// it lands near its number rather than on it — a tenth of a code value is the
|
||||
// tolerance, well inside the one the cube can see.
|
||||
const near = (a, b, msg) => assert.ok(Math.abs(a - b) < 4e-5, `${msg ?? ''} ${a} != ${b}`);
|
||||
// The readings below come out of this same arithmetic to five decimals, so the
|
||||
// tolerance is the rounding of the number written down, well inside the code
|
||||
// value the cube can see.
|
||||
const near4 = (a, b, msg) => assert.ok(Math.abs(a - b) < 1e-4, `${msg ?? ''} ${a} != ${b}`);
|
||||
// Exact to the last digit where it matters: a window of zero is not "nearly"
|
||||
// zero, and that is the claim the independence checks rest on.
|
||||
const exact = (a, b, msg) => assert.equal(a, b, `${msg ?? ''} ${a} != ${b}`);
|
||||
const smoothstep = (e0, e1, x) => {
|
||||
const u = clamp01((x - e0) / (e1 - e0));
|
||||
return u * u * (3 - 2 * u);
|
||||
};
|
||||
const bump = (x, knot, halfWidth) => {
|
||||
const u = (x - knot) / halfWidth;
|
||||
const v = Math.max(0, 1 - u * u);
|
||||
return v * v;
|
||||
// The four windows, as TONE_MATH_SKSL emits them — zero outside their own band.
|
||||
const toneBlackW = (L) => {
|
||||
const u = clamp01(1 - L / TONE_BLACK_EDGE);
|
||||
return u * u * u;
|
||||
};
|
||||
function ramp(t, k) {
|
||||
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: BLACK
|
||||
// (the doc's lift through the square root, or the doc's crush), SHADOW (a gain on
|
||||
// the light), HIGHLIGHT (the doc's knee against the headroom that is left) and
|
||||
// WHITE (the Hermite (1 - L) * L). `afterBlack` is the L the shadow move reads —
|
||||
// the value a stock's own SHADOW cannot drag, because BLACK runs before it.
|
||||
function tones(t, k) {
|
||||
const { dr = 0, hl = 0, sh = 0, wh = 0, bl = 0 } = k;
|
||||
let L = t;
|
||||
let q = toneBlackW(L);
|
||||
const blackA = clamp(bl + dr * 0.12, -1, 1);
|
||||
L = blackA > 0
|
||||
? L + TONE_BLACK_LIFT * blackA * q * (Math.sqrt(L) - L)
|
||||
: L * Math.max(1 + TONE_BLACK_CRUSH * blackA * q, 0);
|
||||
const afterBlack = clamp01(L);
|
||||
L = afterBlack;
|
||||
q = toneShadowW(L);
|
||||
L *= 1 + clamp(sh + dr * 0.06, -1, 1) * q * (1 - L) ** 1.8;
|
||||
L = clamp01(L);
|
||||
q = toneHighW(L);
|
||||
L += TONE_HIGH_GAIN * clamp(hl - dr * 0.09, -1, 1) * q * Math.max(L - 0.5, 0) ** 1.5 * (1 - L);
|
||||
L = clamp01(L);
|
||||
q = toneWhiteW(L);
|
||||
L += TONE_WHITE_GAIN * clamp(wh - dr * 0.18, -1, 1) * q * (1 - L) * L;
|
||||
return { o: clamp01(L), afterBlack };
|
||||
}
|
||||
// ...plus the four ZONE masks, which the ramp does NOT read any more: they are
|
||||
// the partition the stock split tones ride, one per quarter, each the doc's own
|
||||
// smoothstep minus the tent before it. They are carried here because the checks
|
||||
// below measure that partition; `o` is delegated to the shared maths above.
|
||||
function ramp(t, k) {
|
||||
const blMask = 1 - smoothstep(0, 0.25, t);
|
||||
const shMask = clamp01(1 - smoothstep(0.25, 0.5, t) - blMask);
|
||||
const whMask = smoothstep(0.75, 1, t);
|
||||
const hlMask = clamp01(smoothstep(0.5, 0.75, t) - whMask);
|
||||
let blackA = A * bl + dr * 0.12;
|
||||
let shadowA = A * 0.5 * sh + dr * 0.06;
|
||||
let highA = A * 0.5 * hl - dr * 0.09;
|
||||
let whiteA = A * wh - dr * 0.18;
|
||||
// The two moves of a half are read at their own steepest points and held under
|
||||
// a total slope of 1, together — see the note on the guard in toneShader.ts.
|
||||
const holdLo = halfSlope * Math.abs(blackA) + quarterSlope * Math.abs(shadowA);
|
||||
const holdHi = quarterSlope * Math.abs(highA) + halfSlope * Math.abs(whiteA);
|
||||
const kLo = holdLo > 1 ? 1 / holdLo : 1;
|
||||
const kHi = holdHi > 1 ? 1 / holdHi : 1;
|
||||
blackA *= kLo;
|
||||
shadowA *= kLo;
|
||||
highA *= kHi;
|
||||
whiteA *= kHi;
|
||||
const o =
|
||||
t +
|
||||
blackA * bump(t, 0, 0.5) +
|
||||
shadowA * bump(t, 0.25, 0.25) +
|
||||
highA * bump(t, 0.75, 0.25) +
|
||||
whiteA * bump(t, 1, 0.5);
|
||||
return { o: clamp01(o), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask };
|
||||
return { ...tones(t, k), blMask, shMask, hlMask, whMask, maskSum: blMask + shMask + hlMask + whMask };
|
||||
}
|
||||
|
||||
// The tents never overlap — each is the doc's smoothstep minus the tent before
|
||||
@@ -314,8 +363,9 @@ for (let t = 0; t <= 1; t += 1 / 512) {
|
||||
if (Math.abs(t - 0.5) < 1e-12) assert.equal(maskSum, 0, 'the midpoint is weighted');
|
||||
}
|
||||
// The neighbouring tents cross at half weight ON the knot between them, and the
|
||||
// 0.50 midpoint is where all four are on zero — the quiet value, and the reason
|
||||
// a mid-grey does not move while the ends do.
|
||||
// 0.50 midpoint is where all four are on zero — the quiet value for the split
|
||||
// tones, which is all these masks weight. (The RAMP is another matter: nothing
|
||||
// feeds it from here, and SHADOW's own window spans the middle.)
|
||||
assert.equal(ramp(0.125, {}).blMask, 0.5);
|
||||
assert.equal(ramp(0.125, {}).blMask, ramp(0.125, {}).shMask);
|
||||
assert.equal(ramp(0.25, {}).shMask, 1);
|
||||
@@ -326,17 +376,27 @@ assert.equal(ramp(0.75, {}).hlMask, 1);
|
||||
// Every knob on zero is EXACTLY the identity — the pass also runs for the stock
|
||||
// split tones and for DR alone, so a neutral setting must not curve the frame.
|
||||
for (let t = 0; t <= 1; t += 1 / 256) close(ramp(t, {}).o, t, `identity broke at ${t}`);
|
||||
// The midpoint is the one value no knob reaches, at any setting.
|
||||
for (const k of [{ hl: 1, sh: 1, wh: 1, bl: 1 }, { hl: -1, sh: -1, wh: -1, bl: -1 }, { hl: 1, sh: -1, wh: -1, bl: 1 }])
|
||||
close(ramp(0.5, k).o, 0.5, 'a knob moved the midpoint');
|
||||
// Neither END of the ramp moves, at any setting: both are fixed points of every
|
||||
// move above — the black pair is zero at L = 0 and the white pair is zero at
|
||||
// L = 1 — so no combination of the four sliders can lift the toe or roll the
|
||||
// head. There is no fixed midpoint any more: SHADOW is a gain on the light and
|
||||
// its window spans the middle, so at t = 0.5 SHADOW +1 lands on 0.510636 and -1
|
||||
// on 0.489364, where the old sum of bumps met on zero.
|
||||
close(ramp(0, { bl: 1 }).o, 0, 'BLACK lifted the toe off the floor');
|
||||
close(ramp(0, { bl: -1, sh: -1, hl: -1, wh: -1 }).o, 0, 'a knob moved the floor');
|
||||
close(ramp(1, { wh: 1 }).o, 1, 'WHITE moved the head');
|
||||
close(ramp(1, { wh: -1 }).o, 1, 'a knob moved the head');
|
||||
near4(ramp(0.5, { sh: 1 }).o, 0.51064, 'SHADOW no longer spans the middle');
|
||||
near4(ramp(0.5, { sh: -1 }).o, 0.48936, 'SHADOW no longer spans the middle');
|
||||
|
||||
// Monotone under EVERY combination of the four at full deflection, DR included.
|
||||
// This is the whole reason the ramp is bumps on the identity instead of the doc's
|
||||
// additive masks, which measured a slope of -5 per unit luma on BLACK +1 against
|
||||
// SHADOW -1 (an inverted band at t = 0.875, scratchpad tone-proto.mjs): the two
|
||||
// moves of a half are held under a total slope of 1, so the sum can never carry
|
||||
// the curve backwards — and a fold at a knot is a worse band than the seams the
|
||||
// straight segments drew there.
|
||||
// The composition of monotone moves is monotone by construction, but each move is
|
||||
// only piecewise — a window edge is a slope change — so this is the measurement
|
||||
// of that. The grid is 1/512 thick, so a step can straddle an edge and backtrack
|
||||
// inside the width of one cell: the worst measured is -0.00119 at t = 0.0977
|
||||
// (BLACK -1 with SHADOW -1, the two crush moves meeting under the toe window's
|
||||
// edge — 0.30 of a code value), and the same sweep at 1/8192 reads -0.0000745,
|
||||
// so the size of the cell is what the number is. A wider backtrack is a fold.
|
||||
const combos = [];
|
||||
for (const bl of [-1, 0, 1])
|
||||
for (const sh of [-1, 0, 1])
|
||||
@@ -344,109 +404,135 @@ for (const bl of [-1, 0, 1])
|
||||
for (const wh of [-1, 0, 1])
|
||||
for (const dr of [0, 1]) combos.push({ bl, sh, hl, wh, dr });
|
||||
let worst = Infinity;
|
||||
let worstAt = 0;
|
||||
let worstCombo = null;
|
||||
for (const k of combos) {
|
||||
let prev = null;
|
||||
for (let t = 0; t <= 1; t += 1 / 512) {
|
||||
const o = ramp(t, k).o;
|
||||
if (prev !== null) {
|
||||
assert.ok(o >= prev - 1e-12, `ramp folded at ${t} for ${JSON.stringify(k)}`);
|
||||
if (o - prev < worst) worst = o - prev;
|
||||
if (prev !== null && o - prev < worst) {
|
||||
worst = o - prev;
|
||||
worstAt = t;
|
||||
worstCombo = k;
|
||||
}
|
||||
prev = o;
|
||||
}
|
||||
}
|
||||
assert.ok(worst > -1e-12, `worst step ${worst} — the ramp is folded`);
|
||||
// A knob moves its own knot by its own reach: +BLACK takes the toe off the
|
||||
// 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 are
|
||||
// the HEAD of the quarter in front of them, and the bump only ever ADDS to the
|
||||
// identity, so the whole anchor would draw that quarter flat — 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.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');
|
||||
// WHITE + is free to pass 1.0 — that is the move that clips a highlight to
|
||||
// white — and the ramp still runs through a raised knot at 1.25.
|
||||
assert.ok(1 + A * 1 > 1, 'the white knot can no longer pass 1.0');
|
||||
close(ramp(1, { wh: 1 }).o, 1, 'a raised white knot left the top of the ramp');
|
||||
// DR at full is the same curve it was on the toe — 0.12, what the masked term
|
||||
// added at t = 0, and half of it on the knot next to it — and on the head it
|
||||
// gives up a tenth of its roll: its 0.18 alone is over the guard (holdHi 1.11),
|
||||
// so the head lands on 0.8376 where the two masked terms put 0.82, and the
|
||||
// midpoint is still untouched. Now it is a bump move, so BLACK and SHADOW both at
|
||||
// -1 (a flat stretch between 0.25 and 0.5, where the old additive lift sloped
|
||||
// down and folded the ramp at 0.238) stays monotone.
|
||||
close(ramp(0, { dr: 1 }).o, 0.12, 'DR no longer lifts the toe the way it did');
|
||||
near(ramp(1, { dr: 1 }).o, 0.8376201610808, 'DR no longer rolls the head the way it did');
|
||||
close(ramp(0.5, { dr: 1 }).o, 0.5, 'DR moved the midpoint');
|
||||
// Black and shadow both at -1 are the flat stretch DR used to fold: the toe takes
|
||||
// BLACK's -0.25 against DR's +0.12 and lands under the floor (clamped there), the
|
||||
// 0.25 knot takes SHADOW's -0.125 against DR's own +0.06, and the two bumps carry
|
||||
// the stretch between them up to the midpoint — never a step down.
|
||||
close(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0, 'the floor is not the floor');
|
||||
near(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.111875, 'DR moved the 0.25 knot');
|
||||
near(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.3135546875, 'DR folded the flat stretch');
|
||||
// The band above SHADOW is the one that pays for its lift, and HALF its slope is
|
||||
// the floor the fix is. Measured on a real frame (DSCF1701, 21% of its pixels in
|
||||
// that band) with SHADOW +90, a whole anchor left 0.10 of the band's own spread
|
||||
// where half leaves 0.55 — see the note on a1 in toneShader.ts and the sweep in
|
||||
// scratchpad sh-band.mjs. Swept here over the knob's whole travel: neither
|
||||
// quarter of the ramp SHADOW touches may be drawn flatter than half its slope,
|
||||
// and neither may be stretched past one and a half, which is the same defect
|
||||
// upside down (a crush that flattens the darks into one black).
|
||||
assert.ok(worst >= -2e-3, `worst step ${worst} at ${worstAt} for ${JSON.stringify(worstCombo)} — the ramp is folded`);
|
||||
// ...and every one of those combinations still lands on the two anchors, exactly.
|
||||
for (const k of combos) {
|
||||
exact(ramp(0, k).o, 0, `the toe moved at ${JSON.stringify(k)}`);
|
||||
exact(ramp(1, k).o, 1, `the head moved at ${JSON.stringify(k)}`);
|
||||
}
|
||||
// A knob moves ONLY its own band. The windows are compactly supported, so off
|
||||
// its own band a knob is the EXACT identity: the monochrome stock's own SHADOW
|
||||
// (-0.20, or the classic stocks' -0.47) changes nothing where BLACK is working at
|
||||
// t = 0.01, where the shared ceiling used to take 0.663 of BLACK's travel off that
|
||||
// stock. That is the "kéo theo sự thay đổi của thông số khác" report, retired.
|
||||
exact(ramp(0.01, { bl: -1 }).o, ramp(0.01, { bl: -1, sh: -0.47 }).o, 'SHADOW reached into BLACK\'s band');
|
||||
exact(ramp(0.90, { hl: -1 }).o, ramp(0.90, { hl: -1, sh: 0.47 }).o, 'SHADOW reached into HIGHLIGHT\'s band');
|
||||
exact(ramp(0.05, { sh: -1 }).o, ramp(0.05, { sh: -1, wh: 1 }).o, 'WHITE reached into SHADOW\'s band');
|
||||
// The reach of each knob, read off the formula. These are OUTPUT values of the
|
||||
// ramp (`.o`) to five decimals, so near4 is the rounding of what is written down
|
||||
// and nothing wider. BLACK - is the doc's crush and + the doc's lift; SHADOW is a
|
||||
// gain on the light, so it reaches past 0.5 (0.51064 at +100 against 0.48936 at
|
||||
// -100 — the bell under the window is not symmetric); HIGHLIGHT and WHITE move
|
||||
// the head and neither clips it.
|
||||
for (const [knob, sides] of [
|
||||
['bl', [
|
||||
[-1, [[0.05, 0.03399], [0.10, 0.09254], [0.15, 0.14941], [0.20, 0.20], [0.50, 0.50]]],
|
||||
[1, [[0.05, 0.09578], [0.10, 0.11329], [0.18, 0.18]]],
|
||||
]],
|
||||
['sh', [
|
||||
[1, [[0.10, 0.17412], [0.20, 0.33384], [0.50, 0.51064]]],
|
||||
[-1, [[0.10, 0.02588], [0.20, 0.06616], [0.50, 0.48936]]],
|
||||
]],
|
||||
['hl', [
|
||||
[1, [[0.70, 0.76708], [0.80, 0.88216], [0.90, 0.96325], [0.95, 0.97579]]],
|
||||
[-1, [[0.70, 0.63292], [0.80, 0.71784], [0.95, 0.92421]]],
|
||||
]],
|
||||
['wh', [
|
||||
[1, [[0.90, 0.93375], [0.95, 0.99008]]],
|
||||
[-1, [[0.90, 0.86625], [0.95, 0.90992]]],
|
||||
]],
|
||||
])
|
||||
for (const [amount, cases] of sides)
|
||||
for (const [t, want] of cases)
|
||||
near4(ramp(t, { [knob]: amount }).o, want, `${knob} at ${amount} on ${t}`);
|
||||
// Nothing clips inside the ramp: HIGHLIGHT +100 at 0.95 is still under the
|
||||
// ceiling of the cube, and the only interior value that reaches 1.0 is all four
|
||||
// at +100 on a pixel already at the head — the anchors are what the clamp holds.
|
||||
assert.ok(ramp(0.95, { hl: 1 }).o < 1, 'HIGHLIGHT +100 is clipping the head');
|
||||
exact(ramp(0.99, { bl: 1, sh: 1, hl: 1, wh: 1 }).o, 1, 'a full set of knobs on a bright pixel does not reach white');
|
||||
// DR rides the same four moves, a share of each (0.12 / 0.06 / -0.09 / -0.18),
|
||||
// and the two ends are still fixed points: +0.12 of BLACK lifts nothing at
|
||||
// L = 0 and -0.18 of WHITE rolls nothing at L = 1. The middle moves with the
|
||||
// SHADOW share DR takes — 0.500638 where the old masked terms summed to a flat
|
||||
// 0.5 — which is the DR a frame can see.
|
||||
exact(ramp(0, { dr: 1 }).o, 0, 'DR lifted the toe off the floor');
|
||||
exact(ramp(1, { dr: 1 }).o, 1, 'DR rolled the head under 1.0');
|
||||
near4(ramp(0.5, { dr: 1 }).o, 0.50064, 'DR no longer moves the middle the way it did');
|
||||
// BLACK and SHADOW both at -1 under DR: the two crush moves meet under the toe
|
||||
// window's edge and the 1/512 cell reads a backtrack there (the monotone sweep
|
||||
// above), where the old additive terms folded the stretch outright.
|
||||
exact(ramp(0, { dr: 1, bl: -1, sh: -1 }).o, 0, 'the floor is not the floor');
|
||||
near4(ramp(0.25, { dr: 1, bl: -1, sh: -1 }).o, 0.10998, 'DR moved off the 0.25 knot');
|
||||
near4(ramp(0.375, { dr: 1, bl: -1, sh: -1 }).o, 0.28063, 'DR folded the flat stretch');
|
||||
// A band can only be lifted at the cost of the slope inside it — the report this
|
||||
// whole design answered — so each knob's own quarters are swept over the whole
|
||||
// travel and bounded rather than left to a comment. SHADOW's window spans the
|
||||
// middle, so both its quarters take the move: over the sweep the slope below the
|
||||
// 0.25 knot reads 1.5958 down to 0.4042 and the band above it 1.5532 down to
|
||||
// 0.4468, which is the quarter it is allowed to give up (neither may be drawn
|
||||
// flat, and neither may be stretched past one and a half — a crush that flattens
|
||||
// the darks into one black is the same defect upside down).
|
||||
for (let sh = -1; sh <= 1.0001; sh += 1 / 64) {
|
||||
const below = (ramp(0.25, { sh }).o - ramp(0, { sh }).o) / 0.25;
|
||||
const band = (ramp(0.5, { sh }).o - ramp(0.25, { sh }).o) / 0.25;
|
||||
assert.ok(band >= 0.5 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`);
|
||||
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}`);
|
||||
assert.ok(below >= 0.40 - 1e-12, `SHADOW drew the quarter below it flat at ${sh}: slope ${below}`);
|
||||
assert.ok(band >= 0.40 - 1e-12, `SHADOW drew the band above it flat at ${sh}: slope ${band}`);
|
||||
assert.ok(below <= 1.60 + 1e-12, `SHADOW stretched the quarter below it at ${sh}: slope ${below}`);
|
||||
assert.ok(band <= 1.60 + 1e-12, `SHADOW stretched the band above it at ${sh}: slope ${band}`);
|
||||
}
|
||||
// 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.
|
||||
// HIGHLIGHT pays the same way and at BOTH ends — its knot is the head of the top
|
||||
// quarter, so lifting it draws 0.75..1.00 flat and pulling it draws 0.50..0.75 —
|
||||
// and its window is the narrower one, so its own spread over the sweep is 0.6875
|
||||
// to 1.3125 either side of the knot against the half it is allowed.
|
||||
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}`);
|
||||
assert.ok(mid >= 0.50 - 1e-12, `HIGHLIGHT drew the quarter below it flat at ${hl}: slope ${mid}`);
|
||||
assert.ok(top >= 0.50 - 1e-12, `HIGHLIGHT drew the top quarter flat at ${hl}: slope ${top}`);
|
||||
assert.ok(mid <= 1.50 + 1e-12, `HIGHLIGHT stretched the quarter below it at ${hl}: slope ${mid}`);
|
||||
assert.ok(top <= 1.50 + 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).
|
||||
// The film stocks ride the same windows, so their numbers are their own. The
|
||||
// knots they were tuned to are 0.18 Classic Chrome/Vivid, 0.22 Acros, 0.17 Acros
|
||||
// HC, with the head 0.7375 on Acros and 0.815 on Acros HC — and through this
|
||||
// curve they read 0.179992 / 0.220209 / 0.169565 and 0.7375 / 0.814844, so the
|
||||
// two Acros toes land 2.1e-4 and 4.3e-4 under the target (the shadow window's own
|
||||
// bell at 0.25, which the reach checks above have already measured). The entries
|
||||
// are pinned to the values the ramp is actually read at, so a stock that drifts
|
||||
// off its look is a red check.
|
||||
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, hlSrc, hl, head] of [
|
||||
['classic-chrome', -0.56, 0.18],
|
||||
['classic-vivid', -0.56, 0.18],
|
||||
['monochrome', -0.24, 0.22, '-0.10', -0.1, 0.7375],
|
||||
['mono-high-contrast', -0.64, 0.17, '0.52', 0.52, 0.815],
|
||||
['classic-chrome', -0.47, 0.17999192],
|
||||
['classic-vivid', -0.47, 0.17999192],
|
||||
['monochrome', -0.2, 0.22020933, '-0.16', -0.16, 0.7375],
|
||||
['mono-high-contrast', -0.54, 0.16956519, '0.83', 0.83, 0.81484375],
|
||||
]) {
|
||||
assert.ok(
|
||||
filmFlat.includes(`${name}: { sh: ${sh}`),
|
||||
`${name} is not on the doubled ${sh} — the stock's crush moved with the knob's reach`
|
||||
`${name} is not on the ${sh} its crush was solved at`
|
||||
);
|
||||
close(ramp(0.25, { sh }).o, knot, `${name}'s shadow knot moved`);
|
||||
near4(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`);
|
||||
assert.ok(filmFlat.includes(`hl: ${hlSrc}`), `${name} is not on the ${hlSrc} its shoulder was solved at`);
|
||||
near4(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
|
||||
@@ -577,11 +663,12 @@ for (const [rgb, knobs] of [
|
||||
// 0.50, at the deflection this was reported at (the full +100, which is +1 here):
|
||||
//
|
||||
// read at the pixel every pixel lands on its own o, so the band's spread
|
||||
// comes out multiplied by the slope the quarter has left
|
||||
// under the knot — the grey sheet, drawn flat, 0.48 of its
|
||||
// own spread at this deflection (0.50 when the ramp was
|
||||
// straight segments, the bump adds a little of the lift
|
||||
// back into the band).
|
||||
// comes out multiplied by the slope the curve has left over
|
||||
// it — the grey sheet, drawn flat, 0.4163 of its own spread
|
||||
// at this deflection (the shadow window is a gain whose bell
|
||||
// is already falling through 0.26..0.50, so the quarter
|
||||
// above the knot keeps less of itself than a straight
|
||||
// segment would have kept).
|
||||
// read at the base every pixel of ONE neighbourhood takes the same move,
|
||||
// o(base) - base, and its own difference from the base is
|
||||
// added to it, so the texture inside the region comes out
|
||||
@@ -598,7 +685,7 @@ const spread = (xs) => Math.max(...xs) - Math.min(...xs);
|
||||
const bandBase = band.reduce((a, b) => a + b, 0) / band.length;
|
||||
const movedGlobally = band.map((t) => rebuild([t, t, t], { sh: 1 }).clamped[0]);
|
||||
const movedLocally = band.map((t) => rebuild([t, t, t], { sh: 1 }, bandBase).clamped[0]);
|
||||
near(spread(movedGlobally) / spread(band), 0.480832, 'the pixel-read ramp no longer draws its own band flat');
|
||||
near4(spread(movedGlobally) / spread(band), 0.41631, 'the pixel-read ramp no longer draws its own band flat');
|
||||
const bandGain = ramp(bandBase, { sh: 1 }).o / bandBase;
|
||||
assert.ok(bandGain > 1.1, `the lift is not worth measuring: gain ${bandGain}`);
|
||||
close(spread(movedLocally) / spread(band), 1, 'the band lost its texture under the lift — the detail is being scaled again');
|
||||
|
||||
@@ -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