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:
+193
-12
@@ -21,6 +21,17 @@
|
||||
//! boxes, one draw each, compositing onto the slice with blend state — see the
|
||||
//! second half of `mask.wgsl`.
|
||||
//!
|
||||
//! # The photograph, bound as an input
|
||||
//!
|
||||
//! A range mask (FR-DEV-10) selects by what a pixel *is*, so this pass reads
|
||||
//! the demosaiced source as well as writing masks. It is bound for every draw
|
||||
//! and looked at by two modes; everything else gets a 1x1 placeholder, for the
|
||||
//! reason the label field below does — the bindings are fixed, and a second
|
||||
//! pipeline differing only in what it ignores costs more than a texel.
|
||||
//!
|
||||
//! Nothing is read back and nothing is rasterised on this side. What crosses
|
||||
//! into CPU memory for a range layer is five floats and a matrix.
|
||||
//!
|
||||
//! # The label field
|
||||
//!
|
||||
//! Region masks index a compacted label field uploaded once per segmentation.
|
||||
@@ -31,9 +42,10 @@
|
||||
//! same place and cadence the region adjacency graph is already built at.
|
||||
|
||||
use dr_pipeline::mask::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::{GpuContext, GpuError};
|
||||
use crate::{DemosaicedImage, GpuContext, GpuError};
|
||||
|
||||
/// Modes understood by `mask.wgsl`. Kept beside the shader's `switch`.
|
||||
const MODE_REGIONS: u32 = 0;
|
||||
@@ -43,6 +55,10 @@ const MODE_SUBJECT: u32 = 3;
|
||||
/// Brush layers go through their own entry points rather than the `switch`, so
|
||||
/// this is only ever read by a person looking at a captured frame.
|
||||
const MODE_BRUSH: u32 = 4;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_LUMINANCE: u32 = 5;
|
||||
/// TRACES: FR-DEV-10
|
||||
const MODE_COLOUR: u32 = 6;
|
||||
|
||||
/// Six vertices — two triangles — per stroke. See `vs_brush`.
|
||||
const VERTICES_PER_STROKE: u32 = 6;
|
||||
@@ -66,7 +82,19 @@ struct MaskParams {
|
||||
axis: [f32; 2],
|
||||
softness: f32,
|
||||
angle: f32,
|
||||
_pad1: [f32; 2],
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Source texels per mask texel, per axis. See `image_value` in the
|
||||
/// shader for why a range averages its footprint rather than sampling it.
|
||||
source_step: [f32; 2],
|
||||
|
||||
/// Camera RGB → linear sRGB, one row per `vec4` because that is the
|
||||
/// alignment a uniform gives a three-component vector anyway. Only a
|
||||
/// range mask reads them.
|
||||
cam_to_srgb: [[f32; 4]; 3],
|
||||
/// `rgb`: as-shot white balance. `w`: non-zero for a gamma-encoded source.
|
||||
/// The same packing the generated adjust shader uses, so the two agree by
|
||||
/// construction rather than by inspection.
|
||||
as_shot_wb: [f32; 4],
|
||||
}
|
||||
|
||||
/// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it.
|
||||
@@ -371,6 +399,14 @@ pub struct MaskPass {
|
||||
/// label slots even when rasterising a gradient. A placeholder is cheaper
|
||||
/// and far simpler than two pipelines differing only in what they ignore.
|
||||
placeholder: LabelField,
|
||||
/// TRACES: FR-DEV-10
|
||||
/// Bound at the image slot for every mask that is not a range.
|
||||
///
|
||||
/// Never sampled by those modes, so its contents do not matter — but it is
|
||||
/// cleared rather than left undefined, because a placeholder whose value
|
||||
/// is arbitrary is one that makes a binding mistake look like a mask that
|
||||
/// nearly works.
|
||||
empty_image: wgpu::TextureView,
|
||||
}
|
||||
|
||||
impl MaskPass {
|
||||
@@ -405,6 +441,20 @@ impl MaskPass {
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// The photograph, for a range mask. Unfilterable for the
|
||||
// same reason the field above is: every read is a
|
||||
// `textureLoad`, and this pipeline binds no sampler.
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 6,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -515,6 +565,7 @@ impl MaskPass {
|
||||
}
|
||||
|
||||
let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?;
|
||||
let empty_image = empty_image(ctx);
|
||||
// Everywhere outside, so a layer that somehow reaches this masks
|
||||
// nothing rather than everything.
|
||||
let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?;
|
||||
@@ -530,6 +581,7 @@ impl MaskPass {
|
||||
allocations: 0,
|
||||
placeholder,
|
||||
empty_subject,
|
||||
empty_image,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -538,11 +590,17 @@ impl MaskPass {
|
||||
/// `labels` may be `None` when no layer is a region mask; a region layer
|
||||
/// without one is skipped rather than drawn wrong, since a mask that
|
||||
/// silently covers the whole frame would apply an edit everywhere.
|
||||
///
|
||||
/// `source` is the photograph a range layer measures (FR-DEV-10), and it
|
||||
/// is skipped on the same rule for the same reason: without it the shader
|
||||
/// would read a blank placeholder, and a band that happens to contain
|
||||
/// black would then cover the whole frame.
|
||||
pub fn render(
|
||||
&mut self,
|
||||
stack: &MaskStack,
|
||||
labels: Option<&LabelField>,
|
||||
subjects: Option<&SubjectMasks>,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<&MaskArray, GpuError> {
|
||||
@@ -597,7 +655,24 @@ impl MaskPass {
|
||||
_ => (&self.empty_subject, 0),
|
||||
};
|
||||
|
||||
let params = self.params(layer, field, width, height);
|
||||
// TRACES: FR-DEV-10
|
||||
// A range layer with no photograph bound is skipped rather than
|
||||
// drawn against the placeholder, on exactly the rule the two
|
||||
// cases above follow: an absent mask that defaults to "everything"
|
||||
// takes a local adjustment global, which is a far quieter failure
|
||||
// than a layer that visibly did not render.
|
||||
let image = match (&layer.source, source) {
|
||||
(s, None) if s.is_range() => {
|
||||
log::warn!(
|
||||
"mask layer {} selects a range with no image loaded; skipping",
|
||||
layer.id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
(_, image) => image,
|
||||
};
|
||||
|
||||
let params = self.params(layer, field, image, width, height);
|
||||
match &layer.source {
|
||||
MaskSource::Brush { strokes } => {
|
||||
self.draw_brush(&mut encoder, slot as u32, ¶ms, strokes, width, height)
|
||||
@@ -611,6 +686,7 @@ impl MaskPass {
|
||||
field,
|
||||
&selected,
|
||||
subject,
|
||||
image,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -634,9 +710,34 @@ impl MaskPass {
|
||||
&self,
|
||||
layer: &dr_pipeline::mask::MaskLayer,
|
||||
field: &LabelField,
|
||||
source: Option<&DemosaicedImage>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> MaskParams {
|
||||
// TRACES: FR-DEV-10
|
||||
// How much of the photograph one mask texel covers. One when there is
|
||||
// no image bound, which is a value nothing reads — the range modes are
|
||||
// the only readers and they are skipped in that case.
|
||||
let source_step = match source {
|
||||
Some(image) => {
|
||||
let (sw, sh) = image.size();
|
||||
[
|
||||
sw as f32 / width.max(1) as f32,
|
||||
sh as f32 / height.max(1) as f32,
|
||||
]
|
||||
}
|
||||
None => [1.0, 1.0],
|
||||
};
|
||||
// Row-major nine, widened to three `vec4`s. Identity where there is no
|
||||
// image, so a range that somehow reached the shader without one would
|
||||
// read camera values rather than nothing — the same defensive choice
|
||||
// the demosaicer makes for an uncalibrated body.
|
||||
let m = source.map_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], |i| {
|
||||
i.color_matrix()
|
||||
});
|
||||
let wb = source.map_or([1.0, 1.0, 1.0], |i| i.as_shot_wb());
|
||||
let non_linear = source.is_some_and(|i| i.is_non_linear());
|
||||
|
||||
let base = MaskParams {
|
||||
width,
|
||||
height,
|
||||
@@ -650,7 +751,13 @@ impl MaskPass {
|
||||
axis: [1.0, 0.0],
|
||||
softness: 0.0,
|
||||
angle: 0.0,
|
||||
_pad1: [0.0, 0.0],
|
||||
source_step,
|
||||
cam_to_srgb: [
|
||||
[m[0], m[1], m[2], 0.0],
|
||||
[m[3], m[4], m[5], 0.0],
|
||||
[m[6], m[7], m[8], 0.0],
|
||||
],
|
||||
as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }],
|
||||
};
|
||||
|
||||
match &layer.source {
|
||||
@@ -719,6 +826,33 @@ impl MaskPass {
|
||||
mode: MODE_BRUSH,
|
||||
..base
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
// A band, carried in the fields the gradients measure geometry
|
||||
// in. Reused rather than given their own, and it is not a
|
||||
// shortcut: `centre` and `axis` are two pairs of floats whose
|
||||
// meaning has always been the mode's to decide, and a range that
|
||||
// added four more would grow the uniform every other mask pays
|
||||
// for. What matters is that nothing here is a *coordinate* — a
|
||||
// range is not a function of position at all.
|
||||
MaskSource::Luminance { lo, hi, softness } => MaskParams {
|
||||
mode: MODE_LUMINANCE,
|
||||
axis: [*lo, *hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
MaskSource::Colour {
|
||||
hue,
|
||||
hue_width,
|
||||
chroma_lo,
|
||||
chroma_hi,
|
||||
softness,
|
||||
} => MaskParams {
|
||||
mode: MODE_COLOUR,
|
||||
centre: [*hue, *hue_width],
|
||||
axis: [*chroma_lo, *chroma_hi],
|
||||
softness: *softness,
|
||||
..base
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,6 +1021,7 @@ impl MaskPass {
|
||||
field: &LabelField,
|
||||
selected: &wgpu::Buffer,
|
||||
subject: (&SubjectMasks, usize),
|
||||
source: Option<&DemosaicedImage>,
|
||||
) {
|
||||
let params_buf = self
|
||||
.ctx
|
||||
@@ -924,6 +1059,13 @@ impl MaskPass {
|
||||
}),
|
||||
),
|
||||
},
|
||||
// TRACES: FR-DEV-10
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 6,
|
||||
resource: wgpu::BindingResource::TextureView(
|
||||
source.map_or(&self.empty_image, |i| i.view()),
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1005,6 +1147,38 @@ impl MaskPass {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-10
|
||||
/// A single black texel, bound at the image slot for a mask that is not a
|
||||
/// range.
|
||||
///
|
||||
/// Written rather than merely allocated. Undefined contents would be read by
|
||||
/// nothing today, but a binding mistake in a range mask would then produce
|
||||
/// whatever the driver left in memory — a mask that flickers between builds
|
||||
/// and machines, which is the hardest shape of bug this pass could have.
|
||||
fn empty_image(ctx: &GpuContext) -> wgpu::TextureView {
|
||||
let texture = ctx.device.create_texture_with_data(
|
||||
&ctx.queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("mask-empty-image"),
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DemosaicedImage::FORMAT,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
// Four half-floats of zero. Rgba16Float, so eight bytes.
|
||||
&[0u8; 8],
|
||||
);
|
||||
texture.create_view(&wgpu::TextureViewDescriptor::default())
|
||||
}
|
||||
|
||||
/// The shorter edge of the space the mask is rasterised in.
|
||||
///
|
||||
/// Feather and morphology are stored as fractions of it, so the same edit is
|
||||
@@ -1131,7 +1305,7 @@ mod tests {
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&stack, Some(&field), None, w, h)
|
||||
.render(&stack, Some(&field), None, None, w, h)
|
||||
.expect("render");
|
||||
assert_eq!(array.size(), (w, h));
|
||||
assert_eq!(array.layers(), 1);
|
||||
@@ -1153,7 +1327,7 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
assert!(pass.render(&stack, None, None, 8, 8).is_ok());
|
||||
assert!(pass.render(&stack, None, None, None, 8, 8).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1176,7 +1350,9 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 16, 16).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 16, 16)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 2, "one slice per active layer");
|
||||
}
|
||||
|
||||
@@ -1208,7 +1384,9 @@ mod tests {
|
||||
stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])]));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
let array = pass
|
||||
.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(array.layers(), 1);
|
||||
}
|
||||
|
||||
@@ -1258,7 +1436,7 @@ mod tests {
|
||||
};
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
let array = pass
|
||||
.render(&MaskStack::new(), None, None, 8, 8)
|
||||
.render(&MaskStack::new(), None, None, None, 8, 8)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
array.layers(),
|
||||
@@ -1281,17 +1459,20 @@ mod tests {
|
||||
}));
|
||||
|
||||
let mut pass = MaskPass::new(&ctx).expect("mask pass");
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 1);
|
||||
|
||||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||||
pass.render(&stack, None, None, None, 32, 32)
|
||||
.expect("render");
|
||||
assert_eq!(
|
||||
pass.allocations(),
|
||||
1,
|
||||
"same size and layer count should not reallocate"
|
||||
);
|
||||
|
||||
pass.render(&stack, None, None, 64, 64).expect("render");
|
||||
pass.render(&stack, None, None, None, 64, 64)
|
||||
.expect("render");
|
||||
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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