Brighten her face without touching the sky behind her
A mask layer is an ordinary develop chain plus a rule about where it applies. Nothing in the chain knows it is being masked, so every operation that works globally now works locally and a newly declared op in `ops/` arrives with local support already done. The composer emits each layer after the global chain and before the conversion out of camera space, which is what a photographer means by "and *then* lift the shadows on her face". Op fragments write to a `c` they expect to own, so a layer block shadows it and copies the result back out through a carrier — assigning the outer one from inside is impossible precisely because it is shadowed. The fused dispatch survives: three global adjustments and two masked ones remain one shader, one read, one write. Masks rasterise on the GPU and never exist in CPU memory (ARCH §5.4). That is the whole reason darktable's brush masks lag, and it is architectural rather than tuning, so it is not a thing to inherit and fix later. The rasteriser is a render pass rather than the compute shader it obviously wants to be, and the format is why: R8Unorm is not a core storage format, so a compute path has to widen masks to four bytes per pixel — 768 MB across eight layers of a 24 MP export, against 192 MB at one byte. A colour attachment takes R8Unorm happily. The array slice comes from the attached view, so no slot uniform exists to disagree with where the pass writes. Region masks index a compacted label field rather than the watershed's raw basin roots, because a root is a sparse index into pixel space and indexing a per-region array by one would need a table the size of the image. Changing a selection then costs a few kilobytes, not a re-upload. Stored as region ids, not as pixels: diffable, mergeable per-field under FR-NC-9, and cheap in a sidecar. The ids only mean anything alongside the segmentation that produced them, so each layer carries that signature and is treated as stale rather than applied when it does not match — a confidently wrong mask being much worse than an absent one. Seven device tests render actual frames and read them back. The unit tests either side check halves that would both pass if the two agreed with each other and were both wrong; a mask sampled with x and y swapped satisfies them and fails these.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
// Rasterise one local-adjustment mask into a layer of the mask array.
|
||||
//
|
||||
// ARCH §5.4: every mask becomes pixels here and never in CPU memory. One draw
|
||||
// per layer, each targeting its own array slice, run only when a mask's
|
||||
// *shape* changes — moving a slider on a masked layer re-runs the adjust
|
||||
// shader and not this one.
|
||||
//
|
||||
// # Why this is a render pass and not a compute one
|
||||
//
|
||||
// The natural shape for this is a compute shader writing a storage texture,
|
||||
// and the format is what rules that out: **R8Unorm is not a core storage
|
||||
// format**, so a compute path has to widen the mask to R32Float or RGBA8 —
|
||||
// four bytes per pixel per layer. At eight layers over a 24 MP export that is
|
||||
// 768 MB of masks, against 192 MB at one byte. A colour attachment takes
|
||||
// R8Unorm happily, so the mask stays one byte and the pass becomes a
|
||||
// full-screen triangle.
|
||||
//
|
||||
// The array slice is chosen by the *view* the caller attaches, so there is no
|
||||
// slot uniform here — one less thing that can disagree with the shader.
|
||||
|
||||
struct MaskParams {
|
||||
// Output size, which is the render size rather than the segmentation's.
|
||||
width: u32,
|
||||
height: u32,
|
||||
// Label field size. Different from the above: the watershed runs at a
|
||||
// proxy resolution, and the mask is drawn at whatever the display or the
|
||||
// export asked for.
|
||||
label_width: u32,
|
||||
label_height: u32,
|
||||
|
||||
// 0 = regions, 1 = linear, 2 = radial.
|
||||
mode: u32,
|
||||
// How many regions the label field holds, so an out-of-range label is
|
||||
// caught rather than read past the end of `selected`.
|
||||
region_count: u32,
|
||||
// Softening applied to a region mask, in output pixels.
|
||||
feather: f32,
|
||||
_pad0: f32,
|
||||
|
||||
// Geometry, in normalised output coordinates. Meaning depends on `mode`.
|
||||
centre: vec2<f32>,
|
||||
// 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.
|
||||
softness: f32,
|
||||
// Radial only: rotation of the ellipse.
|
||||
angle: f32,
|
||||
_pad1: vec2<f32>,
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var<uniform> p: MaskParams;
|
||||
// Compacted region id per pixel of the label field. Compacted rather than the
|
||||
// watershed's raw basin roots: the roots are sparse indices into pixel space,
|
||||
// so indexing a per-region array by one would need a table as large as the
|
||||
// image. The compaction happens once, when the segmentation is built.
|
||||
@group(0) @binding(1) var<storage, read> labels: array<u32>;
|
||||
// One entry per region: non-zero if the region is in this mask. Small — a few
|
||||
// thousand bytes — which is what makes changing a selection cheap.
|
||||
@group(0) @binding(2) var<storage, read> selected: array<u32>;
|
||||
|
||||
// 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.
|
||||
@vertex
|
||||
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
|
||||
let x = f32(i32(i) / 2) * 4.0 - 1.0;
|
||||
let y = f32(i32(i) & 1) * 4.0 - 1.0;
|
||||
return vec4<f32>(x, y, 0.0, 1.0);
|
||||
}
|
||||
|
||||
fn region_at(px: vec2<i32>) -> u32 {
|
||||
// Nearest-neighbour from output space into the label field. Deliberately
|
||||
// not bilinear: region ids are *names*, and the average of region 4 and
|
||||
// region 9 is not region 6.
|
||||
let fx = (f32(px.x) + 0.5) / f32(p.width);
|
||||
let fy = (f32(px.y) + 0.5) / f32(p.height);
|
||||
let lx = clamp(i32(fx * f32(p.label_width)), 0, i32(p.label_width) - 1);
|
||||
let ly = clamp(i32(fy * f32(p.label_height)), 0, i32(p.label_height) - 1);
|
||||
return labels[u32(ly) * p.label_width + u32(lx)];
|
||||
}
|
||||
|
||||
fn in_selection(px: vec2<i32>) -> f32 {
|
||||
let r = region_at(px);
|
||||
if (r >= p.region_count) {
|
||||
return 0.0;
|
||||
}
|
||||
return select(0.0, 1.0, selected[r] != 0u);
|
||||
}
|
||||
|
||||
fn region_mask(px: vec2<i32>) -> f32 {
|
||||
let hard = in_selection(px);
|
||||
if (p.feather <= 0.0) {
|
||||
return hard;
|
||||
}
|
||||
|
||||
// Box-average the binary selection over the feather radius. Cheap, and it
|
||||
// is the whole reason a region mask does not look cut out with scissors:
|
||||
// the watershed boundary is pixel-exact, which is correct and also harsher
|
||||
// than any edit wants at a subject's edge.
|
||||
let r = i32(ceil(p.feather));
|
||||
var total = 0.0;
|
||||
var n = 0.0;
|
||||
for (var dy = -r; dy <= r; dy = dy + 1) {
|
||||
for (var dx = -r; dx <= r; dx = dx + 1) {
|
||||
let q = clamp(
|
||||
px + vec2<i32>(dx, dy),
|
||||
vec2<i32>(0, 0),
|
||||
vec2<i32>(i32(p.width) - 1, i32(p.height) - 1),
|
||||
);
|
||||
total = total + in_selection(q);
|
||||
n = n + 1.0;
|
||||
}
|
||||
}
|
||||
return total / n;
|
||||
}
|
||||
|
||||
fn linear_mask(uv: vec2<f32>) -> f32 {
|
||||
// Signed distance along the ramp direction, from the centre.
|
||||
let d = dot(uv - p.centre, p.axis);
|
||||
if (p.softness <= 0.0) {
|
||||
return select(0.0, 1.0, d >= 0.0);
|
||||
}
|
||||
return smoothstep(-p.softness * 0.5, p.softness * 0.5, d);
|
||||
}
|
||||
|
||||
fn radial_mask(uv: vec2<f32>) -> f32 {
|
||||
let ca = cos(-p.angle);
|
||||
let sa = sin(-p.angle);
|
||||
let d = uv - p.centre;
|
||||
// Into the ellipse's own frame, then normalised by its semi-axes so the
|
||||
// problem becomes a unit circle.
|
||||
let local = vec2<f32>(d.x * ca - d.y * sa, d.x * sa + d.y * ca);
|
||||
let r = length(local / max(p.axis, vec2<f32>(1e-6)));
|
||||
|
||||
let edge = clamp(p.softness, 0.0, 1.0);
|
||||
if (edge <= 0.0) {
|
||||
return select(0.0, 1.0, r <= 1.0);
|
||||
}
|
||||
return 1.0 - smoothstep(1.0 - edge, 1.0, r);
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
|
||||
let px = vec2<i32>(i32(pos.x), i32(pos.y));
|
||||
|
||||
// Normalised, so a gradient's geometry survives a crop or an export at
|
||||
// another size — the mask is defined on the frame, not on a pixel count.
|
||||
let uv = vec2<f32>(pos.x / f32(p.width), pos.y / f32(p.height));
|
||||
|
||||
var m = 0.0;
|
||||
switch p.mode {
|
||||
case 0u: { m = region_mask(px); }
|
||||
case 1u: { m = linear_mask(uv); }
|
||||
case 2u: { m = radial_mask(uv); }
|
||||
default: { m = 0.0; }
|
||||
}
|
||||
|
||||
return vec4<f32>(clamp(m, 0.0, 1.0), 0.0, 0.0, 1.0);
|
||||
}
|
||||
Reference in New Issue
Block a user