Let a mask start from a tone or a colour, not only a shape
Every local adjustment began from a shape: painted, drawn with a handle, or found by a model. So the only way to hold back a sky was to draw a line near where it ended, and the only way to warm skin was to paint round it — both of which put the edit's edge where the photographer put a gesture rather than where the picture changes. A gradient across a treeline halos, and an adjustment traced round a face stops on the outline of a hand. MaskSource grows two variants that select by what a pixel *is*. Luminance carries two bounds on the perceptual tone scale plus a softness; Colour carries an arc of hue, a range of chroma, and one softness for every edge of both. Five floats and three, so they diff, sync and merge per field under FR-NC-9 exactly as a gradient's geometry does — the property a stored raster has none of, and the reason the model's coverage had to sit beside its source rather than inside it. The pixels are the shader's business and nowhere else's. `mask.wgsl` takes the demosaiced source as a sixth binding and two new modes read it: decode, balance, pull a clipped photosite back to neutral, apply the camera matrix, then weigh the band. Nothing crosses to the CPU but the numbers and the matrix, and each mask texel averages its own footprint in the source, so a band lands on the tone an area is rather than on whichever texel a proxy grid happened to land on. The photograph it measures is the one the camera recorded, before this edit. A band over the edited result would slide out from under the edit as the edit was made — raising the highlights would change which pixels counted as highlights, and the slider would chase its own mask. Feather, falloff and morphology stay off a range layer, which is what `shapeable` already meant. All three are functions of the signed distance from a boundary, and a range has no boundary to be at a distance from; its edge is the softness of its own band, in the band's units. Offering them would be four controls that move and change nothing.
This commit is contained in:
@@ -28,7 +28,8 @@ struct MaskParams {
|
||||
label_width: u32,
|
||||
label_height: u32,
|
||||
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush.
|
||||
// 0 = regions, 1 = linear, 2 = radial, 3 = subject, 4 = brush,
|
||||
// 5 = luminance range, 6 = colour range.
|
||||
//
|
||||
// A brush does not read this — it has its own entry points, because it is
|
||||
// the one mask that is not a function of the whole frame — but it is set
|
||||
@@ -50,10 +51,33 @@ struct MaskParams {
|
||||
// Linear: (cos, sin) of the ramp direction. Radial: semi-axes.
|
||||
axis: vec2<f32>,
|
||||
// Linear: ramp width. Radial: edge falloff as a fraction of the radius.
|
||||
// A range: the fade at each edge of its band, in the band's own units.
|
||||
softness: f32,
|
||||
// Radial only: rotation of the ellipse.
|
||||
angle: f32,
|
||||
_pad1: vec2<f32>,
|
||||
|
||||
// TRACES: FR-DEV-10
|
||||
// How many source texels one mask texel spans, per axis.
|
||||
//
|
||||
// The mask array is rasterised at a proxy size and the photograph is not,
|
||||
// so one texel here covers several there. A range mask is a function of
|
||||
// pixel *values*, and point-sampling one source texel in four would make
|
||||
// its edge follow the sensor's noise wherever the picture has fine
|
||||
// texture — speckle that is then a mask, and therefore visible in the
|
||||
// adjustment. Averaging the footprint is what makes the band land on the
|
||||
// tone the area actually is.
|
||||
source_step: vec2<f32>,
|
||||
|
||||
// Camera RGB → linear sRGB, one row each. Only a range reads these: it is
|
||||
// the one mask that looks at the photograph, and a hue is the body's own
|
||||
// primaries until this matrix has been applied — so the same stored arc
|
||||
// would select a different set of colours on every make of sensor.
|
||||
cam_to_srgb_0: vec4<f32>,
|
||||
cam_to_srgb_1: vec4<f32>,
|
||||
cam_to_srgb_2: vec4<f32>,
|
||||
// rgb: as-shot white balance. w: non-zero when the source arrived
|
||||
// gamma-encoded rather than linear.
|
||||
as_shot_wb: vec4<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: MaskParams;
|
||||
@@ -74,6 +98,19 @@ struct MaskParams {
|
||||
// shrinking and feathering free: each is arithmetic on this, so a slider moves
|
||||
// a uniform instead of rebuilding a mask.
|
||||
@group(0) @binding(3) var subject: texture_2d<f32>;
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph itself, as the demosaicer left it: camera RGB, unbalanced,
|
||||
// with no edit applied. A 1x1 placeholder for every mask that is a shape,
|
||||
// because the bindings are fixed and a second pipeline differing only in what
|
||||
// it ignores would cost more than one texel.
|
||||
//
|
||||
// **The unedited image, and that is the design rather than an accident of
|
||||
// pass order.** A band over the *edited* result would move as the edit was
|
||||
// made: raising the highlights would change which pixels counted as
|
||||
// highlights, so the slider would chase its own mask. Measuring what the
|
||||
// camera recorded means the selection stays where the photographer put it
|
||||
// while they work on it.
|
||||
@group(0) @binding(6) var image: texture_2d<f32>;
|
||||
|
||||
// A full-screen triangle rather than a quad: three vertices instead of six,
|
||||
// no shared edge for the rasteriser to crack along, and no vertex buffer.
|
||||
@@ -221,6 +258,177 @@ fn subject_mask(uv: vec2<f32>) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Range masks (FR-DEV-10)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// The masks that select by what a pixel *is* rather than by where it sits.
|
||||
// Nothing below reads `frame_delta`, and that absence is the point: a range is
|
||||
// not a function of position, so it cannot be stretched by an aspect ratio,
|
||||
// cannot drift under a crop, and comes out the same at a proxy size and at an
|
||||
// export because the only thing it depends on is the photograph's own values.
|
||||
//
|
||||
// The band arrives entirely in the fields the gradients use — `axis` is the
|
||||
// pair of bounds, `centre` is a colour range's arc, `softness` is the fade —
|
||||
// so a range costs nothing in the uniform beyond the image transform above.
|
||||
|
||||
// Display-encoded sRGB back to linear.
|
||||
//
|
||||
// A JPEG is uploaded with its bytes untouched, so its values are gamma-encoded
|
||||
// where the demosaicer's are linear. The same undoing the generated adjust
|
||||
// shader does, at the same point and for the same reason: a band over
|
||||
// brightness is meaningless if two sources disagree about what a value means.
|
||||
fn decode_srgb(c: vec3<f32>) -> vec3<f32> {
|
||||
let lo = c / 12.92;
|
||||
let hi = pow((max(c, vec3<f32>(0.04045)) + 0.055) / 1.055, vec3<f32>(2.4));
|
||||
return select(hi, lo, c <= vec3<f32>(0.04045));
|
||||
}
|
||||
|
||||
// One source texel, as linear sRGB.
|
||||
//
|
||||
// This is the prologue of the generated adjust shader, repeated: decode,
|
||||
// balance, pull a clipped pixel back to neutral, then the camera matrix. It is
|
||||
// repeated rather than shared because the composer emits WGSL for the *edit*
|
||||
// and this pass is not one — but it must agree with it, since a range mask
|
||||
// exists to select the values the layer's own adjustments will then see.
|
||||
//
|
||||
// The highlight desaturation is the part that looks skippable and is not. A
|
||||
// fully clipped photosite arrives as (1,1,1), carrying no colour at all; the
|
||||
// as-shot multipliers are far from neutral, so balancing it and passing it
|
||||
// through the matrix produces a strong magenta. A colour range would then
|
||||
// select every blown sky as if the photographer had asked for magenta.
|
||||
fn source_texel(px: vec2<i32>) -> vec3<f32> {
|
||||
var c = textureLoad(image, px, 0).rgb;
|
||||
if (p.as_shot_wb.w > 0.5) {
|
||||
c = decode_srgb(c);
|
||||
}
|
||||
|
||||
let clipped = smoothstep(0.985, 1.0, max(c.r, max(c.g, c.b)));
|
||||
c = c * p.as_shot_wb.rgb;
|
||||
if (clipped > 0.0) {
|
||||
c = mix(c, vec3<f32>(max(c.r, max(c.g, c.b))), clipped);
|
||||
}
|
||||
|
||||
return vec3<f32>(
|
||||
dot(p.cam_to_srgb_0.rgb, c),
|
||||
dot(p.cam_to_srgb_1.rgb, c),
|
||||
dot(p.cam_to_srgb_2.rgb, c),
|
||||
);
|
||||
}
|
||||
|
||||
// The most taps one mask texel averages, per axis.
|
||||
//
|
||||
// A cap rather than the true footprint. At a 1600 px proxy over a 24 MP frame
|
||||
// the ratio is under four, so this is the whole footprint for every ordinary
|
||||
// photograph; past it the taps stride across the footprint instead of
|
||||
// covering it, which is a sample of the area rather than its mean. That is the
|
||||
// right way to run out of budget here — the estimate gets noisier, it does not
|
||||
// start measuring somewhere else.
|
||||
const MAX_SOURCE_TAPS: i32 = 4;
|
||||
|
||||
// The photograph's value under one mask texel, in linear sRGB.
|
||||
fn image_value(px: vec2<i32>) -> vec3<f32> {
|
||||
let dims = vec2<i32>(textureDimensions(image));
|
||||
let last = dims - vec2<i32>(1);
|
||||
|
||||
// The footprint's top-left corner in source texels. Not a centre plus a
|
||||
// radius: the mask texel is a *box* over the source, and sampling
|
||||
// symmetrically about its centre would weight the middle of every
|
||||
// footprint twice at odd tap counts.
|
||||
let origin = vec2<f32>(px) * p.source_step;
|
||||
let taps = clamp(vec2<i32>(ceil(p.source_step)), vec2<i32>(1), vec2<i32>(MAX_SOURCE_TAPS));
|
||||
// `stride`, not `step`: WGSL has a builtin of that name, and a local that
|
||||
// shadows one is legal and unreadable in the same breath.
|
||||
let stride = p.source_step / vec2<f32>(taps);
|
||||
|
||||
var total = vec3<f32>(0.0);
|
||||
for (var y = 0; y < taps.y; y = y + 1) {
|
||||
for (var x = 0; x < taps.x; x = x + 1) {
|
||||
let at = origin + (vec2<f32>(f32(x), f32(y)) + vec2<f32>(0.5)) * stride;
|
||||
total = total + source_texel(clamp(vec2<i32>(at), vec2<i32>(0), last));
|
||||
}
|
||||
}
|
||||
return total / f32(taps.x * taps.y);
|
||||
}
|
||||
|
||||
// A soft band: one inside, nothing outside, a smooth ramp across each edge.
|
||||
//
|
||||
// The `min` rather than a product of the two ramps. A band narrower than twice
|
||||
// its softness has no plateau, and multiplying the rising and falling ramps
|
||||
// would then peak well below one — so "select the highlights" would come out
|
||||
// at sixty per cent and the photographer would compensate with opacity,
|
||||
// against a mask that was quietly weaker than it said. `min` keeps the
|
||||
// plateau where there is one and degrades to a single peak where there is not.
|
||||
fn band(v: f32, lo: f32, hi: f32, soft: f32) -> f32 {
|
||||
if (soft <= 0.0) {
|
||||
return select(0.0, 1.0, v >= lo && v <= hi);
|
||||
}
|
||||
return min(smoothstep(lo - soft, lo, v), 1.0 - smoothstep(hi, hi + soft, v));
|
||||
}
|
||||
|
||||
fn luminance_mask(px: vec2<i32>) -> f32 {
|
||||
let y = dot(image_value(px), vec3<f32>(0.2126, 0.7152, 0.0722));
|
||||
// Onto the perceptual position `tone_position` in `ops/_helpers.yaml`
|
||||
// establishes, which is where the stored bounds are measured. Linear light
|
||||
// puts middle grey at 0.18, so a band stated in it would spend four fifths
|
||||
// of its travel inside the shadows.
|
||||
let t = clamp(pow(max(y, 0.0), 1.0 / 3.0), 0.0, 1.0);
|
||||
return band(t, p.axis.x, p.axis.y, p.softness);
|
||||
}
|
||||
|
||||
// Hue in turns, 0 at red and increasing through yellow.
|
||||
//
|
||||
// The plain six-sector definition. Zero for a neutral, which is a value the
|
||||
// caller must not act on — the chroma bound below is what keeps a colour range
|
||||
// away from the greys where this number is rounding noise.
|
||||
fn hue_of(c: vec3<f32>) -> f32 {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
let d = hi - lo;
|
||||
if (d <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
var h = 0.0;
|
||||
if (hi == c.r) {
|
||||
h = (c.g - c.b) / d;
|
||||
} else if (hi == c.g) {
|
||||
h = (c.b - c.r) / d + 2.0;
|
||||
} else {
|
||||
h = (c.r - c.g) / d + 4.0;
|
||||
}
|
||||
return fract(h / 6.0);
|
||||
}
|
||||
|
||||
fn colour_mask(px: vec2<i32>) -> f32 {
|
||||
let c = max(image_value(px), vec3<f32>(0.0));
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
// The max-minus-min chroma `colour_saturation` uses, so the number the
|
||||
// band is stated in is the one the rest of the pipeline means by
|
||||
// 'colourfulness'.
|
||||
var chroma = 0.0;
|
||||
if (hi > 0.0) {
|
||||
chroma = (hi - lo) / hi;
|
||||
}
|
||||
|
||||
// Distance round the circle, so an arc centred near red reaches both ways
|
||||
// past zero. Written as a wrap rather than as two comparisons because red
|
||||
// is exactly where skin sits, and an arc that stopped at the seam would
|
||||
// select half of it.
|
||||
let d = abs(fract(hue_of(c) - p.centre.x + 0.5) - 0.5);
|
||||
var arc = 0.0;
|
||||
if (p.softness <= 0.0) {
|
||||
arc = select(0.0, 1.0, d <= p.centre.y);
|
||||
} else {
|
||||
arc = 1.0 - smoothstep(p.centre.y, p.centre.y + p.softness, d);
|
||||
}
|
||||
|
||||
// Both, not either: an arc alone selects a haze of noise everywhere the
|
||||
// picture is nearly grey, because a hue rounded out of three almost-equal
|
||||
// channels is still a hue.
|
||||
return min(arc, band(chroma, p.axis.x, p.axis.y, p.softness));
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(i32(pos.x), i32(pos.y));
|
||||
@@ -235,6 +443,8 @@ fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
case 1u: { m = linear_mask(uv); }
|
||||
case 2u: { m = radial_mask(uv); }
|
||||
case 3u: { m = subject_mask(uv); }
|
||||
case 5u: { m = luminance_mask(px); }
|
||||
case 6u: { m = colour_mask(px); }
|
||||
default: { m = 0.0; }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user