light: the tone ramp is four bumps on the identity, not straight segments between five knots — a knot is an angle, an angle in a tone curve is a Mach band, and the wedge reads the seams: BLACK +100 broke at 0.030 with 105 of second difference, HIGHLIGHT -100 at 0.747 with 72
The ramp was a0..a4 with the pixel's base interpolated straight between them. A segment meets its neighbour at an ANGLE, and the second derivative of a tone curve is what a gradient reads as a band — so a knob left a line across the mid-tones, worst exactly where it was reported: BLACK +100 put its whole lift inside 0.25 and the stretch from 0.25 up came back identical to the untouched frame (the "transition stays grey" it was reported for), and HIGHLIGHT -100 folded a seam into 0.747, between the highlights it pulled and the shadow it left under them. Measured on a 1024-step luma wedge through the exported pass, second difference through a ±1% box: BLACK +100 read 105 at 0.030 against 0.000 from 0.25 up, HIGHLIGHT -100 read 72 at 0.747. Step of the first derivative across the knots: 0.955 at 0.25 and 1.146 at 0.75 — the curve arrived folded, and 1.146 is a sign flip, not a bend. So each knob is now a BUMP on the identity, peaking on its own knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT on 0.75, WHITE on 1.00 — with the kernel (1-u^2)^2 over a half-width (a half of the ramp for the two ends, whose knots ARE the ends, a quarter for the two heads). Level at u = 0, so a knot moves without a fold at its own top; level at u = 1, so a move lands on the identity and on its neighbour without an angle; C1 everywhere between. The two bumps of a half meet on 0.50 both on zero, which is the same fixed midpoint as before, and DR still moves the same knots (0.12 on the toe, 0.18 on the head, half of each on the heads beside them). A sum of bumps can overshoot where two steep sides land on one stretch — past a slope of 1 the curve runs BACKWARDS, a worse band than the seams this replaces, and it is reachable: DR alone was under it, BLACK and SHADOW +100 together were not (unguarded min slope -0.0141). The guard reads each pair at its own steepest points, 8/(3*sqrt(3))/w per unit amplitude (TONE_BUMP_SLOPE_HALF 3.0792, TONE_BUMP_SLOPE_QUARTER 6.1584), holds the two under one and gives them up together past it. A single knob never reaches it (a full BLACK is 0.77, a full SHADOW 0.77), so every slider keeps its whole travel; the worst case is DR at full, which gives up a tenth of its head roll (0.18 -> 0.8376 on the head), and BLACK with SHADOW both at +100, which arrive at 0.65 of their own lift instead of folding. Guarded, the sweep over five levels of all four knobs and DR reads a min slope of +0.0183 and a max of 1.9937, with the largest slope jump 0.00005. After: the same wedge, the same pass. The knot steps are 0.096 at 0.25 and 0.478 at 0.75, with no sign flip — C1 across the knot instead of a fold. BLACK +100 now carries the rework out of its own quarter: +0.139 at 0.25, +0.101 at 0.30, +0.033 at 0.40, 0.000 at 0.50, where it used to read 0.000 from 0.25 all the way up. HIGHLIGHT -100 keeps its lift (-0.126 peak against -0.121 before) and spends it over the quarter instead of into a line. Every knob on zero is the identity to the last bit — the pass also runs for the stock split tones and for DR alone — and 0.50 is still the one value no knob moves. Checks: tone-base-check.mjs now runs the bump and the guard as arithmetic beside the shader (with the negative control: the unguarded pair still folds, the guard is what stops it). highlight-knee-check.mjs reads the kernel and the two slope constants off the source, pins the four amplitudes and the guard, and sweeps the travel of every knob as before. All ten checks that run without a browser pass, build clean. Skipped: the guard's ceiling is a constant, not a search for the widest travel that still clears a band we cannot see. Add when a frame shows a band the deflections in hand cannot explain.
This commit is contained in:
@@ -141,6 +141,15 @@ const BAND_BLOCK = hslBandGaps()
|
||||
// a whole quarter the band is drawn flat — see the notes on a1 and a3.
|
||||
export const TONE_ANCHOR = 0.25;
|
||||
|
||||
// How steep a bump of amplitude A gets, A excluded: the kernel (1-u^2)^2 tops out
|
||||
// at |dk/du| = 8/(3*sqrt(3)) = 1.5396 on each side of its peak (u = 1/sqrt(3)),
|
||||
// and u counts the distance to the knot in units of the bump's half-width. Over
|
||||
// the half-width of BLACK and WHITE that is 3.08*A, over the quarter-width of
|
||||
// SHADOW and HIGHLIGHT 6.16*A, and the two numbers are what the guard inside
|
||||
// toneRamp holds two moves sharing a half of the ramp under.
|
||||
export const TONE_BUMP_SLOPE_HALF = 3.0792;
|
||||
export const TONE_BUMP_SLOPE_QUARTER = 6.1584;
|
||||
|
||||
// How far out the BASE layer of `toneRamp` reads, as a fraction of the frame's
|
||||
// own width — the fix_shadow.md neighbourhood (it asks for 2%..5% of the width).
|
||||
// A fraction rather than a pixel count so the preview and the export look at the
|
||||
@@ -179,26 +188,41 @@ export const TONE_BASE_SIGMA = 0.35;
|
||||
// has no such knob and hands in 0, which is what DR's terms are worth when it is
|
||||
// off on the frame too.
|
||||
export const TONE_MATH_SKSL = `
|
||||
// Fraction of the way from e0 to e1, clamped — the position on one straight
|
||||
// segment of the tone ramp.
|
||||
float lin(float e0, float e1, float x) {
|
||||
return clamp((x - e0) / (e1 - e0), 0.0, 1.0);
|
||||
// One knob's move, read at the position x on the ramp: 1 on the knob's own knot,
|
||||
// falling to 0 on the knot a half-width away and staying there — (1-u^2)^2 with u
|
||||
// the distance to the knot in half-widths. Level at u = 0 and at u = 1, so the
|
||||
// move joins the ramp without an angle at either end.
|
||||
float toneBump(float x, float knot, float half_width) {
|
||||
float u = (x - knot) / half_width;
|
||||
float v = max(0.0, 1.0 - u * u);
|
||||
return v * v;
|
||||
}
|
||||
// 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 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
|
||||
// this file).
|
||||
// DR moves the same knots instead of adding its own masked terms on top: it
|
||||
// The ramp the pixel is rebuilt through: the identity plus four moves. Each knob
|
||||
// owns a bump that PEAKS on its knot — BLACK on 0.00, SHADOW on 0.25, HIGHLIGHT
|
||||
// on 0.75, WHITE on 1.00 — and is back on zero where the next one starts, a
|
||||
// quarter away (a half for the two ends, their knots being the ends of the ramp),
|
||||
// so the two bumps of a half meet on 0.50 at zero height AND zero slope: a knob
|
||||
// moves its own end of the ramp and nothing else, and 0.50 is fixed — the one
|
||||
// point all four sliders leave alone, which is what keeps a mid-grey a mid-grey
|
||||
// while the ends move around it. Every knob on zero leaves the sum empty, so the
|
||||
// whole thing is exactly the identity (see the note at the head of this file).
|
||||
//
|
||||
// Straight segments between the knots were the first cut, and the measurement is
|
||||
// what retired them: a segment meets its neighbour at an angle, and an angle in a
|
||||
// tone curve is a Mach band. On a 1024-step luma wedge (scratchpad probe, JPEG
|
||||
// 8-bit, second derivative through a ±1% box) BLACK +100 came back with 105 at
|
||||
// 0.030 while the stretch above it was untouched to the last bit — 0.0000 from
|
||||
// 0.25 up, the "transition stays grey" the knob was reported for — and HIGHLIGHT
|
||||
// -100 broke at 0.747 with 72, the seam between the lifted highlights and the
|
||||
// shadow under them. The kernel is (1-u^2)^2: level at the peak, so a knot moves
|
||||
// without a fold at its own top, level where it lands, so the join on 0.50 and
|
||||
// the toe on 0.00 stay clean, and C1 at both ends of a move and everywhere
|
||||
// between, which is the whole of what a Mach band asks for.
|
||||
//
|
||||
// DR moves the same four bumps instead of adding its own masked terms on top: it
|
||||
// lifts the toe and rolls the head exactly as before at t = 0 and t = 1 —
|
||||
// 0.12 and 0.18 at full strength — and half of each at the knots next to them,
|
||||
// but because it is a knot move the ordering clamp holds it too. Added as a
|
||||
// separate term it could not: with BLACK and SHADOW both at -1 the ramp is flat
|
||||
// between 0.25 and 0.5, and DR's own shadow lift slopes DOWN through that
|
||||
// stretch, which is a fold at 0.238.
|
||||
// so the terms land on the two ends and on the heads beside them as one move.
|
||||
//
|
||||
// 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,
|
||||
@@ -245,40 +269,65 @@ vec3 lightMove(vec3 c, float t, float o) {
|
||||
return clamp(vec3(o) + (c - vec3(t)) * k, 0.0, 1.0);
|
||||
}
|
||||
vec3 toneRamp(vec3 c, float t, float base, float bl, float sh, float hl, float wh, float dr) {
|
||||
float a4 = 1.0 + ${TONE_ANCHOR} * wh - dr * 0.18;
|
||||
// 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);
|
||||
// BLACK owns 0.00, the edge of the ramp, so its bump is the half-width one and
|
||||
// the toe opens to A while the quarter above it takes the fall of it. As a
|
||||
// bump the fall is a slope and never a plateau, which is what the report was
|
||||
// about: at +100 the lift used to be spent inside 0.25 and the stretch above it
|
||||
// came back identical to the untouched frame — the measured 0.0000 from 0.25 up
|
||||
// in the note at the top of the ramp. Here the mid-tones rise a little with the
|
||||
// toe and the corner that drew the seam at 0.25 is gone.
|
||||
float blackA = ${TONE_ANCHOR} * bl + dr * 0.12;
|
||||
// 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 —
|
||||
// and that band is where a waterfall's spray and a sunlit rock sit. At the
|
||||
// whole anchor the knot lands ON the midpoint and draws the band flat: on a
|
||||
// real frame (DSCF1701, misty valley, 21% of its pixels in the band) SHADOW
|
||||
// +90 came back with 0.10 of the band's own spread and a mean of 0.392 where
|
||||
// it was 0.489 — the milky white sheet the knob was reported for, bright areas
|
||||
// with their contrast gone. At half: 0.55 of the spread, mean 0.441, and the
|
||||
// toe still opens at 1.45x. Half also keeps the knob smooth over its whole
|
||||
// travel — a clamp alone would pin it from +50 on and leave the top of the
|
||||
// slider dead — which is why the RATE is halved and not just the ceiling.
|
||||
float a1 = clamp(0.25 + ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06, 0.0, 0.5);
|
||||
float a0 = clamp(${TONE_ANCHOR} * bl + dr * 0.12, 0.0, a1);
|
||||
// taste. The knot is the HEAD of the quarter above it, and the bump only ever
|
||||
// ADDS to the identity, so that quarter pays in slope for every unit the knot
|
||||
// rises: its mean slope is 1 - A / 0.25, and that band is where a waterfall's
|
||||
// spray and a sunlit rock sit. At the whole anchor the mean is 0 and the band
|
||||
// is drawn flat — on a real frame (DSCF1701, misty valley, 21% of its pixels in
|
||||
// the band) SHADOW +90 came back with 0.10 of the band's own spread and a mean
|
||||
// of 0.392 where it was 0.489, the milky white sheet the knob was reported for,
|
||||
// bright areas with their contrast gone. At half: 0.55 of the spread, mean
|
||||
// 0.441, and the toe still opens at 1.45x. Half also keeps the knob clear of
|
||||
// the guard below: at a whole-anchor rate the guard would take over past ±65
|
||||
// and the top third of the slider would do nothing, which is why the RATE is
|
||||
// halved and not just a ceiling.
|
||||
float shadowA = ${TONE_ANCHOR} * 0.5 * sh + dr * 0.06;
|
||||
// HIGHLIGHT rides half the anchor for the reason SHADOW does, and its knot is
|
||||
// the head of the 0.75..1.00 quarter: at a whole-anchor rate +100 (A = 0.25)
|
||||
// draws that quarter flat against the ceiling, its whole mean slope gone, and
|
||||
// the same knob the other way spends the quarter BELOW the knot instead. Half
|
||||
// draws neither, and the head still rolls from 0.625 to 0.875 over the travel.
|
||||
float highA = ${TONE_ANCHOR} * 0.5 * hl - dr * 0.09;
|
||||
float whiteA = ${TONE_ANCHOR} * wh - dr * 0.18;
|
||||
// Two bumps share each half of the ramp, and their steep sides can land on the
|
||||
// same stretch: past a total slope of 1 the sum would carry the curve
|
||||
// BACKWARDS — a fold, a worse band than the seams this replaced. Read at their
|
||||
// own steepest points, which is the loosest the pair can be, the two amplitudes
|
||||
// of a half are held under one number and given up together past it. A knob on
|
||||
// its own never reaches it — a full BLACK is 0.77, a full SHADOW 0.77, and DR,
|
||||
// which moves both bumps of a half at once, 0.74 below and 1.11 above for a
|
||||
// steepest sum of 1.00, so a full DR gives up a tenth of its head roll — so a
|
||||
// single slider has its whole travel, and only a pair pushed together gives
|
||||
// anything up: BLACK and SHADOW both at +100 arrive at 0.65 of their own lift
|
||||
// rather than folding.
|
||||
float holdLo = ${TONE_BUMP_SLOPE_HALF} * abs(blackA) + ${TONE_BUMP_SLOPE_QUARTER} * abs(shadowA);
|
||||
float holdHi = ${TONE_BUMP_SLOPE_QUARTER} * abs(highA) + ${TONE_BUMP_SLOPE_HALF} * abs(whiteA);
|
||||
float kLo = holdLo > 1.0 ? 1.0 / holdLo : 1.0;
|
||||
float kHi = holdHi > 1.0 ? 1.0 / holdHi : 1.0;
|
||||
blackA *= kLo;
|
||||
shadowA *= kLo;
|
||||
highA *= kHi;
|
||||
whiteA *= kHi;
|
||||
// The ramp is read at the BASE, so the curve's move is the neighbourhood's and
|
||||
// the pixel keeps its own difference from it — the ratio below is the whole of
|
||||
// the detail layer (fix_shadow.md's Reconstructed = Base' * (Input / Base)),
|
||||
// with lightMove and its caps doing the reconstruction the way every other
|
||||
// brightness move in this file is made. The pixel's own luma still travels as
|
||||
// t: it is the value that is being rebuilt, and the one the caps read.
|
||||
float o = mix(a0, a1, lin(0.00, 0.25, base));
|
||||
o = mix(o, mix(a1, 0.5, lin(0.25, 0.50, base)), step(0.25, base));
|
||||
o = mix(o, mix(0.5, a3, lin(0.50, 0.75, base)), step(0.50, base));
|
||||
o = mix(o, mix(a3, a4, lin(0.75, 1.00, base)), step(0.75, base));
|
||||
float o = base
|
||||
+ blackA * toneBump(base, 0.00, 0.50)
|
||||
+ shadowA * toneBump(base, 0.25, 0.25)
|
||||
+ highA * toneBump(base, 0.75, 0.25)
|
||||
+ whiteA * toneBump(base, 1.00, 0.50);
|
||||
// Below the pedestal there is no base worth a ratio (the same floor lightMove
|
||||
// holds its own scale under); the pixel is left where the ramp put its own
|
||||
// luma, which is what a caller with no neighbourhood hands in anyway.
|
||||
|
||||
Reference in New Issue
Block a user