Add folder scan with format selection; validate A3 on a real library
Library setup as the user described it: pick a folder, choose which RAW
types to look for, scan recursively.
dr-types::FormatFilter the tick-box selection, seeing through VFS
placeholder suffixes so a dehydrated CR2 still
matches as a CR2
dr-sync::scan recursive walk, Depth:1 per directory, pruning
unchanged subtrees where the backend propagates
directory ETags
Verified against nextcloud.tourolle.paris (34.0.2) on a real library:
browse root 32 entries, 98ms
scan PhotosRaw 17,185 RAW files in 334 directories, 34.1s
(7,836 CR2 + 9,349 DNG)
range read 262KB of a 21.5MB DNG in 119ms — 1.22% of the file,
and enough to read "Canon EOS 6D | ISO 100"
That last line is assumption A3 validated on real data. Cataloguing this
library by whole-file fetch would move roughly 370GB; the range path
moves a few MB.
Pruning is capability-gated rather than assumed: with per-entry ETags a
probe costs a request and proves nothing about children, so it is skipped
entirely. A test asserts zero probes in that case.
Still unresolved: /core/preview returns 400 for every parameter
combination tried, including on a JPEG the server reports as having a
preview. Not a request-shape bug — it fails identically bare. Recorded
rather than worked around; ARCH §6.7 already treats server previews as
opportunistic, so nothing depends on it.
This commit is contained in:
@@ -113,6 +113,37 @@ impl ParamDescriptor {
|
||||
}
|
||||
}
|
||||
|
||||
/// A toggle. Neutral when off, so the reset contract still holds.
|
||||
pub const fn switch(id: &'static str, label: &'static str) -> Self {
|
||||
Self {
|
||||
id: ParamId(id),
|
||||
label: LocalizedKey(label),
|
||||
kind: ParamKind::Bool,
|
||||
default: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A 0…1 fraction — a proportion of something, rather than an amount.
|
||||
///
|
||||
/// Its own constructor because the crop rect needs four of them and the
|
||||
/// default differs per edge: an origin starts at 0 and an extent at 1.
|
||||
/// Precision of 4 because at 6000px a step of 0.0001 is under a pixel,
|
||||
/// and a coarser one would make a crop edge unplaceable.
|
||||
pub const fn fraction(id: &'static str, label: &'static str, default: f32) -> Self {
|
||||
Self {
|
||||
id: ParamId(id),
|
||||
label: LocalizedKey(label),
|
||||
kind: ParamKind::Scalar {
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
scale: Scale::Linear,
|
||||
unit: Unit::None,
|
||||
precision: 4,
|
||||
},
|
||||
default,
|
||||
}
|
||||
}
|
||||
|
||||
/// A general scalar with an explicit range and default.
|
||||
//
|
||||
// Eight arguments, and a builder would be the usual answer — but this has
|
||||
|
||||
@@ -0,0 +1,943 @@
|
||||
//! Framing — crop, straighten, rotate, flip (FR-DEV-3, ARCH §5.2).
|
||||
//!
|
||||
//! # Why this is not an `Operation`, and not a `Warp` either
|
||||
//!
|
||||
//! An [`crate::operation::Operation`] is a function from colour to colour. By
|
||||
//! the time one runs, the colour has been sampled and the question framing
|
||||
//! asks — *which* source pixel does this output pixel come from — has already
|
||||
//! been answered. And a crop changes the output's dimensions and aspect
|
||||
//! ratio, which no colour fragment can express.
|
||||
//!
|
||||
//! [`crate::warp::Warp`] is closer: it also rewrites coordinates before the
|
||||
//! fetch. But a warp is a *correction to the optics* — distortion and CA are
|
||||
//! properties of the lens, defined about the optical axis, over the whole
|
||||
//! frame the lens projected. Framing is a decision about *composition*, made
|
||||
//! afterwards. The order matters and is not a preference:
|
||||
//!
|
||||
//! ```text
|
||||
//! output pixel → framing → warp (lens) → sample → colour ops → output
|
||||
//! ```
|
||||
//!
|
||||
//! Reading forward, the lens is corrected on the full frame and the crop
|
||||
//! then selects from the corrected result. Correcting distortion on an
|
||||
//! already-cropped frame would place the optical centre in the wrong spot and
|
||||
//! bend the image about a point the lens never saw.
|
||||
//!
|
||||
//! So framing runs **first** in the coordinate chain, and hands the warp
|
||||
//! chain exactly the space it documents: normalised, centred, `r == 1` at the
|
||||
//! corner. Neither stage needs to know the other exists.
|
||||
//!
|
||||
//! # Why sampling changes with the angle
|
||||
//!
|
||||
//! At 90° steps and flips, output pixels land exactly on source pixels, so
|
||||
//! the map is a permutation and an integer `textureLoad` is both correct and
|
||||
//! lossless. At any other angle it is not, and nearest-neighbour sampling
|
||||
//! makes a straightened horizon visibly stair-step — the commonest use of
|
||||
//! this stage, and where the artefact is most obvious. Free angles therefore
|
||||
//! need interpolation, which is what [`Framing::needs_interpolation`] tells
|
||||
//! the composer. Paying for it only when the angle demands it keeps the
|
||||
//! common case exact rather than merely close.
|
||||
|
||||
use std::f32::consts::PI;
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit};
|
||||
use crate::operation::Affects;
|
||||
|
||||
pub const ID: OpId = OpId("framing");
|
||||
|
||||
pub const ANGLE: ParamId = ParamId("angle");
|
||||
pub const ROTATION: ParamId = ParamId("rotation");
|
||||
pub const FLIP_H: ParamId = ParamId("flip_h");
|
||||
pub const FLIP_V: ParamId = ParamId("flip_v");
|
||||
pub const CROP_X: ParamId = ParamId("crop_x");
|
||||
pub const CROP_Y: ParamId = ParamId("crop_y");
|
||||
pub const CROP_W: ParamId = ParamId("crop_w");
|
||||
pub const CROP_H: ParamId = ParamId("crop_h");
|
||||
|
||||
/// Widest straightening the control offers, in degrees either way.
|
||||
///
|
||||
/// Straightening a horizon is a small correction; a gross reorientation is
|
||||
/// what the 90° steps are for. Bounding it keeps the slider's travel where
|
||||
/// the edits actually are.
|
||||
pub const MAX_STRAIGHTEN: f32 = 45.0;
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.framing"),
|
||||
params: &[
|
||||
// Straightening. Degrees rather than a normalised amount because a
|
||||
// photographer reading "-1.4°" off a horizon knows what it means.
|
||||
ParamDescriptor::scalar(
|
||||
"angle",
|
||||
"param.angle",
|
||||
-MAX_STRAIGHTEN,
|
||||
MAX_STRAIGHTEN,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
2,
|
||||
),
|
||||
// Quarter turns, 0..3. Separate from `angle` because these are exact
|
||||
// and lossless, and because reorienting a frame is a different
|
||||
// gesture from nudging a horizon.
|
||||
ParamDescriptor::scalar(
|
||||
"rotation",
|
||||
"param.rotation",
|
||||
0.0,
|
||||
3.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
),
|
||||
ParamDescriptor::switch("flip_h", "param.flip_h"),
|
||||
ParamDescriptor::switch("flip_v", "param.flip_v"),
|
||||
// The crop rect, in fractions of the source. Normalised rather than
|
||||
// in pixels so a crop survives being applied to a proxy, a full
|
||||
// resolution render, or an export at another size — the same reason
|
||||
// the viewport renders at display resolution (FR-DSP-1).
|
||||
ParamDescriptor::fraction("crop_x", "param.crop_x", 0.0),
|
||||
ParamDescriptor::fraction("crop_y", "param.crop_y", 0.0),
|
||||
ParamDescriptor::fraction("crop_w", "param.crop_w", 1.0),
|
||||
ParamDescriptor::fraction("crop_h", "param.crop_h", 1.0),
|
||||
],
|
||||
};
|
||||
|
||||
/// A normalised crop rectangle, in fractions of the source image.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct CropRect {
|
||||
pub x: f32,
|
||||
pub y: f32,
|
||||
pub width: f32,
|
||||
pub height: f32,
|
||||
}
|
||||
|
||||
impl Default for CropRect {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 1.0,
|
||||
height: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CropRect {
|
||||
/// Smallest crop the rect may be reduced to, as a fraction of the source.
|
||||
///
|
||||
/// A zero-extent crop produces a zero-sized output texture, which is a
|
||||
/// device error rather than a visibly silly image. Bounding it here means
|
||||
/// no caller has to defend against it.
|
||||
pub const MIN_EXTENT: f32 = 0.01;
|
||||
|
||||
/// Whether this rect selects the whole image.
|
||||
pub fn is_full(&self) -> bool {
|
||||
self.x == 0.0 && self.y == 0.0 && self.width == 1.0 && self.height == 1.0
|
||||
}
|
||||
|
||||
/// Clamp into the unit square, keeping the rect non-degenerate.
|
||||
///
|
||||
/// The origin is clamped first and the extent fitted to what remains, so
|
||||
/// a rect dragged past an edge slides rather than inverting.
|
||||
pub fn normalised(self) -> Self {
|
||||
let x = finite(self.x, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||||
let y = finite(self.y, 0.0).clamp(0.0, 1.0 - Self::MIN_EXTENT);
|
||||
let width = finite(self.width, 1.0).clamp(Self::MIN_EXTENT, 1.0 - x);
|
||||
let height = finite(self.height, 1.0).clamp(Self::MIN_EXTENT, 1.0 - y);
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a non-finite value with a fallback.
|
||||
///
|
||||
/// A NaN reaching the crop rect would propagate into the output *dimensions*,
|
||||
/// not merely the pixels — `NaN as u32` is 0, and a zero-sized texture is a
|
||||
/// device error. The same defence as `ParamDescriptor::clamp`, one level up.
|
||||
fn finite(v: f32, fallback: f32) -> f32 {
|
||||
if v.is_finite() {
|
||||
v
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
/// Crop, straighten, rotation and flips for one image.
|
||||
///
|
||||
/// Holds no GPU state: like the rest of the graph this is CPU-side, so a lost
|
||||
/// device is recovered by re-composing rather than by re-deriving the edit
|
||||
/// (ARCH §6.10).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Framing {
|
||||
/// Straightening, in degrees. Positive rotates the image clockwise.
|
||||
angle: f32,
|
||||
/// Quarter turns clockwise, 0..=3.
|
||||
quarter_turns: u8,
|
||||
flip_h: bool,
|
||||
flip_v: bool,
|
||||
crop: CropRect,
|
||||
}
|
||||
|
||||
impl Default for Framing {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
angle: 0.0,
|
||||
quarter_turns: 0,
|
||||
flip_h: false,
|
||||
flip_v: false,
|
||||
crop: CropRect::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Framing {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
/// What this stage affects, for invalidation scoping (FR-DEV-3d).
|
||||
pub fn affects(&self) -> Affects {
|
||||
Affects::Geometry
|
||||
}
|
||||
|
||||
pub fn crop(&self) -> CropRect {
|
||||
self.crop
|
||||
}
|
||||
|
||||
pub fn set_crop(&mut self, rect: CropRect) {
|
||||
self.crop = rect.normalised();
|
||||
}
|
||||
|
||||
pub fn angle(&self) -> f32 {
|
||||
self.angle
|
||||
}
|
||||
|
||||
pub fn quarter_turns(&self) -> u8 {
|
||||
self.quarter_turns
|
||||
}
|
||||
|
||||
pub fn flips(&self) -> (bool, bool) {
|
||||
(self.flip_h, self.flip_v)
|
||||
}
|
||||
|
||||
/// Add quarter turns, wrapping. The rotate-left/right buttons.
|
||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||
self.quarter_turns = (i32::from(self.quarter_turns) + turns).rem_euclid(4) as u8;
|
||||
}
|
||||
|
||||
/// Whether this stage currently changes the image.
|
||||
///
|
||||
/// The same contract the operations honour: neutral framing contributes
|
||||
/// nothing to the generated shader, so an uncropped image reads its
|
||||
/// pixels through the identity map exactly as it did before this existed.
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.angle != 0.0
|
||||
|| self.quarter_turns != 0
|
||||
|| self.flip_h
|
||||
|| self.flip_v
|
||||
|| !self.crop.is_full()
|
||||
}
|
||||
|
||||
/// Whether the axes are swapped — a 90° or 270° turn.
|
||||
fn swaps_axes(&self) -> bool {
|
||||
self.quarter_turns % 2 == 1
|
||||
}
|
||||
|
||||
/// Whether the map puts output pixels between source pixels.
|
||||
///
|
||||
/// False for quarter turns and flips, which are permutations with an
|
||||
/// exact answer. True once a free angle is involved. The composer reads
|
||||
/// this to decide between an integer load and a filtered sample — and a
|
||||
/// warp being active forces interpolation regardless, which is the
|
||||
/// composer's call to make rather than this stage's.
|
||||
pub fn needs_interpolation(&self) -> bool {
|
||||
self.angle != 0.0
|
||||
}
|
||||
|
||||
pub fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
ANGLE => self.angle = finite(value, 0.0),
|
||||
// Descriptor-clamped to 0..3, so the cast cannot wrap.
|
||||
ROTATION => self.quarter_turns = finite(value, 0.0).round().clamp(0.0, 3.0) as u8,
|
||||
FLIP_H => self.flip_h = value != 0.0,
|
||||
FLIP_V => self.flip_v = value != 0.0,
|
||||
CROP_X => {
|
||||
self.crop = CropRect {
|
||||
x: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
CROP_Y => {
|
||||
self.crop = CropRect {
|
||||
y: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
CROP_W => {
|
||||
self.crop = CropRect {
|
||||
width: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
CROP_H => {
|
||||
self.crop = CropRect {
|
||||
height: value,
|
||||
..self.crop
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
_ => log::warn!("framing: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
ANGLE => self.angle,
|
||||
ROTATION => f32::from(self.quarter_turns),
|
||||
FLIP_H => f32::from(u8::from(self.flip_h)),
|
||||
FLIP_V => f32::from(u8::from(self.flip_v)),
|
||||
CROP_X => self.crop.x,
|
||||
CROP_Y => self.crop.y,
|
||||
CROP_W => self.crop.width,
|
||||
CROP_H => self.crop.height,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
|
||||
/// The output size this framing produces from a source of `(w, h)`.
|
||||
///
|
||||
/// The rendered aspect ratio follows from here, which is why this is the
|
||||
/// one piece of framing both the UI and the GPU pass need before any
|
||||
/// pixel is shaded: the output texture is allocated from it.
|
||||
///
|
||||
/// A free angle does **not** change the output size. The rotated image is
|
||||
/// sampled into the crop rect as it stands, so straightening a horizon
|
||||
/// leaves the frame where the user put it and may pull in undefined area
|
||||
/// at the corners — see [`Self::max_inscribed_crop`] for the rect that
|
||||
/// avoids that.
|
||||
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
|
||||
let (w, h) = if self.swaps_axes() {
|
||||
(height, width)
|
||||
} else {
|
||||
(width, height)
|
||||
};
|
||||
// Round rather than truncate: half of a 101px axis should be 51, and
|
||||
// truncation biases every crop smaller.
|
||||
let cw = ((w as f32 * self.crop.width).round() as u32).max(1);
|
||||
let ch = ((h as f32 * self.crop.height).round() as u32).max(1);
|
||||
(cw, ch)
|
||||
}
|
||||
|
||||
/// The largest centred crop, at the current angle, containing no
|
||||
/// undefined area.
|
||||
///
|
||||
/// Rotating a rectangle inside its own bounds exposes the corners: there
|
||||
/// is no source pixel there, and the shader renders it black. This is the
|
||||
/// rect that avoids it — what a "straighten and auto-crop" gesture would
|
||||
/// apply, and what the crop overlay should offer as its bound.
|
||||
///
|
||||
/// The standard largest-inscribed-rectangle result for a rotated
|
||||
/// rectangle of the same aspect ratio.
|
||||
pub fn max_inscribed_crop(&self, width: u32, height: u32) -> CropRect {
|
||||
if self.angle == 0.0 || width == 0 || height == 0 {
|
||||
return CropRect::default();
|
||||
}
|
||||
|
||||
let (w, h) = if self.swaps_axes() {
|
||||
(height as f32, width as f32)
|
||||
} else {
|
||||
(width as f32, height as f32)
|
||||
};
|
||||
|
||||
let a = (self.angle * PI / 180.0).abs();
|
||||
let (sin, cos) = (a.sin(), a.cos());
|
||||
|
||||
// Longer and shorter side, so the two cases below stay symmetric.
|
||||
let (long, short) = if w >= h { (w, h) } else { (h, w) };
|
||||
|
||||
let (bw, bh) = if short <= 2.0 * sin * cos * long || (sin - cos).abs() < 1e-6 {
|
||||
// Half-constrained: the shorter side alone limits the rectangle.
|
||||
let half = 0.5 * short;
|
||||
if w >= h {
|
||||
(half / sin, half / cos)
|
||||
} else {
|
||||
(half / cos, half / sin)
|
||||
}
|
||||
} else {
|
||||
// Fully constrained by both sides.
|
||||
let cos2 = cos * cos - sin * sin;
|
||||
((w * cos - h * sin) / cos2, (h * cos - w * sin) / cos2)
|
||||
};
|
||||
|
||||
// Back to fractions of the (possibly axis-swapped) frame, centred.
|
||||
let fw = (bw / w).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
let fh = (bh / h).clamp(CropRect::MIN_EXTENT, 1.0);
|
||||
CropRect {
|
||||
x: (1.0 - fw) * 0.5,
|
||||
y: (1.0 - fh) * 0.5,
|
||||
width: fw,
|
||||
height: fh,
|
||||
}
|
||||
.normalised()
|
||||
}
|
||||
|
||||
/// Uniform values the generated prologue reads.
|
||||
///
|
||||
/// A fixed-size block in a fixed slot, like the camera matrix: the
|
||||
/// prologue is emitted whether or not any operation is active, so its
|
||||
/// uniforms cannot be positioned by the op loop.
|
||||
///
|
||||
/// The angle reaches the shader as sin/cos rather than degrees — a trig
|
||||
/// call per pixel would recover a value constant across the dispatch.
|
||||
pub fn uniforms(&self) -> [f32; FRAMING_UNIFORM_FIELDS] {
|
||||
let rad = self.angle * PI / 180.0;
|
||||
[
|
||||
self.crop.x,
|
||||
self.crop.y,
|
||||
self.crop.width,
|
||||
self.crop.height,
|
||||
rad.sin(),
|
||||
rad.cos(),
|
||||
0.0,
|
||||
0.0,
|
||||
]
|
||||
}
|
||||
|
||||
/// The WGSL mapping an output pixel to a **normalised centred** source
|
||||
/// position, ready for the warp chain.
|
||||
///
|
||||
/// Leaves the result in `p`: centre `(0, 0)`, `r == 1` at the corner —
|
||||
/// exactly the space [`crate::warp`] documents, so lens correction
|
||||
/// composes on top of this without either stage naming the other.
|
||||
///
|
||||
/// `aspect` is left in scope alongside it, since the warp chain and the
|
||||
/// sampler both need it to return to texture coordinates.
|
||||
pub fn wgsl_prologue(&self) -> String {
|
||||
let mut s = String::new();
|
||||
|
||||
s.push_str(
|
||||
" // ---- framing ----
|
||||
// Output pixel -> source position, in the normalised centred space the
|
||||
// warp chain expects: the centre is (0, 0) and the radius is 1 at the
|
||||
// corner. Working here rather than in pixels is what makes the map
|
||||
// independent of the resolution being rendered at.
|
||||
let src_dims = textureDimensions(source);
|
||||
let aspect = vec2<f32>(f32(src_dims.x) / f32(src_dims.y), 1.0);
|
||||
var uv = (vec2<f32>(gid.xy) + vec2<f32>(0.5)) / vec2<f32>(dims);
|
||||
",
|
||||
);
|
||||
|
||||
if !self.is_active() {
|
||||
// Neutral framing still has to produce `p`, since the warp chain
|
||||
// and the sampler read it either way. It is only the crop,
|
||||
// rotation and flip steps that vanish.
|
||||
s.push_str(
|
||||
"
|
||||
// Framing is neutral: the whole frame, unrotated.
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
",
|
||||
);
|
||||
return s;
|
||||
}
|
||||
|
||||
s.push_str(
|
||||
"
|
||||
// Into the crop rect.
|
||||
uv = u.crop_rect.xy + uv * u.crop_rect.zw;
|
||||
var p = (uv - vec2<f32>(0.5)) * aspect;
|
||||
",
|
||||
);
|
||||
|
||||
if self.angle != 0.0 {
|
||||
// Done in the aspect-corrected space, which is the whole reason
|
||||
// `p` is scaled by `aspect` above: a rotation applied to raw 0..1
|
||||
// coordinates on a non-square image shears it rather than
|
||||
// turning it, and that reads as a rendering fault.
|
||||
s.push_str(
|
||||
"
|
||||
// Straighten, about the frame centre.
|
||||
p = vec2<f32>(
|
||||
p.x * u.framing_angle.y - p.y * u.framing_angle.x,
|
||||
p.x * u.framing_angle.x + p.y * u.framing_angle.y,
|
||||
);
|
||||
",
|
||||
);
|
||||
}
|
||||
|
||||
if self.quarter_turns != 0 {
|
||||
// An exact coordinate permutation rather than a rotation through
|
||||
// the matrix above, which would resample a transform that has an
|
||||
// exact answer. Applied to `p`, so the aspect scaling has to be
|
||||
// undone and reapplied across the swap.
|
||||
let permutation = match self.quarter_turns {
|
||||
1 => " p = vec2<f32>(p.y * aspect.x, -p.x / aspect.x);",
|
||||
2 => " p = -p;",
|
||||
_ => " p = vec2<f32>(-p.y * aspect.x, p.x / aspect.x);",
|
||||
};
|
||||
let _ = write!(
|
||||
s,
|
||||
"
|
||||
// {}° clockwise — an exact permutation, so nothing is resampled.
|
||||
{permutation}
|
||||
",
|
||||
u32::from(self.quarter_turns) * 90
|
||||
);
|
||||
}
|
||||
|
||||
if self.flip_h {
|
||||
s.push_str(" p.x = -p.x;\n");
|
||||
}
|
||||
if self.flip_v {
|
||||
s.push_str(" p.y = -p.y;\n");
|
||||
}
|
||||
|
||||
s
|
||||
}
|
||||
|
||||
/// Identifies this framing's *structure* — which branches the prologue
|
||||
/// generates, not the values it reads.
|
||||
///
|
||||
/// Deliberately coarse, for the reason the operation hash is: dragging
|
||||
/// the crop handles or the straighten slider must reuse the compiled
|
||||
/// pipeline and upload uniforms only. Only the presence of each
|
||||
/// transform, never its magnitude, may enter this.
|
||||
pub fn structure_key(&self) -> u64 {
|
||||
u64::from(!self.crop.is_full())
|
||||
| u64::from(self.angle != 0.0) << 1
|
||||
| u64::from(self.flip_h) << 2
|
||||
| u64::from(self.flip_v) << 3
|
||||
| u64::from(self.quarter_turns) << 4
|
||||
}
|
||||
}
|
||||
|
||||
/// Floats the framing block occupies in the generated uniform struct.
|
||||
///
|
||||
/// Two `vec4`s: the crop rect, and the angle's sin/cos with padding.
|
||||
pub const FRAMING_UNIFORM_FIELDS: usize = 8;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_fresh_framing_is_neutral() {
|
||||
// The invariant behind "opening an image shows the image".
|
||||
let f = Framing::new();
|
||||
assert!(!f.is_active());
|
||||
assert!(!f.needs_interpolation());
|
||||
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_framing_still_produces_a_position_for_the_warp_chain() {
|
||||
// The prologue always defines `p` and `aspect`, active or not — the
|
||||
// warp chain and the sampler read them either way, so a neutral
|
||||
// framing that skipped them would fail to compile rather than
|
||||
// rendering an unframed image.
|
||||
let src = Framing::new().wgsl_prologue();
|
||||
assert!(src.contains("var p ="), "{src}");
|
||||
assert!(src.contains("let aspect ="), "{src}");
|
||||
// ...but none of the transform steps.
|
||||
assert!(!src.contains("crop_rect"));
|
||||
assert!(!src.contains("framing_angle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_framing_reads_the_crop_rect() {
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert!(f.wgsl_prologue().contains("u.crop_rect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cropping_changes_the_output_size() {
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.25,
|
||||
y: 0.25,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert!(f.is_active());
|
||||
assert_eq!(f.output_size(1000, 800), (500, 400));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_swaps_the_output_axes() {
|
||||
// What makes a landscape frame come out portrait: the output is
|
||||
// genuinely taller than it is wide.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
assert_eq!(f.output_size(6000, 4000), (4000, 6000));
|
||||
|
||||
f.rotate_quarters(1);
|
||||
assert_eq!(f.output_size(6000, 4000), (6000, 4000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_applies_within_the_rotated_frame() {
|
||||
// Half of a rotated frame must be half of the *rotated* dimensions,
|
||||
// or a crop drawn on screen after a rotation lands somewhere else.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
f.set_crop(CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 1.0,
|
||||
});
|
||||
assert_eq!(f.output_size(6000, 4000), (2000, 6000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_turns_wrap_in_both_directions() {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(-1);
|
||||
assert_eq!(f.quarter_turns(), 3);
|
||||
f.rotate_quarters(1);
|
||||
assert_eq!(f.quarter_turns(), 0);
|
||||
f.rotate_quarters(7);
|
||||
assert_eq!(f.quarter_turns(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_cannot_be_driven_degenerate() {
|
||||
// A zero-extent crop produces a zero-sized texture, which is a device
|
||||
// error rather than a visibly silly image.
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
});
|
||||
let (w, h) = f.output_size(1000, 1000);
|
||||
assert!(w >= 1 && h >= 1);
|
||||
assert!(f.crop().width >= CropRect::MIN_EXTENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crop_pushed_past_the_edge_stays_inside() {
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.8,
|
||||
y: 0.9,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
let c = f.crop();
|
||||
assert!(c.x + c.width <= 1.0 + 1e-6, "{c:?} extends past the edge");
|
||||
assert!(c.y + c.height <= 1.0 + 1e-6, "{c:?} extends past the edge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nan_crop_falls_back_rather_than_producing_a_zero_texture() {
|
||||
// Worse than a wrong image: `NaN as u32` is 0, and a zero-sized
|
||||
// texture is a device error.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(CROP_W, f32::NAN);
|
||||
f.set_param(CROP_X, f32::INFINITY);
|
||||
let (w, h) = f.output_size(1000, 1000);
|
||||
assert!(w >= 1 && h >= 1);
|
||||
assert!(f.crop().width.is_finite() && f.crop().x.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_size_rounds_rather_than_truncating() {
|
||||
// Truncation biases every crop smaller; half of 101 should be 51.
|
||||
let mut f = Framing::new();
|
||||
f.set_crop(CropRect {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert_eq!(f.output_size(101, 101), (51, 51));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quarter_turns_and_flips_need_no_interpolation() {
|
||||
// Why 90° steps are handled apart from the free angle: they have an
|
||||
// exact answer and must not be resampled.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
f.set_param(FLIP_H, 1.0);
|
||||
assert!(f.is_active());
|
||||
assert!(!f.needs_interpolation());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_free_angle_needs_interpolation() {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 1.5);
|
||||
assert!(f.needs_interpolation());
|
||||
assert!(f.wgsl_prologue().contains("u.framing_angle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quarter_turn_corrects_for_aspect_across_the_swap() {
|
||||
// `p` is scaled by the source aspect, so a permutation that exchanges
|
||||
// the axes has to undo and reapply it. Without that a 90° turn on a
|
||||
// 3:2 frame comes out stretched.
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(1);
|
||||
assert!(f.wgsl_prologue().contains("aspect.x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_structure_key_ignores_magnitudes() {
|
||||
// What the pipeline cache depends on: dragging the straighten slider
|
||||
// or the crop handles must not recompile.
|
||||
let mut a = Framing::new();
|
||||
a.set_param(ANGLE, 1.0);
|
||||
let mut b = Framing::new();
|
||||
b.set_param(ANGLE, 4.0);
|
||||
assert_eq!(a.structure_key(), b.structure_key());
|
||||
assert_eq!(a.wgsl_prologue(), b.wgsl_prologue());
|
||||
assert_ne!(a.uniforms(), b.uniforms());
|
||||
|
||||
let mut c = Framing::new();
|
||||
c.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
let mut d = Framing::new();
|
||||
d.set_crop(CropRect {
|
||||
x: 0.2,
|
||||
y: 0.2,
|
||||
width: 0.4,
|
||||
height: 0.4,
|
||||
});
|
||||
assert_eq!(c.structure_key(), d.structure_key());
|
||||
assert_eq!(c.wgsl_prologue(), d.wgsl_prologue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_transforms_take_different_structure_keys() {
|
||||
// The other half of the cache contract: framing that generates
|
||||
// different code must not reuse another's pipeline.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
seen.insert(Framing::new().structure_key());
|
||||
|
||||
let mut cropped = Framing::new();
|
||||
cropped.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.5,
|
||||
height: 0.5,
|
||||
});
|
||||
assert!(seen.insert(cropped.structure_key()));
|
||||
|
||||
let mut angled = Framing::new();
|
||||
angled.set_param(ANGLE, 2.0);
|
||||
assert!(seen.insert(angled.structure_key()));
|
||||
|
||||
let mut flipped = Framing::new();
|
||||
flipped.set_param(FLIP_H, 1.0);
|
||||
assert!(seen.insert(flipped.structure_key()));
|
||||
|
||||
for turns in 1..=3 {
|
||||
let mut f = Framing::new();
|
||||
f.rotate_quarters(turns);
|
||||
assert!(seen.insert(f.structure_key()), "{turns} quarter turns");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_round_trip() {
|
||||
let mut f = Framing::new();
|
||||
for (id, v) in [
|
||||
(ANGLE, 2.5),
|
||||
(ROTATION, 2.0),
|
||||
(FLIP_H, 1.0),
|
||||
(FLIP_V, 1.0),
|
||||
(CROP_X, 0.1),
|
||||
(CROP_Y, 0.2),
|
||||
(CROP_W, 0.5),
|
||||
(CROP_H, 0.4),
|
||||
] {
|
||||
f.set_param(id, v);
|
||||
assert_eq!(f.param(id), v, "{id} did not round-trip");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_leaves_the_stage_neutral() {
|
||||
// The same contract the operations honour, checked against the
|
||||
// descriptor rather than a literal.
|
||||
let mut f = Framing::new();
|
||||
for p in DESCRIPTOR.params {
|
||||
f.set_param(p.id, p.default);
|
||||
}
|
||||
assert!(!f.is_active(), "descriptor defaults must be neutral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_default_is_within_its_declared_range() {
|
||||
for p in DESCRIPTOR.params {
|
||||
assert_eq!(p.clamp(p.default), p.default, "{} is out of range", p.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_parameter_is_declared_twice() {
|
||||
let mut ids: Vec<&str> = DESCRIPTOR.params.iter().map(|p| p.id.0).collect();
|
||||
let before = ids.len();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
assert_eq!(before, ids.len(), "framing has a duplicate parameter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_neutral() {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 3.0);
|
||||
f.rotate_quarters(1);
|
||||
f.set_crop(CropRect {
|
||||
x: 0.1,
|
||||
y: 0.1,
|
||||
width: 0.3,
|
||||
height: 0.3,
|
||||
});
|
||||
assert!(f.is_active());
|
||||
|
||||
f.reset();
|
||||
assert!(!f.is_active());
|
||||
assert_eq!(f.wgsl_prologue(), Framing::new().wgsl_prologue());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniforms_carry_the_angle_as_sin_and_cos() {
|
||||
// The shader never sees degrees: converting here keeps a trig call
|
||||
// out of every pixel.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, 90.0);
|
||||
let u = f.uniforms();
|
||||
assert!(
|
||||
(u[4] - 1.0).abs() < 1e-6,
|
||||
"sin(90°) should be 1, got {}",
|
||||
u[4]
|
||||
);
|
||||
assert!(u[5].abs() < 1e-6, "cos(90°) should be 0, got {}", u[5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniforms_are_always_finite() {
|
||||
// One NaN in the uniform block blanks every pixel.
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, f32::NAN);
|
||||
f.set_param(CROP_W, f32::NAN);
|
||||
assert!(
|
||||
f.uniforms().iter().all(|v| v.is_finite()),
|
||||
"{:?}",
|
||||
f.uniforms()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_block_is_vec4_aligned() {
|
||||
// Emitted as whole `vec4`s; a size not divisible by four would
|
||||
// misalign every operation uniform that follows it.
|
||||
assert_eq!(FRAMING_UNIFORM_FIELDS % 4, 0);
|
||||
assert_eq!(Framing::new().uniforms().len(), FRAMING_UNIFORM_FIELDS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_of_an_unrotated_image_is_the_whole_frame() {
|
||||
assert!(Framing::new().max_inscribed_crop(6000, 4000).is_full());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_shrinks_as_the_angle_grows() {
|
||||
// Straightening further must cut in further; anything else leaves
|
||||
// undefined corners inside the frame.
|
||||
let mut small = Framing::new();
|
||||
small.set_param(ANGLE, 2.0);
|
||||
let mut large = Framing::new();
|
||||
large.set_param(ANGLE, 10.0);
|
||||
|
||||
let a = small.max_inscribed_crop(6000, 4000);
|
||||
let b = large.max_inscribed_crop(6000, 4000);
|
||||
assert!(a.width > b.width, "{} should exceed {}", a.width, b.width);
|
||||
assert!(a.width < 1.0, "a rotated frame cannot keep its full width");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_is_centred_and_inside_the_frame() {
|
||||
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -7.5] {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, angle);
|
||||
for (w, h) in [(6000u32, 4000u32), (4000, 6000), (3000, 3000)] {
|
||||
let c = f.max_inscribed_crop(w, h);
|
||||
assert!(
|
||||
c.width > 0.0 && c.height > 0.0,
|
||||
"{angle}° on {w}x{h}: {c:?} is degenerate"
|
||||
);
|
||||
assert!(
|
||||
c.x + c.width <= 1.0 + 1e-4 && c.y + c.height <= 1.0 + 1e-4,
|
||||
"{angle}° on {w}x{h}: {c:?} extends past the frame"
|
||||
);
|
||||
assert!(
|
||||
((c.x + c.width * 0.5) - 0.5).abs() < 1e-4,
|
||||
"{angle}° on {w}x{h}: {c:?} is not centred"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_inscribed_crop_contains_no_undefined_area() {
|
||||
// The property the derivation exists for, checked directly: every
|
||||
// corner of the inscribed rect, mapped through the same transform the
|
||||
// shader applies, must land inside the source.
|
||||
for angle in [1.0f32, 5.0, 15.0, 30.0, 45.0, -12.0] {
|
||||
let mut f = Framing::new();
|
||||
f.set_param(ANGLE, angle);
|
||||
let (w, h) = (6000.0f32, 4000.0f32);
|
||||
let c = f.max_inscribed_crop(6000, 4000);
|
||||
|
||||
let rad = angle * PI / 180.0;
|
||||
let (sn, cs) = (rad.sin(), rad.cos());
|
||||
let aspect = w / h;
|
||||
|
||||
for (fx, fy) in [
|
||||
(c.x, c.y),
|
||||
(c.x + c.width, c.y),
|
||||
(c.x, c.y + c.height),
|
||||
(c.x + c.width, c.y + c.height),
|
||||
] {
|
||||
let (px, py) = ((fx - 0.5) * aspect, fy - 0.5);
|
||||
let (rx, ry) = (px * cs - py * sn, px * sn + py * cs);
|
||||
let (ux, uy) = (rx / aspect + 0.5, ry + 0.5);
|
||||
assert!(
|
||||
(-1e-3..=1.0 + 1e-3).contains(&ux) && (-1e-3..=1.0 + 1e-3).contains(&uy),
|
||||
"{angle}°: corner ({fx}, {fy}) maps to ({ux}, {uy}), outside the source"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+129
-31
@@ -8,7 +8,8 @@
|
||||
//! so reordering the pipeline needs no code change.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
|
||||
use crate::operation::{compose, ComposedShader, Operation};
|
||||
use crate::framing::{CropRect, Framing};
|
||||
use crate::operation::{compose_with_framing, ComposedShader, Operation};
|
||||
use crate::ops;
|
||||
|
||||
/// TRACES: FR-DEV-3a
|
||||
@@ -52,9 +53,16 @@ impl ParamCapability {
|
||||
}
|
||||
}
|
||||
|
||||
/// An ordered pipeline of operations.
|
||||
/// An ordered pipeline of operations, plus how the result is framed.
|
||||
pub struct EditGraph {
|
||||
ops: Vec<Box<dyn Operation>>,
|
||||
/// Crop, straighten, rotation and flips.
|
||||
///
|
||||
/// Held apart from `ops` rather than in the list because it is not one:
|
||||
/// an operation transforms a colour, and framing decides which source
|
||||
/// pixel that colour is read from — and changes the output's dimensions,
|
||||
/// which no colour operation can do. See [`crate::framing`].
|
||||
framing: Framing,
|
||||
}
|
||||
|
||||
impl EditGraph {
|
||||
@@ -70,17 +78,49 @@ impl EditGraph {
|
||||
ops: vec![
|
||||
Box::new(ops::WhiteBalance::new()),
|
||||
Box::new(ops::Exposure::new()),
|
||||
Box::new(ops::Contrast::new()),
|
||||
Box::new(ops::HighlightsShadows::new()),
|
||||
Box::new(ops::BlacksWhites::new()),
|
||||
Box::new(ops::Brilliance::new()),
|
||||
Box::new(ops::Vibrance::new()),
|
||||
Box::new(ops::Saturation::new()),
|
||||
// The mixer comes last: it is the finishing control, and it
|
||||
// should act on the tones the user has already settled.
|
||||
Box::new(ops::ColourMixer::new()),
|
||||
],
|
||||
framing: Framing::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Descriptors for every operation, in order. Drives panel generation
|
||||
/// (FR-DEV-3a).
|
||||
/// The framing — crop, straighten, rotation and flips.
|
||||
///
|
||||
/// Reached directly rather than through `set_param` because the crop is a
|
||||
/// rectangle, and driving one through four independent scalars makes an
|
||||
/// interactive drag four clamps that can disagree. The parameter route
|
||||
/// still exists for the sidecar, which has only scalars to work with.
|
||||
pub fn framing(&self) -> &Framing {
|
||||
&self.framing
|
||||
}
|
||||
|
||||
pub fn framing_mut(&mut self) -> &mut Framing {
|
||||
&mut self.framing
|
||||
}
|
||||
|
||||
/// The size this graph renders to, given a source of `(w, h)`.
|
||||
///
|
||||
/// Cropping and quarter turns change it, so the caller allocating the
|
||||
/// output texture must ask rather than assume the source size.
|
||||
pub fn output_size(&self, width: u32, height: u32) -> (u32, u32) {
|
||||
self.framing.output_size(width, height)
|
||||
}
|
||||
|
||||
/// Descriptors for every operation, in order.
|
||||
///
|
||||
/// Operations only — framing is not one, and is reached through
|
||||
/// [`Self::framing`] or the capability list. The distinction matters here
|
||||
/// because this is what the codegen tests count `---- ` shader blocks
|
||||
/// against, and framing generates a prologue rather than a colour block.
|
||||
/// A UI wanting everything should read [`Self::capabilities`] (FR-DEV-3a).
|
||||
pub fn descriptors(&self) -> Vec<&'static OpDescriptor> {
|
||||
self.ops.iter().map(|o| o.descriptor()).collect()
|
||||
}
|
||||
@@ -100,28 +140,47 @@ impl EditGraph {
|
||||
/// step, and so reopening an edited image shows where the sliders
|
||||
/// actually are.
|
||||
pub fn capabilities(&self) -> Vec<OpCapability> {
|
||||
self.ops
|
||||
.iter()
|
||||
.map(|op| {
|
||||
let desc = op.descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
active: op.is_active(),
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: op.param(p.id),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
let ops = self.ops.iter().map(|op| {
|
||||
let desc = op.descriptor();
|
||||
OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
active: op.is_active(),
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: op.param(p.id),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
});
|
||||
|
||||
// Framing last, matching where it sits in the pipeline: the crop is
|
||||
// decided after the image looks right, not before.
|
||||
let desc = self.framing.descriptor();
|
||||
let framing = OpCapability {
|
||||
id: desc.id,
|
||||
label: desc.label,
|
||||
active: self.framing.is_active(),
|
||||
params: desc
|
||||
.params
|
||||
.iter()
|
||||
.map(|p| ParamCapability {
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
kind: p.kind.clone(),
|
||||
default: p.default,
|
||||
value: self.framing.param(p.id),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
ops.chain(std::iter::once(framing)).collect()
|
||||
}
|
||||
|
||||
/// Set a parameter, clamping to the descriptor's declared range.
|
||||
@@ -130,6 +189,15 @@ impl EditGraph {
|
||||
/// has to defend against an out-of-range value, and a corrupt sidecar
|
||||
/// cannot reach a shader.
|
||||
pub fn set_param(&mut self, op: OpId, param: ParamId, value: f32) {
|
||||
if op == crate::framing::ID {
|
||||
let Some(desc) = self.framing.descriptor().param(param) else {
|
||||
log::warn!("unknown parameter {param} on {op}; ignoring");
|
||||
return;
|
||||
};
|
||||
self.framing.set_param(param, desc.clamp(value));
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(operation) = self.ops.iter_mut().find(|o| o.descriptor().id == op) else {
|
||||
// A sidecar naming an operation this build does not have. The
|
||||
// rest of the edit must still apply.
|
||||
@@ -148,29 +216,51 @@ impl EditGraph {
|
||||
|
||||
/// Read a parameter back.
|
||||
pub fn param(&self, op: OpId, param: ParamId) -> Option<f32> {
|
||||
if op == crate::framing::ID {
|
||||
return self
|
||||
.framing
|
||||
.descriptor()
|
||||
.param(param)
|
||||
.map(|_| self.framing.param(param));
|
||||
}
|
||||
self.ops
|
||||
.iter()
|
||||
.find(|o| o.descriptor().id == op)
|
||||
.map(|o| o.param(param))
|
||||
}
|
||||
|
||||
/// Reset every parameter of every operation to its default.
|
||||
/// Reset every parameter of every operation, and the framing, to default.
|
||||
pub fn reset(&mut self) {
|
||||
for op in &mut self.ops {
|
||||
for p in op.descriptor().params {
|
||||
op.set_param(p.id, p.default);
|
||||
}
|
||||
}
|
||||
self.framing.reset();
|
||||
}
|
||||
|
||||
/// Whether any operation currently changes the image.
|
||||
/// Set the crop rectangle. Clamped to keep it inside the frame.
|
||||
pub fn set_crop(&mut self, rect: CropRect) {
|
||||
self.framing.set_crop(rect);
|
||||
}
|
||||
|
||||
pub fn crop(&self) -> CropRect {
|
||||
self.framing.crop()
|
||||
}
|
||||
|
||||
/// Rotate by quarter turns, wrapping. The rotate-left/right buttons.
|
||||
pub fn rotate_quarters(&mut self, turns: i32) {
|
||||
self.framing.rotate_quarters(turns);
|
||||
}
|
||||
|
||||
/// Whether any operation, or the framing, currently changes the image.
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
!self.ops.iter().any(|o| o.is_active())
|
||||
!self.ops.iter().any(|o| o.is_active()) && !self.framing.is_active()
|
||||
}
|
||||
|
||||
/// Generate the fused shader for the current state.
|
||||
pub fn compose(&self) -> ComposedShader {
|
||||
compose(&self.ops)
|
||||
compose_with_framing(&self.ops, &self.framing)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -406,8 +496,16 @@ mod tests {
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(rendered.len(), 10, "seven operations, ten parameters");
|
||||
// Counted from the chain, not a literal: this test must not need
|
||||
// editing when an operation is added, or it would be asserting the
|
||||
// opposite of what it claims.
|
||||
let expected: usize = g.capabilities().iter().map(|c| c.params.len()).sum();
|
||||
assert_eq!(rendered.len(), expected);
|
||||
assert!(rendered.iter().all(|r| !r.is_empty()));
|
||||
assert!(
|
||||
expected > 40,
|
||||
"the chain should now carry the mixer's 36 parameters too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+102
-6
@@ -32,6 +32,7 @@
|
||||
//! data neither would be physically meaningful (ARCH §5.2).
|
||||
|
||||
pub mod descriptor;
|
||||
pub mod framing;
|
||||
pub mod graph;
|
||||
pub mod operation;
|
||||
pub mod ops;
|
||||
@@ -39,8 +40,11 @@ pub mod ops;
|
||||
pub use descriptor::{
|
||||
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, ParamKind, Scale, Unit,
|
||||
};
|
||||
pub use framing::{CropRect, Framing};
|
||||
pub use graph::{EditGraph, OpCapability, ParamCapability};
|
||||
pub use operation::{compose, Affects, ComposedShader, Helper, Operation, Uniform};
|
||||
pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Operation, Uniform,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -67,10 +71,12 @@ mod tests {
|
||||
let g = fully_active();
|
||||
assert!(!g.is_neutral());
|
||||
let shader = g.compose();
|
||||
// Counted against the chain rather than a literal, so adding an
|
||||
// operation does not require editing this test.
|
||||
assert_eq!(
|
||||
shader.source.matches("---- ").count(),
|
||||
7,
|
||||
"all seven operations should appear"
|
||||
g.descriptors().len(),
|
||||
"every operation in the chain should appear"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,16 +154,106 @@ mod tests {
|
||||
#[test]
|
||||
fn generated_uniform_names_are_valid_wgsl_identifiers() {
|
||||
let shader = fully_active().compose();
|
||||
for line in shader.source.lines() {
|
||||
|
||||
// Only the `struct Params` block. Scanning the whole source picks up
|
||||
// helper *signatures* such as `fn contrast_curve(x: f32, ...)`, whose
|
||||
// parameters are not uniform declarations at all.
|
||||
let body = shader
|
||||
.source
|
||||
.split_once("struct Params {")
|
||||
.expect("a uniform struct is always generated")
|
||||
.1
|
||||
.split_once('}')
|
||||
.expect("the struct is closed")
|
||||
.0;
|
||||
|
||||
let mut checked = 0;
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
let Some((name, _)) = trimmed.split_once(": f32,") else {
|
||||
if trimmed.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
let Some((name, _)) = trimmed.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let name = name.trim();
|
||||
assert!(
|
||||
name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
!name.is_empty()
|
||||
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
|
||||
&& !name.starts_with(|c: char| c.is_ascii_digit()),
|
||||
"{name} is not a valid WGSL identifier"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "the struct should declare fields");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_fragment_declares_a_wgsl_reserved_keyword() {
|
||||
// Caught the hard way: `let target = ...` in the contrast fragment
|
||||
// failed to compile with "name `target` is a reserved keyword", and
|
||||
// the error pointed at generated source rather than at the operation
|
||||
// that wrote it. Checking here names the culprit directly.
|
||||
//
|
||||
// Not the full reserved list — the ones a colour operation would
|
||||
// plausibly reach for.
|
||||
const RESERVED: &[&str] = &[
|
||||
"target",
|
||||
"sample",
|
||||
"filter",
|
||||
"texture",
|
||||
"buffer",
|
||||
"binding",
|
||||
"const",
|
||||
"enum",
|
||||
"mat",
|
||||
"vec",
|
||||
"ptr",
|
||||
"ref",
|
||||
"shared",
|
||||
"static",
|
||||
"typedef",
|
||||
"union",
|
||||
"unless",
|
||||
"handle",
|
||||
"layout",
|
||||
"packed",
|
||||
"premerge",
|
||||
"regardless",
|
||||
"typedef",
|
||||
"active",
|
||||
"do",
|
||||
"enum",
|
||||
"input",
|
||||
"output",
|
||||
"private",
|
||||
"resource",
|
||||
"restrict",
|
||||
"self",
|
||||
"std",
|
||||
"where",
|
||||
];
|
||||
|
||||
let g = EditGraph::default_chain();
|
||||
for cap in g.capabilities() {
|
||||
// Activate the whole operation so its fragment is emitted.
|
||||
let mut probe = EditGraph::default_chain();
|
||||
for p in &cap.params {
|
||||
if let ParamKind::Scalar { max, .. } = p.kind {
|
||||
probe.set_param(cap.id, p.id, max * 0.5);
|
||||
}
|
||||
}
|
||||
let source = probe.compose().source;
|
||||
|
||||
for keyword in RESERVED {
|
||||
let declaration = format!("let {keyword} ");
|
||||
let var_declaration = format!("var {keyword} ");
|
||||
assert!(
|
||||
!source.contains(&declaration) && !source.contains(&var_declaration),
|
||||
"{} declares `{keyword}`, which is a WGSL reserved keyword",
|
||||
cap.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
use crate::framing::{Framing, FRAMING_UNIFORM_FIELDS};
|
||||
|
||||
/// What an operation's parameters affect, for cache invalidation scoping.
|
||||
///
|
||||
@@ -131,7 +132,19 @@ const BASE_UNIFORM_FIELDS: usize = 16;
|
||||
///
|
||||
/// Inactive operations are skipped entirely — they contribute no code, no
|
||||
/// uniforms, and nothing to the structure hash.
|
||||
///
|
||||
/// Equivalent to [`compose_with_framing`] with neutral framing.
|
||||
pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
|
||||
compose_with_framing(ops, &Framing::new())
|
||||
}
|
||||
|
||||
/// Compose operations and framing into a single compute shader.
|
||||
///
|
||||
/// Framing generates the shader's **prologue** — the map from an output pixel
|
||||
/// back to a source position — where [`compose`] would emit a fixed identity
|
||||
/// scale. The fused-dispatch property is unaffected: a cropped, straightened
|
||||
/// edit with three adjustments is still one dispatch, one read, one write.
|
||||
pub fn compose_with_framing(ops: &[Box<dyn Operation>], framing: &Framing) -> ComposedShader {
|
||||
let active: Vec<&dyn Operation> = ops
|
||||
.iter()
|
||||
.map(|o| o.as_ref())
|
||||
@@ -157,6 +170,18 @@ pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
|
||||
);
|
||||
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
|
||||
|
||||
// Framing's block follows the base one at a fixed offset, for the same
|
||||
// reason: the prologue is emitted whether or not any operation is active,
|
||||
// so these slots cannot be positioned by the op loop below.
|
||||
uniform_fields.push_str(
|
||||
" // Framing: the crop rect (origin, extent) and the straightening\n\
|
||||
\x20 // angle as sin/cos — a trig call per pixel would recompute a\n\
|
||||
\x20 // value that is constant across the dispatch.\n\
|
||||
\x20 crop_rect: vec4<f32>,\n\
|
||||
\x20 framing_angle: vec4<f32>,\n",
|
||||
);
|
||||
uniform_values.extend_from_slice(&framing.uniforms());
|
||||
|
||||
for op in &active {
|
||||
let id = op.descriptor().id.0;
|
||||
let prefix = sanitise(id);
|
||||
@@ -206,6 +231,19 @@ pub fn compose(ops: &[Box<dyn Operation>]) -> ComposedShader {
|
||||
let _ = writeln!(helper_src, "{}\n", h.source.trim_end());
|
||||
}
|
||||
|
||||
// The coordinate stage: output pixel -> source position -> colour. Emitted
|
||||
// ahead of the operation fragments, which receive the sampled `c`.
|
||||
let prologue = format!(
|
||||
"{}\n{}",
|
||||
framing.wgsl_prologue(),
|
||||
sample_source(framing.needs_interpolation())
|
||||
);
|
||||
let sampler_helper = if framing.needs_interpolation() {
|
||||
BILINEAR_HELPER
|
||||
} else {
|
||||
""
|
||||
};
|
||||
|
||||
let source = format!(
|
||||
"// GENERATED — do not edit.
|
||||
//
|
||||
@@ -221,7 +259,7 @@ struct Params {{
|
||||
@group(0) @binding(1) var<uniform> u: Params;
|
||||
@group(0) @binding(2) var output: texture_storage_2d<rgba8unorm, write>;
|
||||
|
||||
{helper_src}// Linear sRGB to the display transfer function.
|
||||
{sampler_helper}{helper_src}// Linear sRGB to the display transfer function.
|
||||
//
|
||||
// The one place quantisation happens: everything above runs in linear f16,
|
||||
// and this is the final encode (ARCH §5.2).
|
||||
@@ -238,14 +276,7 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
return;
|
||||
}}
|
||||
|
||||
// Source is the demosaiced image: linear, scene-referred, camera space.
|
||||
let src_dims = textureDimensions(source);
|
||||
let coord = vec2<i32>(
|
||||
i32(gid.x * src_dims.x / dims.x),
|
||||
i32(gid.y * src_dims.y / dims.y),
|
||||
);
|
||||
var c = textureLoad(source, coord, 0).rgb;
|
||||
|
||||
{prologue}
|
||||
// As-shot white balance. Applied unconditionally, before any operation,
|
||||
// because it is part of *interpreting* the sensor rather than an edit: a
|
||||
// Bayer sensor's green photosites collect far more signal than its red
|
||||
@@ -271,7 +302,10 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
active.len()
|
||||
);
|
||||
|
||||
let structure_hash = hash_structure(&active);
|
||||
// Framing enters the hash by structure only — which branches its prologue
|
||||
// generated, never how far a slider moved. Dragging the crop handles must
|
||||
// reuse the compiled pipeline and re-upload uniforms.
|
||||
let structure_hash = mix(hash_structure(&active), framing.structure_key());
|
||||
|
||||
ComposedShader {
|
||||
source,
|
||||
@@ -280,6 +314,83 @@ fn main(@builtin(global_invocation_id) gid: vec3<u32>) {{
|
||||
}
|
||||
}
|
||||
|
||||
/// The WGSL turning the framed source position `p` into the colour `c`.
|
||||
///
|
||||
/// Split out because it is the join between the coordinate stage and the
|
||||
/// colour stage, and because the choice it makes — an exact integer load, or
|
||||
/// a filtered sample — is the one thing the free-angle case changes.
|
||||
fn sample_source(interpolate: bool) -> &'static str {
|
||||
if interpolate {
|
||||
" // Back to texture coordinates.
|
||||
let uv_src = p / aspect + vec2<f32>(0.5);
|
||||
|
||||
// Outside the source there is no pixel. A straightened frame exposes its
|
||||
// corners; render them black rather than clamping, which would smear an
|
||||
// edge pixel across them.
|
||||
if (any(uv_src < vec2<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
// A free angle puts output pixels between source pixels. Nearest-neighbour
|
||||
// here is what makes a straightened horizon stair-step, so interpolate.
|
||||
var c = sample_bilinear(uv_src, src_dims);
|
||||
"
|
||||
} else {
|
||||
" // Back to texture coordinates.
|
||||
let uv_src = p / aspect + vec2<f32>(0.5);
|
||||
|
||||
// Outside the source there is no pixel — possible once the frame has been
|
||||
// transformed at all. Render it black rather than clamping, which would
|
||||
// smear an edge pixel across the gap.
|
||||
if (any(uv_src < vec2<f32>(0.0)) || any(uv_src >= vec2<f32>(1.0))) {
|
||||
textureStore(output, vec2<i32>(gid.xy), vec4<f32>(0.0, 0.0, 0.0, 1.0));
|
||||
return;
|
||||
}
|
||||
|
||||
// Every output pixel lands on a source pixel, so load it directly: exact,
|
||||
// and with no interpolation to soften detail.
|
||||
let coord = min(vec2<i32>(uv_src * vec2<f32>(src_dims)), vec2<i32>(src_dims) - vec2<i32>(1));
|
||||
var c = textureLoad(source, coord, 0).rgb;
|
||||
"
|
||||
}
|
||||
}
|
||||
|
||||
/// Bilinear sampling against an unfiltered `texture_2d`.
|
||||
///
|
||||
/// Hand-rolled rather than done with a sampler: the source is bound as a plain
|
||||
/// texture, and adding a sampler for the straightening case alone would change
|
||||
/// a bind group layout that every pass shares.
|
||||
const BILINEAR_HELPER: &str = "fn sample_bilinear(uv: vec2<f32>, dims: vec2<u32>) -> vec3<f32> {
|
||||
let last = vec2<i32>(dims) - vec2<i32>(1);
|
||||
|
||||
// Half-texel offset: sample positions are texel *centres*. Without it the
|
||||
// image shifts by half a pixel and every rotation comes out slightly soft.
|
||||
let q = uv * vec2<f32>(dims) - vec2<f32>(0.5);
|
||||
let base = floor(q);
|
||||
let f = q - base;
|
||||
let i0 = clamp(vec2<i32>(base), vec2<i32>(0), last);
|
||||
let i1 = min(i0 + vec2<i32>(1), last);
|
||||
|
||||
let c00 = textureLoad(source, vec2<i32>(i0.x, i0.y), 0).rgb;
|
||||
let c10 = textureLoad(source, vec2<i32>(i1.x, i0.y), 0).rgb;
|
||||
let c01 = textureLoad(source, vec2<i32>(i0.x, i1.y), 0).rgb;
|
||||
let c11 = textureLoad(source, vec2<i32>(i1.x, i1.y), 0).rgb;
|
||||
|
||||
return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y);
|
||||
}
|
||||
|
||||
";
|
||||
|
||||
/// Fold a value into a hash. FNV-1a's mixing step, over eight bytes.
|
||||
fn mix(mut h: u64, value: u64) -> u64 {
|
||||
for byte in value.to_le_bytes() {
|
||||
h ^= u64::from(byte);
|
||||
h = h.wrapping_mul(0x100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// Hash the op-set and order — the structure, not the values.
|
||||
///
|
||||
/// Two edits with the same operations at different slider positions produce
|
||||
@@ -305,13 +416,29 @@ fn hash_structure(active: &[&dyn Operation]) -> u64 {
|
||||
///
|
||||
/// Whole-word matching matters: an operation with uniforms `amount` and
|
||||
/// `amount_hi` must not have the first rewrite corrupt the second.
|
||||
fn rewrite_uniform(src: &str, name: &str, replacement: &str) -> String {
|
||||
///
|
||||
/// Shared with [`crate::warp`], which prefixes its uniforms by the same rule
|
||||
/// and must not diverge from it.
|
||||
/// Comments are skipped. A fragment explaining what `factor` does should not
|
||||
/// have its prose rewritten to `u.saturation_factor` — the generated source
|
||||
/// is meant to be read when a shader fails to compile, and mangled comments
|
||||
/// make that harder rather than easier.
|
||||
pub(crate) fn rewrite_uniform(src: &str, name: &str, replacement: &str) -> String {
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let bytes = src.as_bytes();
|
||||
let mut i = 0;
|
||||
// Tracks whether the cursor sits inside a `//` comment. WGSL fragments
|
||||
// use line comments only, so this needs no block-comment handling.
|
||||
let mut in_comment = false;
|
||||
|
||||
while i < src.len() {
|
||||
if src[i..].starts_with(name) {
|
||||
if bytes[i] == b'\n' {
|
||||
in_comment = false;
|
||||
} else if !in_comment && src[i..].starts_with("//") {
|
||||
in_comment = true;
|
||||
}
|
||||
|
||||
if !in_comment && src[i..].starts_with(name) {
|
||||
let before_ok = i == 0 || !is_ident_byte(bytes[i - 1]);
|
||||
let after = i + name.len();
|
||||
let after_ok = after >= src.len() || !is_ident_byte(bytes[after]);
|
||||
@@ -511,6 +638,33 @@ mod tests {
|
||||
assert_eq!(got, "x = u.p_amount + amount_hi;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewriting_leaves_comments_alone() {
|
||||
// Found in generated source: a comment reading "A factor of 0 is
|
||||
// monochrome" came out as "A u.saturation_factor of 0 is monochrome".
|
||||
// The generated source is what gets read when a shader fails to
|
||||
// compile, so mangling it works against the one time it matters.
|
||||
let got = rewrite_uniform(
|
||||
"// A factor of 0 is monochrome\nc = c * factor;",
|
||||
"factor",
|
||||
"u.op_factor",
|
||||
);
|
||||
assert_eq!(got, "// A factor of 0 is monochrome\nc = c * u.op_factor;");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewriting_resumes_after_a_comment_ends() {
|
||||
let got = rewrite_uniform(
|
||||
"// factor here is prose\nlet x = factor;\n// factor again\n",
|
||||
"factor",
|
||||
"u.p",
|
||||
);
|
||||
assert_eq!(
|
||||
got,
|
||||
"// factor here is prose\nlet x = u.p;\n// factor again\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rewriting_leaves_substrings_alone() {
|
||||
let got = rewrite_uniform("total_amount = 1.0;", "amount", "u.a");
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
//! The colour mixer — twelve hue bands, each with hue, saturation and
|
||||
//! luminance.
|
||||
//!
|
||||
//! The control photographers mean by "per-colour adjustment": pick a colour
|
||||
//! range, then shift its hue, deepen or mute it, or lighten it, without
|
||||
//! touching the rest of the image. Thirty-six parameters in one operation.
|
||||
//!
|
||||
//! # Why bands overlap
|
||||
//!
|
||||
//! Each band has a centre hue and influences colours near it with a weight
|
||||
//! that falls smoothly to zero at its neighbours' centres. A hard assignment
|
||||
//! — "this pixel is orange, that one is yellow" — puts a visible seam through
|
||||
//! any gradient crossing a boundary, and skies and skin are exactly where
|
||||
//! that shows. Overlapping weights mean adjacent bands blend, and a colour
|
||||
//! halfway between two centres receives half of each.
|
||||
//!
|
||||
//! # Why the weights are normalised
|
||||
//!
|
||||
//! With overlap, a pixel's weights sum to more than one, so applying each
|
||||
//! band's gain independently would compound them. The shader normalises, so
|
||||
//! setting every band's saturation to +100 gives the same result as setting
|
||||
//! the global saturation to +100 rather than something far stronger.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
use crate::ops::helpers;
|
||||
|
||||
pub const ID: OpId = OpId("colour_mixer");
|
||||
|
||||
/// The twelve bands, in hue order starting at red.
|
||||
///
|
||||
/// Twelve rather than Lightroom's eight: the extra bands fall between the
|
||||
/// primaries and secondaries, which is where skin (orange-to-red) and
|
||||
/// foliage (yellow-to-green) actually sit, and where eight bands force a
|
||||
/// compromise.
|
||||
pub struct Band {
|
||||
/// Stable id fragment, used to build parameter ids.
|
||||
pub key: &'static str,
|
||||
/// Centre hue in degrees.
|
||||
pub hue: f32,
|
||||
}
|
||||
|
||||
pub static BANDS: [Band; 12] = [
|
||||
Band {
|
||||
key: "red",
|
||||
hue: 0.0,
|
||||
},
|
||||
Band {
|
||||
key: "orange",
|
||||
hue: 30.0,
|
||||
},
|
||||
Band {
|
||||
key: "yellow",
|
||||
hue: 60.0,
|
||||
},
|
||||
Band {
|
||||
key: "chartreuse",
|
||||
hue: 90.0,
|
||||
},
|
||||
Band {
|
||||
key: "green",
|
||||
hue: 120.0,
|
||||
},
|
||||
Band {
|
||||
key: "spring",
|
||||
hue: 150.0,
|
||||
},
|
||||
Band {
|
||||
key: "cyan",
|
||||
hue: 180.0,
|
||||
},
|
||||
Band {
|
||||
key: "azure",
|
||||
hue: 210.0,
|
||||
},
|
||||
Band {
|
||||
key: "blue",
|
||||
hue: 240.0,
|
||||
},
|
||||
Band {
|
||||
key: "violet",
|
||||
hue: 270.0,
|
||||
},
|
||||
Band {
|
||||
key: "magenta",
|
||||
hue: 300.0,
|
||||
},
|
||||
Band {
|
||||
key: "rose",
|
||||
hue: 330.0,
|
||||
},
|
||||
];
|
||||
|
||||
/// The three adjustments each band carries.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Channel {
|
||||
Hue,
|
||||
Saturation,
|
||||
Luminance,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub const ALL: [Channel; 3] = [Channel::Hue, Channel::Saturation, Channel::Luminance];
|
||||
|
||||
pub const fn suffix(self) -> &'static str {
|
||||
match self {
|
||||
Channel::Hue => "hue",
|
||||
Channel::Saturation => "sat",
|
||||
Channel::Luminance => "lum",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parameter descriptors, one per band per channel. Written out rather than
|
||||
// generated because `ParamDescriptor` must be `const` to live in a `static`,
|
||||
// and a const loop cannot build a slice. The macro keeps it honest.
|
||||
macro_rules! band_params {
|
||||
($($key:literal),* $(,)?) => {
|
||||
&[
|
||||
$(
|
||||
ParamDescriptor::amount(
|
||||
concat!($key, "_hue"),
|
||||
concat!("param.mixer.", $key, ".hue"),
|
||||
),
|
||||
ParamDescriptor::amount(
|
||||
concat!($key, "_sat"),
|
||||
concat!("param.mixer.", $key, ".sat"),
|
||||
),
|
||||
ParamDescriptor::amount(
|
||||
concat!($key, "_lum"),
|
||||
concat!("param.mixer.", $key, ".lum"),
|
||||
),
|
||||
)*
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.colour_mixer"),
|
||||
params: band_params![
|
||||
"red",
|
||||
"orange",
|
||||
"yellow",
|
||||
"chartreuse",
|
||||
"green",
|
||||
"spring",
|
||||
"cyan",
|
||||
"azure",
|
||||
"blue",
|
||||
"violet",
|
||||
"magenta",
|
||||
"rose",
|
||||
],
|
||||
};
|
||||
|
||||
static MIXER_HELPERS: &[Helper] = &[
|
||||
helpers::LUMINANCE,
|
||||
Helper {
|
||||
name: "rgb_to_hcl",
|
||||
source: "\
|
||||
// Hue (degrees), chroma, and the max channel, in one pass.
|
||||
//
|
||||
// Not a full HSL conversion: the mixer needs hue to weight the bands and
|
||||
// chroma to know how much colour there is to adjust, and computing lightness
|
||||
// separately from Rec. 709 luminance gives a better-behaved result than
|
||||
// HSL's (max+min)/2.
|
||||
fn rgb_to_hcl(c: vec3<f32>) -> vec3<f32> {
|
||||
let hi = max(c.r, max(c.g, c.b));
|
||||
let lo = min(c.r, min(c.g, c.b));
|
||||
let chroma = hi - lo;
|
||||
|
||||
var hue = 0.0;
|
||||
if (chroma > 0.00001) {
|
||||
if (hi == c.r) {
|
||||
// fract handles the wrap from -60 to 300 without a branch.
|
||||
hue = 60.0 * fract(((c.g - c.b) / chroma) / 6.0 + 1.0) * 6.0 / 6.0;
|
||||
hue = 60.0 * (((c.g - c.b) / chroma) % 6.0);
|
||||
if (hue < 0.0) { hue = hue + 360.0; }
|
||||
} else if (hi == c.g) {
|
||||
hue = 60.0 * (((c.b - c.r) / chroma) + 2.0);
|
||||
} else {
|
||||
hue = 60.0 * (((c.r - c.g) / chroma) + 4.0);
|
||||
}
|
||||
}
|
||||
return vec3<f32>(hue, chroma, hi);
|
||||
}",
|
||||
},
|
||||
Helper {
|
||||
name: "band_weight",
|
||||
source: "\
|
||||
// How strongly a hue belongs to a band centred at `centre`.
|
||||
//
|
||||
// Cosine falloff over +/-60 degrees, so a band reaches zero exactly at its
|
||||
// neighbours' centres and adjacent weights sum to one across the gap. A
|
||||
// narrower window would leave hues between bands unreachable; a wider one
|
||||
// would make every adjustment affect the whole wheel.
|
||||
fn band_weight(hue: f32, centre: f32) -> f32 {
|
||||
// Shortest angular distance, accounting for the wrap at 360.
|
||||
var d = abs(hue - centre);
|
||||
if (d > 180.0) { d = 360.0 - d; }
|
||||
if (d >= 60.0) { return 0.0; }
|
||||
// cos ramp: 1 at the centre, 0 at 60 degrees.
|
||||
return 0.5 + 0.5 * cos(d * 3.14159265 / 60.0);
|
||||
}",
|
||||
},
|
||||
Helper {
|
||||
name: "hue_to_rgb_scale",
|
||||
source: "\
|
||||
// Rebuild a colour after shifting its hue, preserving chroma and level.
|
||||
//
|
||||
// Reconstructing from HSV rather than rotating in RGB: an RGB rotation
|
||||
// matrix desaturates as it turns, which is visible as colours going pale
|
||||
// mid-shift.
|
||||
fn hue_to_rgb_scale(hue: f32, chroma: f32, hi: f32) -> vec3<f32> {
|
||||
let h = fract(hue / 360.0) * 6.0;
|
||||
let x = chroma * (1.0 - abs((h % 2.0) - 1.0));
|
||||
var rgb = vec3<f32>(0.0);
|
||||
if (h < 1.0) { rgb = vec3<f32>(chroma, x, 0.0); }
|
||||
else if (h < 2.0) { rgb = vec3<f32>(x, chroma, 0.0); }
|
||||
else if (h < 3.0) { rgb = vec3<f32>(0.0, chroma, x); }
|
||||
else if (h < 4.0) { rgb = vec3<f32>(0.0, x, chroma); }
|
||||
else if (h < 5.0) { rgb = vec3<f32>(x, 0.0, chroma); }
|
||||
else { rgb = vec3<f32>(chroma, 0.0, x); }
|
||||
return rgb + vec3<f32>(hi - chroma);
|
||||
}",
|
||||
},
|
||||
];
|
||||
|
||||
/// Twelve hue bands, each with hue, saturation and luminance.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ColourMixer {
|
||||
/// `[band][channel]`, matching [`BANDS`] and [`Channel::ALL`].
|
||||
values: [[f32; 3]; 12],
|
||||
}
|
||||
|
||||
impl Default for ColourMixer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
values: [[0.0; 3]; 12],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColourMixer {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The parameter id for one band and channel.
|
||||
///
|
||||
/// Ids are `"<band>_<channel>"`, matching the descriptors above.
|
||||
fn index_of(id: ParamId) -> Option<(usize, usize)> {
|
||||
let (band, channel) = id.0.rsplit_once('_')?;
|
||||
let b = BANDS.iter().position(|x| x.key == band)?;
|
||||
let c = Channel::ALL.iter().position(|x| x.suffix() == channel)?;
|
||||
Some((b, c))
|
||||
}
|
||||
|
||||
/// Whether any band has a non-zero setting.
|
||||
fn any_set(&self) -> bool {
|
||||
self.values.iter().flatten().any(|v| *v != 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for ColourMixer {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match Self::index_of(id) {
|
||||
Some((b, c)) => self.values[b][c] = value,
|
||||
None => log::warn!("colour_mixer: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
Self::index_of(id).map_or(0.0, |(b, c)| self.values[b][c])
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.any_set()
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// Only the bands the user actually touched contribute code. A single
|
||||
// adjusted band therefore costs one weight evaluation rather than
|
||||
// twelve — the composition property applied within an operation.
|
||||
let mut lines = String::from(
|
||||
"\
|
||||
let hcl = rgb_to_hcl(c);
|
||||
let hue = hcl.x;
|
||||
let chroma = hcl.y;
|
||||
let hi = hcl.z;
|
||||
|
||||
// Achromatic pixels have no hue to match, and adjusting them would tint
|
||||
// neutrals — the most visible way a mixer can go wrong.
|
||||
if (chroma > 0.0001) {
|
||||
var w_total = 0.0;
|
||||
var d_hue = 0.0;
|
||||
var d_sat = 0.0;
|
||||
var d_lum = 0.0;
|
||||
",
|
||||
);
|
||||
|
||||
for (b, band) in BANDS.iter().enumerate() {
|
||||
let v = self.values[b];
|
||||
if v.iter().all(|x| *x == 0.0) {
|
||||
continue;
|
||||
}
|
||||
let key = band.key;
|
||||
lines.push_str(&format!(
|
||||
"\n // {key}\n {{\n let w = band_weight(hue, {:.1});\n w_total = w_total + w;\n",
|
||||
band.hue
|
||||
));
|
||||
if v[0] != 0.0 {
|
||||
lines.push_str(&format!(" d_hue = d_hue + w * {key}_hue;\n"));
|
||||
}
|
||||
if v[1] != 0.0 {
|
||||
lines.push_str(&format!(" d_sat = d_sat + w * {key}_sat;\n"));
|
||||
}
|
||||
if v[2] != 0.0 {
|
||||
lines.push_str(&format!(" d_lum = d_lum + w * {key}_lum;\n"));
|
||||
}
|
||||
lines.push_str(" }\n");
|
||||
}
|
||||
|
||||
lines.push_str(
|
||||
"
|
||||
// Normalise by the total weight, so overlapping bands blend rather than
|
||||
// compound. Without this, a hue sitting between two adjusted bands would
|
||||
// receive roughly twice the intended adjustment.
|
||||
if (w_total > 0.0001) {
|
||||
d_hue = d_hue / w_total;
|
||||
d_sat = d_sat / w_total;
|
||||
d_lum = d_lum / w_total;
|
||||
|
||||
// Hue: up to 30 degrees at full travel. Enough to move foliage from
|
||||
// yellow-green to green, not enough to turn it blue by accident.
|
||||
let new_hue = hue + d_hue * 30.0;
|
||||
|
||||
// Saturation scales chroma; luminance scales the whole colour.
|
||||
let new_chroma = clamp(chroma * (1.0 + d_sat), 0.0, hi);
|
||||
c = hue_to_rgb_scale(new_hue, new_chroma, hi);
|
||||
c = c * exp2(d_lum);
|
||||
}
|
||||
}
|
||||
c = max(c, vec3<f32>(0.0));",
|
||||
);
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
// Only the bands that contributed code declare uniforms, and in the
|
||||
// same order the fragment references them.
|
||||
let mut out = Vec::new();
|
||||
for b in 0..BANDS.len() {
|
||||
let v = self.values[b];
|
||||
if v.iter().all(|x| *x == 0.0) {
|
||||
continue;
|
||||
}
|
||||
// Names must match those the fragment emitted.
|
||||
if v[0] != 0.0 {
|
||||
out.push(Uniform {
|
||||
name: HUE_NAMES[b],
|
||||
value: v[0] / 100.0,
|
||||
});
|
||||
}
|
||||
if v[1] != 0.0 {
|
||||
out.push(Uniform {
|
||||
name: SAT_NAMES[b],
|
||||
value: v[1] / 100.0,
|
||||
});
|
||||
}
|
||||
if v[2] != 0.0 {
|
||||
out.push(Uniform {
|
||||
name: LUM_NAMES[b],
|
||||
// Up to half a stop per band.
|
||||
value: v[2] / 100.0 * 0.5,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
MIXER_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
// Uniform names must be `&'static str`, and they are built from the band
|
||||
// keys. Declared as tables rather than formatted at runtime, so the fragment
|
||||
// and the uniform list cannot disagree.
|
||||
static HUE_NAMES: [&str; 12] = [
|
||||
"red_hue",
|
||||
"orange_hue",
|
||||
"yellow_hue",
|
||||
"chartreuse_hue",
|
||||
"green_hue",
|
||||
"spring_hue",
|
||||
"cyan_hue",
|
||||
"azure_hue",
|
||||
"blue_hue",
|
||||
"violet_hue",
|
||||
"magenta_hue",
|
||||
"rose_hue",
|
||||
];
|
||||
static SAT_NAMES: [&str; 12] = [
|
||||
"red_sat",
|
||||
"orange_sat",
|
||||
"yellow_sat",
|
||||
"chartreuse_sat",
|
||||
"green_sat",
|
||||
"spring_sat",
|
||||
"cyan_sat",
|
||||
"azure_sat",
|
||||
"blue_sat",
|
||||
"violet_sat",
|
||||
"magenta_sat",
|
||||
"rose_sat",
|
||||
];
|
||||
static LUM_NAMES: [&str; 12] = [
|
||||
"red_lum",
|
||||
"orange_lum",
|
||||
"yellow_lum",
|
||||
"chartreuse_lum",
|
||||
"green_lum",
|
||||
"spring_lum",
|
||||
"cyan_lum",
|
||||
"azure_lum",
|
||||
"blue_lum",
|
||||
"violet_lum",
|
||||
"magenta_lum",
|
||||
"rose_lum",
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn there_are_twelve_bands_with_thirty_six_parameters() {
|
||||
assert_eq!(BANDS.len(), 12);
|
||||
assert_eq!(DESCRIPTOR.params.len(), 36);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bands_are_evenly_spaced_around_the_wheel() {
|
||||
// Uneven spacing would leave some hues weakly covered, since the
|
||||
// weight window is a fixed 60 degrees.
|
||||
for (i, band) in BANDS.iter().enumerate() {
|
||||
assert!(
|
||||
(band.hue - i as f32 * 30.0).abs() < 1e-6,
|
||||
"{} is at {}, expected {}",
|
||||
band.key,
|
||||
band.hue,
|
||||
i as f32 * 30.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_descriptor_id_resolves_to_a_band_and_channel() {
|
||||
// The link between the descriptor list and the value array. A
|
||||
// mismatch would make a slider silently adjust nothing.
|
||||
for p in DESCRIPTOR.params {
|
||||
assert!(
|
||||
ColourMixer::index_of(p.id).is_some(),
|
||||
"{} does not map to a band",
|
||||
p.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_band_and_channel_has_a_descriptor() {
|
||||
// The reverse direction: a band with no descriptor is unreachable
|
||||
// from the UI.
|
||||
for band in BANDS.iter() {
|
||||
for ch in Channel::ALL {
|
||||
let id = format!("{}_{}", band.key, ch.suffix());
|
||||
assert!(
|
||||
DESCRIPTOR.params.iter().any(|p| p.id.0 == id),
|
||||
"{id} has no descriptor"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_uniform_name_tables_match_the_band_keys() {
|
||||
// Three parallel tables and a band list; if they drift, the fragment
|
||||
// references a uniform that was never declared and the shader fails
|
||||
// to compile.
|
||||
for (i, band) in BANDS.iter().enumerate() {
|
||||
assert_eq!(HUE_NAMES[i], format!("{}_hue", band.key));
|
||||
assert_eq!(SAT_NAMES[i], format!("{}_sat", band.key));
|
||||
assert_eq!(LUM_NAMES[i], format!("{}_lum", band.key));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fresh_mixer_is_inactive() {
|
||||
assert!(!ColourMixer::new().is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_any_band_activates_it() {
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("blue_sat"), 40.0);
|
||||
assert!(m.is_active());
|
||||
assert_eq!(m.param(ParamId("blue_sat")), 40.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_adjusted_bands_reach_the_shader() {
|
||||
// The composition property applied within an operation: adjusting
|
||||
// one band must not cost twelve weight evaluations.
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("blue_sat"), 40.0);
|
||||
let body = m.wgsl_body();
|
||||
|
||||
assert!(body.contains("blue_sat"), "the adjusted band must appear");
|
||||
assert!(!body.contains("red_sat"), "untouched bands must not");
|
||||
assert_eq!(
|
||||
body.matches("band_weight(").count(),
|
||||
1,
|
||||
"one adjusted band means one weight evaluation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_adjusted_channels_within_a_band_reach_the_shader() {
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("green_lum"), -25.0);
|
||||
let body = m.wgsl_body();
|
||||
assert!(body.contains("green_lum"));
|
||||
assert!(!body.contains("green_hue"));
|
||||
assert!(!body.contains("green_sat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fragment_and_uniforms_agree_on_names() {
|
||||
// The failure this prevents is a compile error in generated code,
|
||||
// which is far harder to read than a failed assertion here.
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("orange_hue"), 20.0);
|
||||
m.set_param(ParamId("orange_sat"), -30.0);
|
||||
m.set_param(ParamId("azure_lum"), 15.0);
|
||||
|
||||
let body = m.wgsl_body();
|
||||
for u in m.uniforms() {
|
||||
assert!(
|
||||
body.contains(u.name),
|
||||
"uniform {} is declared but never used",
|
||||
u.name
|
||||
);
|
||||
}
|
||||
// And nothing referenced without being declared.
|
||||
let declared: Vec<&str> = m.uniforms().iter().map(|u| u.name).collect();
|
||||
for band in BANDS.iter() {
|
||||
for ch in Channel::ALL {
|
||||
let name = format!("{}_{}", band.key, ch.suffix());
|
||||
if body.contains(&name) {
|
||||
assert!(
|
||||
declared.contains(&name.as_str()),
|
||||
"{name} is used but not declared"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn achromatic_pixels_are_excluded() {
|
||||
// Adjusting a hue-less pixel would tint neutrals, which is the most
|
||||
// visible way a mixer misbehaves.
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("red_sat"), 50.0);
|
||||
assert!(m.wgsl_body().contains("chroma > 0.0001"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_weights_are_normalised() {
|
||||
// Without normalising, a hue between two adjusted bands gets roughly
|
||||
// double the intended adjustment.
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("red_sat"), 50.0);
|
||||
m.set_param(ParamId("orange_sat"), 50.0);
|
||||
let body = m.wgsl_body();
|
||||
assert!(body.contains("d_sat / w_total"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_parameters_are_ignored() {
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("puce_sat"), 50.0);
|
||||
m.set_param(ParamId("malformed"), 50.0);
|
||||
assert!(!m.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn luminance_travel_is_bounded_to_half_a_stop() {
|
||||
let mut m = ColourMixer::new();
|
||||
m.set_param(ParamId("blue_lum"), 100.0);
|
||||
let v = m.uniforms()[0].value;
|
||||
assert!((v - 0.5).abs() < 1e-6, "got {v}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Contrast — an S-curve about a fixed mid-point.
|
||||
//!
|
||||
//! Pushes tones away from middle grey (positive) or toward it (negative),
|
||||
//! pivoting where the eye reads "neither light nor dark". In linear light
|
||||
//! that point is 0.18, not 0.5: a scene-referred value of 0.5 is roughly a
|
||||
//! stop and a half above middle grey, and pivoting there would darken almost
|
||||
//! every photograph.
|
||||
//!
|
||||
//! The curve is applied in a perceptual domain rather than directly to linear
|
||||
//! values. Applied linearly, an S-curve crushes shadows far harder than it
|
||||
//! lifts highlights, because linear light devotes most of its range to the
|
||||
//! brightest stop.
|
||||
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Operation, Uniform};
|
||||
use crate::ops::helpers;
|
||||
|
||||
pub const ID: OpId = OpId("contrast");
|
||||
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.contrast"),
|
||||
params: &[ParamDescriptor::amount("contrast", "param.contrast")],
|
||||
};
|
||||
|
||||
/// The helpers this operation needs, including its own S-curve.
|
||||
static CONTRAST_HELPERS: &[Helper] = &[
|
||||
helpers::LUMINANCE,
|
||||
helpers::APPLY_TONE_GAIN,
|
||||
Helper {
|
||||
name: "contrast_curve",
|
||||
source: "\
|
||||
// A symmetric S-curve on a 0..1 perceptual position.
|
||||
//
|
||||
// `amount` above zero steepens, below zero flattens. The smoothstep form is
|
||||
// used for the steepening direction because it has zero gradient at both
|
||||
// ends, so the curve cannot invert however hard it is pushed — the failure
|
||||
// that makes naive gain-about-a-pivot unusable past moderate settings.
|
||||
fn contrast_curve(x: f32, amount: f32) -> f32 {
|
||||
let clamped = clamp(x, 0.0, 1.0);
|
||||
if (amount >= 0.0) {
|
||||
// Blend toward a smoothstep, which is the S.
|
||||
let s = clamped * clamped * (3.0 - 2.0 * clamped);
|
||||
return mix(clamped, s, amount);
|
||||
}
|
||||
// Flattening: pull toward the mid-point. At amount = -1 every tone
|
||||
// collapses to 0.5, which is the meaningful limit of 'no contrast'.
|
||||
return mix(clamped, 0.5, -amount);
|
||||
}",
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Contrast {
|
||||
amount: f32,
|
||||
}
|
||||
|
||||
impl Contrast {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation for Contrast {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
CONTRAST => self.amount = value,
|
||||
_ => log::warn!("contrast: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
CONTRAST => self.amount,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
"\
|
||||
let luma = luminance(c);
|
||||
if (luma > 0.0001) {
|
||||
// Work on luminance and rescale the colour by the ratio, rather than
|
||||
// curving each channel independently. Per-channel contrast shifts hue
|
||||
// wherever the channels differ — the classic symptom being skies going
|
||||
// cyan as contrast rises.
|
||||
//
|
||||
// MIDDLE_GREY is 0.18: the linear value the eye reads as mid-tone. The
|
||||
// curve operates on luma/(2*0.18) so that middle grey lands at the
|
||||
// curve's own 0.5 pivot.
|
||||
let pos = clamp(luma / 0.36, 0.0, 1.0);
|
||||
let curved = contrast_curve(pos, amount);
|
||||
// Not `target`: that is a WGSL reserved keyword, and using it produces a
|
||||
// parse error in generated code rather than anywhere a reader would look.
|
||||
let curved_luma = curved * 0.36;
|
||||
c = apply_tone_gain(c, curved_luma / luma);
|
||||
}
|
||||
c = max(c, vec3<f32>(0.0));"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "amount",
|
||||
value: self.amount / 100.0,
|
||||
}]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
CONTRAST_HELPERS
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::operation::compose;
|
||||
|
||||
#[test]
|
||||
fn neutral_does_nothing() {
|
||||
let c = Contrast::new();
|
||||
assert!(!c.is_active());
|
||||
assert_eq!(c.uniforms()[0].value, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_amount_is_normalised_to_unit_range() {
|
||||
// The shader's curve expects -1..1; the descriptor speaks -100..100.
|
||||
let mut c = Contrast::new();
|
||||
c.set_param(CONTRAST, 100.0);
|
||||
assert!((c.uniforms()[0].value - 1.0).abs() < 1e-6);
|
||||
c.set_param(CONTRAST, -100.0);
|
||||
assert!((c.uniforms()[0].value + 1.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_works_on_luminance_not_per_channel() {
|
||||
// Curving each channel separately shifts hue; the ratio form is what
|
||||
// keeps a blue sky blue as contrast rises.
|
||||
let mut c = Contrast::new();
|
||||
c.set_param(CONTRAST, 50.0);
|
||||
let body = c.wgsl_body();
|
||||
assert!(body.contains("luminance(c)"));
|
||||
assert!(
|
||||
body.contains("apply_tone_gain"),
|
||||
"the colour must be scaled by a ratio, not curved per channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_pivot_is_middle_grey_not_half() {
|
||||
// Pivoting at 0.5 in linear light would darken nearly every image:
|
||||
// scene-referred 0.5 is well above what the eye calls mid-tone.
|
||||
let c = Contrast::new();
|
||||
assert!(
|
||||
c.wgsl_body().contains("0.36"),
|
||||
"the curve must pivot about middle grey (0.18, doubled to place \
|
||||
it at the curve's own midpoint)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_cannot_invert() {
|
||||
// A gain-about-a-pivot form produces a non-monotonic curve past
|
||||
// moderate settings, which inverts tones. smoothstep cannot.
|
||||
let helper = CONTRAST_HELPERS
|
||||
.iter()
|
||||
.find(|h| h.name == "contrast_curve")
|
||||
.expect("declares its curve");
|
||||
assert!(helper.source.contains("3.0 - 2.0 * clamped"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn it_composes_with_the_other_tonal_operations() {
|
||||
// Contrast, highlights/shadows and brilliance all want `luminance`;
|
||||
// the composer must emit it once.
|
||||
let ops: Vec<Box<dyn Operation>> = vec![
|
||||
Box::new({
|
||||
let mut o = Contrast::new();
|
||||
o.set_param(CONTRAST, 40.0);
|
||||
o
|
||||
}),
|
||||
Box::new({
|
||||
let mut o = crate::ops::HighlightsShadows::new();
|
||||
o.set_param(crate::ops::tone::HIGHLIGHTS, -30.0);
|
||||
o
|
||||
}),
|
||||
];
|
||||
let shader = compose(&ops);
|
||||
assert_eq!(shader.source.matches("fn luminance(").count(), 1);
|
||||
assert_eq!(shader.source.matches("fn apply_tone_gain(").count(), 1);
|
||||
assert_eq!(shader.source.matches("fn contrast_curve(").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_division_by_luminance_is_guarded() {
|
||||
// A black pixel has zero luminance; dividing by it would produce NaN
|
||||
// and propagate through everything downstream.
|
||||
assert!(
|
||||
Contrast::new().wgsl_body().contains("luma > 0.0001"),
|
||||
"the ratio must be guarded against black pixels"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! Geometric distortion correction.
|
||||
//!
|
||||
//! Straightens the lines a lens bends: barrel distortion on wide angles,
|
||||
//! pincushion on telephotos. A [`crate::warp::Warp`] rather than an
|
||||
//! [`crate::operation::Operation`], because it changes *where* a pixel is read
|
||||
//! from rather than what its value becomes.
|
||||
//!
|
||||
//! # The model
|
||||
//!
|
||||
//! Lensfun's `ptlens` model, matched deliberately so a lens profile from the
|
||||
//! Lensfun database applies with no conversion:
|
||||
//!
|
||||
//! ```text
|
||||
//! r_d = r_u · (a·r_u³ + b·r_u² + c·r_u + 1 − a − b − c)
|
||||
//! ```
|
||||
//!
|
||||
//! The `1 − a − b − c` term is not decoration: it forces the polynomial to
|
||||
//! equal 1 at `r_u = 1`, pinning the image corner in place. Without it every
|
||||
//! coefficient change would also rescale the frame, so the distortion slider
|
||||
//! would double as a zoom and no setting would leave the framing alone.
|
||||
//!
|
||||
//! `a` and `b` are the higher-order terms that describe a lens's real,
|
||||
//! slightly wavy profile; `c` alone gives the simple barrel/pincushion shape.
|
||||
//! The manual control drives `c` only — a single slider cannot meaningfully
|
||||
//! set three correlated coefficients, and hand-correcting a lens with no
|
||||
//! profile is a "make the horizon straight" task, which one term does well.
|
||||
//! The full triple is reachable by loading a profile.
|
||||
|
||||
use crate::descriptor::{
|
||||
LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||
};
|
||||
use crate::operation::{Helper, Uniform};
|
||||
use crate::warp::Warp;
|
||||
|
||||
pub const ID: OpId = OpId("distortion");
|
||||
pub const AMOUNT: ParamId = ParamId("amount");
|
||||
|
||||
static DESCRIPTOR: OpDescriptor = OpDescriptor {
|
||||
id: ID,
|
||||
label: LocalizedKey("op.distortion"),
|
||||
// ±100 maps to a ±0.25 cubic coefficient. That covers an uncorrected
|
||||
// fisheye at one end and strong pincushion at the other; beyond it the
|
||||
// inverse mapping stops being single-valued near the corners and the
|
||||
// correction folds the image over itself.
|
||||
params: &[ParamDescriptor::scalar(
|
||||
"amount",
|
||||
"param.distortion.amount",
|
||||
-100.0,
|
||||
100.0,
|
||||
0.0,
|
||||
Unit::None,
|
||||
Scale::Linear,
|
||||
0,
|
||||
)],
|
||||
};
|
||||
|
||||
/// The cubic coefficient at full slider travel.
|
||||
const MAX_COEFF: f32 = 0.25;
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Distortion {
|
||||
amount: f32,
|
||||
/// Profile coefficients, when a lens profile is loaded. `None` means the
|
||||
/// manual slider drives `c` alone.
|
||||
profile: Option<PtLens>,
|
||||
}
|
||||
|
||||
/// The three `ptlens` coefficients, as Lensfun stores them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PtLens {
|
||||
pub a: f32,
|
||||
pub b: f32,
|
||||
pub c: f32,
|
||||
}
|
||||
|
||||
impl Distortion {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Apply a lens profile's coefficients.
|
||||
///
|
||||
/// The manual slider then acts as a *trim* on top: photographers routinely
|
||||
/// find a profile slightly over- or under-corrects on their copy of a
|
||||
/// lens, and having to choose between "profile" and "manual" would make
|
||||
/// that untunable.
|
||||
pub fn set_profile(&mut self, profile: Option<PtLens>) {
|
||||
self.profile = profile;
|
||||
}
|
||||
|
||||
/// The effective coefficients: profile plus manual trim.
|
||||
fn coefficients(&self) -> PtLens {
|
||||
let trim = self.amount / 100.0 * MAX_COEFF;
|
||||
match self.profile {
|
||||
Some(p) => PtLens {
|
||||
a: p.a,
|
||||
b: p.b,
|
||||
c: p.c + trim,
|
||||
},
|
||||
None => PtLens {
|
||||
a: 0.0,
|
||||
b: 0.0,
|
||||
c: trim,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Warp for Distortion {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
&DESCRIPTOR
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||
match id {
|
||||
AMOUNT => self.amount = value,
|
||||
_ => log::warn!("distortion: unknown parameter {id}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn param(&self, id: ParamId) -> f32 {
|
||||
match id {
|
||||
AMOUNT => self.amount,
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
// A loaded profile corrects even with the slider at zero — that is
|
||||
// the whole point of a profile.
|
||||
let c = self.coefficients();
|
||||
c.a != 0.0 || c.b != 0.0 || c.c != 0.0
|
||||
}
|
||||
|
||||
fn wgsl_body(&self) -> String {
|
||||
// Written against `p`, which is already normalised and centred.
|
||||
"\
|
||||
let r = length(p);
|
||||
p = p * ptlens_scale(r, dist_a, dist_b, dist_c);"
|
||||
.into()
|
||||
}
|
||||
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
let c = self.coefficients();
|
||||
vec![
|
||||
Uniform {
|
||||
name: "dist_a",
|
||||
value: c.a,
|
||||
},
|
||||
Uniform {
|
||||
name: "dist_b",
|
||||
value: c.b,
|
||||
},
|
||||
Uniform {
|
||||
name: "dist_c",
|
||||
value: c.c,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
PTLENS
|
||||
}
|
||||
}
|
||||
|
||||
static PTLENS: &[Helper] = &[Helper {
|
||||
name: "ptlens_scale",
|
||||
source: "\
|
||||
// The `ptlens` radial polynomial (Lensfun's model).
|
||||
//
|
||||
// Returns the factor mapping an undistorted radius to the distorted radius
|
||||
// it should be sampled from. The trailing `1 - a - b - c` normalises the
|
||||
// polynomial to 1 at r = 1, which pins the corner and stops a coefficient
|
||||
// change from also rescaling the frame.
|
||||
fn ptlens_scale(r: f32, a: f32, b: f32, c: f32) -> f32 {
|
||||
let d = 1.0 - a - b - c;
|
||||
return ((a * r + b) * r + c) * r + d;
|
||||
}",
|
||||
}];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The scale factor the shader would compute, mirrored on the CPU so the
|
||||
/// maths is testable without a device (ARCH §6.5a).
|
||||
fn scale(c: PtLens, r: f32) -> f32 {
|
||||
let d = 1.0 - c.a - c.b - c.c;
|
||||
((c.a * r + c.b) * r + c.c) * r + d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_does_nothing() {
|
||||
let d = Distortion::new();
|
||||
assert!(!d.is_active());
|
||||
let c = d.coefficients();
|
||||
assert_eq!((c.a, c.b, c.c), (0.0, 0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_polynomial_is_the_identity() {
|
||||
// Every radius must map to itself when no correction is set,
|
||||
// otherwise opening an image would resample it for nothing.
|
||||
let c = Distortion::new().coefficients();
|
||||
for r in [0.0, 0.25, 0.5, 0.75, 1.0] {
|
||||
assert!((scale(c, r) - 1.0).abs() < 1e-6, "r={r} was rescaled");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_corner_is_pinned_whatever_the_coefficients() {
|
||||
// The property the `1 - a - b - c` term exists for: correction must
|
||||
// not silently zoom the frame. If this fails, the distortion slider
|
||||
// doubles as a crop and no setting leaves framing untouched.
|
||||
for amount in [-100.0, -50.0, -1.0, 1.0, 50.0, 100.0] {
|
||||
let mut d = Distortion::new();
|
||||
d.set_param(AMOUNT, amount);
|
||||
let s = scale(d.coefficients(), 1.0);
|
||||
assert!(
|
||||
(s - 1.0).abs() < 1e-5,
|
||||
"amount {amount} moved the corner by {}",
|
||||
s - 1.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_centre_never_moves() {
|
||||
// r = 0 is the optical axis; a radial model must leave it fixed, and
|
||||
// `p * scale` does so for any finite scale.
|
||||
let mut d = Distortion::new();
|
||||
d.set_param(AMOUNT, 100.0);
|
||||
assert!(scale(d.coefficients(), 0.0).is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positive_amounts_correct_barrel_distortion() {
|
||||
// Barrel distortion pushes detail outward, so correcting it must
|
||||
// sample from further out at mid radii — an inverse map (see the
|
||||
// `warp` module docs), which is why "correct barrel" magnifies.
|
||||
let mut d = Distortion::new();
|
||||
d.set_param(AMOUNT, 100.0);
|
||||
let s = scale(d.coefficients(), 0.5);
|
||||
assert!(s < 1.0, "mid-radius scale was {s}, expected < 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_amounts_go_the_other_way() {
|
||||
let mut pin = Distortion::new();
|
||||
pin.set_param(AMOUNT, -100.0);
|
||||
let mut bar = Distortion::new();
|
||||
bar.set_param(AMOUNT, 100.0);
|
||||
assert!(scale(pin.coefficients(), 0.5) > scale(bar.coefficients(), 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_mapping_stays_monotonic_across_the_whole_range() {
|
||||
// If radius stops increasing with radius, the correction folds the
|
||||
// image over itself and produces a mirrored ring. This is what bounds
|
||||
// the slider at ±100, so it is worth asserting rather than trusting.
|
||||
for amount in [-100.0, -50.0, 0.0, 50.0, 100.0] {
|
||||
let mut d = Distortion::new();
|
||||
d.set_param(AMOUNT, amount);
|
||||
let c = d.coefficients();
|
||||
let mut prev = 0.0;
|
||||
for i in 1..=100 {
|
||||
let r = i as f32 / 100.0;
|
||||
let mapped = r * scale(c, r);
|
||||
assert!(
|
||||
mapped > prev,
|
||||
"amount {amount}: mapping folded at r={r} ({mapped} <= {prev})"
|
||||
);
|
||||
prev = mapped;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_corrects_with_the_slider_at_zero() {
|
||||
// Loading a lens profile must do something on its own; requiring the
|
||||
// user to also move a slider would make profiles pointless.
|
||||
let mut d = Distortion::new();
|
||||
assert!(!d.is_active());
|
||||
d.set_profile(Some(PtLens {
|
||||
a: 0.0168,
|
||||
b: -0.0320,
|
||||
c: -0.0287,
|
||||
}));
|
||||
assert!(d.is_active());
|
||||
assert_eq!(d.param(AMOUNT), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_slider_trims_a_loaded_profile_rather_than_replacing_it() {
|
||||
// A profile that over-corrects on this copy of the lens must stay
|
||||
// tunable, so the manual control adds to `c` and leaves a and b.
|
||||
let profile = PtLens {
|
||||
a: 0.01,
|
||||
b: -0.02,
|
||||
c: 0.03,
|
||||
};
|
||||
let mut d = Distortion::new();
|
||||
d.set_profile(Some(profile));
|
||||
d.set_param(AMOUNT, 100.0);
|
||||
|
||||
let c = d.coefficients();
|
||||
assert_eq!(c.a, profile.a, "the profile's a must survive a trim");
|
||||
assert_eq!(c.b, profile.b);
|
||||
assert!((c.c - (profile.c + MAX_COEFF)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_can_be_cleared() {
|
||||
let mut d = Distortion::new();
|
||||
d.set_profile(Some(PtLens {
|
||||
a: 0.01,
|
||||
b: 0.0,
|
||||
c: 0.0,
|
||||
}));
|
||||
assert!(d.is_active());
|
||||
d.set_profile(None);
|
||||
assert!(!d.is_active(), "clearing a profile must return to neutral");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_wgsl_body_reads_its_declared_uniforms() {
|
||||
// The composer rewrites bare names; a body naming something it did
|
||||
// not declare would compile to a reference to a nonexistent field.
|
||||
let mut d = Distortion::new();
|
||||
d.set_param(AMOUNT, 50.0);
|
||||
let body = d.wgsl_body();
|
||||
for u in d.uniforms() {
|
||||
assert!(body.contains(u.name), "{} is declared but unused", u.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,16 @@
|
||||
//! shader to edit, no UI change (FR-DEV-3c).
|
||||
|
||||
pub mod colour;
|
||||
pub mod colour_mixer;
|
||||
pub mod contrast;
|
||||
pub mod exposure;
|
||||
pub mod helpers;
|
||||
pub mod tone;
|
||||
pub mod white_balance;
|
||||
|
||||
pub use colour::{Brilliance, Saturation, Vibrance};
|
||||
pub use colour_mixer::ColourMixer;
|
||||
pub use contrast::Contrast;
|
||||
pub use exposure::Exposure;
|
||||
pub use tone::{BlacksWhites, HighlightsShadows};
|
||||
pub use white_balance::WhiteBalance;
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
//! Coordinate-domain operations — the geometry half of the pipeline.
|
||||
//!
|
||||
//! # Why this is not `Operation`
|
||||
//!
|
||||
//! Every [`crate::operation::Operation`] is a function from colour to colour:
|
||||
//! `wgsl_body` receives `c: vec3<f32>` and produces one. That shape cannot
|
||||
//! express lens correction, and the reason is worth stating precisely because
|
||||
//! it is what justifies a second trait rather than an extension of the first.
|
||||
//!
|
||||
//! Distortion does not change a pixel's value; it changes **which pixel you
|
||||
//! read**. Chromatic aberration is worse still: lateral CA is a per-channel
|
||||
//! radial magnification, so red, green and blue must be fetched from three
|
||||
//! *different* coordinates. No function of an already-fetched `vec3<f32>` can
|
||||
//! recover that — by the time a colour reaches an `Operation`, the three
|
||||
//! channels have been sampled together and the information is gone.
|
||||
//!
|
||||
//! So a warp runs **before** the fetch, and composes into the generated
|
||||
//! shader ahead of it (ARCH §5.2 places lens corrections in the geometry
|
||||
//! half of the chain).
|
||||
//!
|
||||
//! # Inverse mapping
|
||||
//!
|
||||
//! A warp declares where an output pixel's colour **came from**, not where an
|
||||
//! input pixel goes. This is not a stylistic choice:
|
||||
//!
|
||||
//! - A forward map is a *scatter* — each input pixel writes somewhere. In a
|
||||
//! compute shader that needs atomics, leaves holes where the map expands,
|
||||
//! and races where it contracts.
|
||||
//! - An inverse map is a *gather* — each output pixel reads somewhere. One
|
||||
//! dispatch, one write per pixel, no contention, and hole-free by
|
||||
//! construction.
|
||||
//!
|
||||
//! So `undistort` is expressed as "given this output position, which source
|
||||
//! position feeds it?". For a barrel-distorting lens that means the warp
|
||||
//! *magnifies* the radius, which reads backwards until you remember the
|
||||
//! direction is inverse.
|
||||
//!
|
||||
//! # Coordinate space
|
||||
//!
|
||||
//! Warps work in **normalised centred** coordinates: the image centre is
|
||||
//! `(0, 0)`, and the radius is scaled so that `r == 1` at the corner. Both
|
||||
//! properties matter.
|
||||
//!
|
||||
//! Centring is what makes the polynomial meaningful — lens distortion is
|
||||
//! radially symmetric about the optical axis, so a formula written about any
|
||||
//! other origin would need cross terms to say the same thing.
|
||||
//!
|
||||
//! Corner normalisation is what makes a coefficient **portable across
|
||||
//! resolutions and aspect ratios**: the same value describes the lens whether
|
||||
//! applied to a full-resolution export, a 512px thumbnail, or a cropped
|
||||
//! frame. Normalising to the shorter edge instead — the other obvious choice
|
||||
//! — would make a coefficient mean different things on a 3:2 and a 16:9 body
|
||||
//! wearing the same lens, which defeats the point of a lens profile.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use crate::descriptor::{OpDescriptor, ParamId};
|
||||
use crate::operation::{Helper, Uniform};
|
||||
|
||||
/// A coordinate-domain operation, applied before the source is sampled.
|
||||
///
|
||||
/// Object-safe for the same reason [`crate::operation::Operation`] is: the
|
||||
/// graph holds `Box<dyn Warp>` in order, so the geometry chain is data.
|
||||
pub trait Warp: Send + Sync {
|
||||
/// Static description, driving UI generation exactly as for an operation.
|
||||
fn descriptor(&self) -> &'static OpDescriptor;
|
||||
|
||||
/// Set a parameter. Values arrive already clamped to the descriptor.
|
||||
fn set_param(&mut self, id: ParamId, value: f32);
|
||||
|
||||
/// Read a parameter back.
|
||||
fn param(&self, id: ParamId) -> f32;
|
||||
|
||||
/// Whether this warp currently moves any pixel.
|
||||
///
|
||||
/// A warp at neutral is omitted from the shader entirely — and if *every*
|
||||
/// warp is neutral the generated shader keeps its integer `textureLoad`
|
||||
/// path rather than paying for a bilinear sample it does not need.
|
||||
fn is_active(&self) -> bool;
|
||||
|
||||
/// The WGSL body of this warp's inverse coordinate transform.
|
||||
///
|
||||
/// Receives `p` (a `vec2<f32>`, normalised and centred per the module
|
||||
/// docs) and must leave the **source** position in `p`.
|
||||
///
|
||||
/// A warp needing per-channel divergence writes `p_r` and `p_b` as well;
|
||||
/// they enter the block equal to `p` and are carried out of it. A warp
|
||||
/// that ignores them costs nothing — the composer drops the per-channel
|
||||
/// path when no active warp declares [`Self::splits_channels`].
|
||||
///
|
||||
/// Uniforms are addressed by their bare declared names, as for an
|
||||
/// operation; the composer rewrites them to their prefixed fields.
|
||||
fn wgsl_body(&self) -> String;
|
||||
|
||||
/// Uniform values this warp's body reads.
|
||||
fn uniforms(&self) -> Vec<Uniform>;
|
||||
|
||||
/// Whether this warp moves the channels independently.
|
||||
///
|
||||
/// True only for chromatic aberration. When no active warp declares it,
|
||||
/// the composer emits a single sample instead of three — a 3× saving in
|
||||
/// texture bandwidth for the common case of distortion alone, which at
|
||||
/// 24 MP is the difference the tile budget is measured in.
|
||||
fn splits_channels(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Any WGSL helper functions the body calls.
|
||||
fn helpers(&self) -> &'static [Helper] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// The composed geometry stage: WGSL, uniforms, and what it needs from the
|
||||
/// sampler.
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct ComposedWarp {
|
||||
/// The WGSL block computing source coordinates, or empty when no warp is
|
||||
/// active.
|
||||
pub body: String,
|
||||
/// Helper functions the body calls.
|
||||
pub helpers: Vec<Helper>,
|
||||
/// Uniform declarations, to be appended to the generated struct.
|
||||
pub uniform_fields: String,
|
||||
/// Uniform values, in declaration order.
|
||||
pub uniforms: Vec<f32>,
|
||||
/// Whether any active warp samples the channels separately.
|
||||
pub splits_channels: bool,
|
||||
}
|
||||
|
||||
impl ComposedWarp {
|
||||
/// Whether any warp is active. When false the shader samples with an
|
||||
/// integer `textureLoad` and no interpolation at all.
|
||||
pub fn is_active(&self) -> bool {
|
||||
!self.body.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose the active warps into one coordinate transform.
|
||||
///
|
||||
/// Warps chain in order: each receives the position the previous one produced,
|
||||
/// so correcting distortion and then CA composes as a single expression with
|
||||
/// no intermediate buffer.
|
||||
pub fn compose_warps(warps: &[Box<dyn Warp>]) -> ComposedWarp {
|
||||
let active: Vec<&dyn Warp> = warps
|
||||
.iter()
|
||||
.map(|w| w.as_ref())
|
||||
.filter(|w| w.is_active())
|
||||
.collect();
|
||||
|
||||
if active.is_empty() {
|
||||
return ComposedWarp::default();
|
||||
}
|
||||
|
||||
let mut out = ComposedWarp {
|
||||
splits_channels: active.iter().any(|w| w.splits_channels()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
for warp in &active {
|
||||
let id = warp.descriptor().id.0;
|
||||
let prefix = sanitise(id);
|
||||
|
||||
let warp_uniforms = warp.uniforms();
|
||||
if !warp_uniforms.is_empty() {
|
||||
let _ = writeln!(out.uniform_fields, " // {id}");
|
||||
}
|
||||
for u in &warp_uniforms {
|
||||
let _ = writeln!(out.uniform_fields, " {prefix}_{}: f32,", u.name);
|
||||
out.uniforms.push(u.value);
|
||||
}
|
||||
|
||||
for h in warp.helpers() {
|
||||
if !out.helpers.iter().any(|e| e.name == h.name) {
|
||||
out.helpers.push(*h);
|
||||
}
|
||||
}
|
||||
|
||||
let mut fragment = warp.wgsl_body();
|
||||
for u in &warp_uniforms {
|
||||
fragment = crate::operation::rewrite_uniform(
|
||||
&fragment,
|
||||
u.name,
|
||||
&format!("u.{prefix}_{}", u.name),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = writeln!(out.body, "\n // ---- warp: {id} ----");
|
||||
let _ = writeln!(out.body, " {{");
|
||||
for line in fragment.lines() {
|
||||
let _ = writeln!(out.body, " {line}");
|
||||
}
|
||||
let _ = writeln!(out.body, " }}");
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
fn sanitise(id: &str) -> String {
|
||||
id.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::descriptor::{LocalizedKey, OpDescriptor, OpId, ParamDescriptor};
|
||||
|
||||
static DESC_A: OpDescriptor = OpDescriptor {
|
||||
id: OpId("warp_a"),
|
||||
label: LocalizedKey("a"),
|
||||
params: &[ParamDescriptor::amount("amount", "a.amount")],
|
||||
};
|
||||
static DESC_B: OpDescriptor = OpDescriptor {
|
||||
id: OpId("warp_b"),
|
||||
label: LocalizedKey("b"),
|
||||
params: &[ParamDescriptor::amount("amount", "b.amount")],
|
||||
};
|
||||
|
||||
struct Fake {
|
||||
desc: &'static OpDescriptor,
|
||||
amount: f32,
|
||||
splits: bool,
|
||||
}
|
||||
|
||||
impl Warp for Fake {
|
||||
fn descriptor(&self) -> &'static OpDescriptor {
|
||||
self.desc
|
||||
}
|
||||
fn set_param(&mut self, _id: ParamId, value: f32) {
|
||||
self.amount = value;
|
||||
}
|
||||
fn param(&self, _id: ParamId) -> f32 {
|
||||
self.amount
|
||||
}
|
||||
fn is_active(&self) -> bool {
|
||||
self.amount != 0.0
|
||||
}
|
||||
fn wgsl_body(&self) -> String {
|
||||
"p = p * amount;".into()
|
||||
}
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "amount",
|
||||
value: self.amount,
|
||||
}]
|
||||
}
|
||||
fn splits_channels(&self) -> bool {
|
||||
self.splits
|
||||
}
|
||||
}
|
||||
|
||||
fn fake(desc: &'static OpDescriptor, amount: f32, splits: bool) -> Box<dyn Warp> {
|
||||
Box::new(Fake {
|
||||
desc,
|
||||
amount,
|
||||
splits,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_active_warp_composes_to_nothing() {
|
||||
// The property that keeps the common case free: an image with no lens
|
||||
// correction must not pay for a bilinear sample.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 0.0, false)]);
|
||||
assert!(!composed.is_active());
|
||||
assert!(composed.uniforms.is_empty());
|
||||
assert!(!composed.splits_channels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_active_warp_appears_once() {
|
||||
let composed = compose_warps(&[fake(&DESC_A, 2.0, false)]);
|
||||
assert!(composed.is_active());
|
||||
assert!(composed.body.contains("---- warp: warp_a ----"));
|
||||
assert!(composed.body.contains("u.warp_a_amount"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniforms_are_prefixed_so_warps_cannot_collide() {
|
||||
// Both fakes declare `amount`; without prefixing the generated struct
|
||||
// would carry a duplicate field and fail to compile.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 2.0, false)]);
|
||||
assert!(composed.uniform_fields.contains("warp_a_amount: f32"));
|
||||
assert!(composed.uniform_fields.contains("warp_b_amount: f32"));
|
||||
assert_eq!(composed.uniforms, vec![1.0, 2.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_splitting_is_requested_by_any_active_warp() {
|
||||
// One CA warp among several must switch the whole stage to the
|
||||
// three-sample path.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, true)]);
|
||||
assert!(composed.splits_channels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_inactive_splitting_warp_does_not_force_three_samples() {
|
||||
// CA present but at neutral must cost nothing — otherwise every image
|
||||
// with the panel visible pays triple bandwidth.
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 0.0, true)]);
|
||||
assert!(composed.is_active());
|
||||
assert!(!composed.splits_channels);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warps_compose_in_order() {
|
||||
let composed = compose_warps(&[fake(&DESC_A, 1.0, false), fake(&DESC_B, 1.0, false)]);
|
||||
let a = composed.body.find("warp_a").expect("a present");
|
||||
let b = composed.body.find("warp_b").expect("b present");
|
||||
assert!(a < b, "warps must chain in graph order");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user