Files
DarkRoom/core/dr-gpu/src/adjust.rs
T
dtourolle 5a500118ae Correct converging verticals with a keystone in framing
There was no perspective transform anywhere in the pipeline: framing
offered a ±45° straighten, quarter turns and flips, and a building shot
looking up kept its leaning walls.

Framing gains a vertical and a horizontal keystone (-100..100). They are
parameters of framing rather than a new stage, so they carry its Compose
attribute, persist in the sidecar under framing, and are withheld from a
default paste exactly as the crop is. In the prologue the keystone runs
after the crop and the straightening and before the stored orientation
and the lens warp, so "vertical" is the photograph's displayed height and
the lens still sees its whole frame.

The map takes the output frame onto a trapezoid inside the source, built
as a homography from four corners and uploaded as three columns in the
framing uniform block (which grows from two vec4s to five). A keystone on
its own therefore never exposes an empty corner and leaves any crop valid.
Combined with a straightening angle the empty area is a pulled-back
quadrilateral the closed-form inscribed rectangle cannot describe, so
max_inscribed_crop searches for the largest centred rectangle whose
corners all have a source pixel behind them. source_at and output_at
apply the same map, so masks, gradients and spot handles follow it.
2026-09-24 22:13:11 -04:00

2840 lines
118 KiB
Rust

//! The adjust pass — runs `dr-pipeline`'s generated shader.
//!
//! Takes the demosaiced texture, applies the composed operation chain, and
//! writes a display-ready RGBA8 texture. One dispatch, whatever the number of
//! active operations, because the operations were fused into one shader
//! before they got here.
//!
//! # The pipeline cache
//!
//! Compiling a shader takes milliseconds — fine once, ruinous per frame while
//! a slider is moving. Pipelines are therefore cached by the composed
//! shader's `structure_hash`, which covers the operation set and their order
//! but not their values. Dragging a slider re-uploads a uniform buffer and
//! reuses the compiled pipeline; enabling an operation compiles once and then
//! also reuses.
use std::collections::HashMap;
use dr_pipeline::detail::ComposedDetail;
use dr_pipeline::{ComposedShader, OutputMode};
use wgpu::util::DeviceExt;
use crate::detail::DetailRunner;
use crate::readback::await_mapping;
use crate::{DemosaicedImage, GpuContext, GpuError};
/// Leading floats the composer reserves before any operation's own uniforms:
/// three padded matrix rows, the as-shot white balance, and framing's block.
///
/// Imported rather than restated. It was a local literal, which was a latent
/// bug of exactly the kind that is invisible until it is severe: growing the
/// reserved block on the pipeline side would leave this short, and every
/// operation's uniforms would silently shift out from under the shader that
/// reads them.
const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS;
/// TRACES: FR-DEV-3e
/// The two crates must agree on how many points a base curve has.
///
/// `dr-decode` reads them from the profile database and `dr-pipeline` declares
/// the uniform slots; this file is the only place the two meet, and it packs
/// them by index. A disagreement would not fail to compile — it would upload a
/// curve with a point missing or a stale float in it, which renders as a
/// plausible-looking wrong tone response. Cheaper to catch here, at build time.
const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS);
/// Runs composed operation chains against demosaiced images.
pub struct AdjustPass {
ctx: GpuContext,
bind_group_layout: wgpu::BindGroupLayout,
pipeline_layout: wgpu::PipelineLayout,
/// Compiled pipelines by structure hash (ARCH §5.6).
cache: HashMap<u64, wgpu::ComputePipeline>,
/// TRACES: FR-DSP-1 | AC-8
/// Output textures, written alternately, each reallocated only when the
/// size changes.
///
/// **Two, and the second one is not an optimisation — it is what makes the
/// zero-copy path visible.** Since S1 the compositor is handed this
/// texture rather than a copy of its pixels, and Slint decides whether to
/// repaint by comparing the image property against its previous value. Two
/// images wrapping the *same* `wgpu::Texture` compare equal, so a pass
/// that always wrote one texture would recompute every frame on the GPU
/// and never once be asked to show it. Alternating makes each frame a
/// genuinely different value, which is the only thing that makes it a
/// different picture as far as the property system is concerned.
///
/// It also settles the question of whether the compositor is still
/// sampling last frame while this frame's dispatch overwrites it. Both go
/// through one queue, so submission order already answers that — but not
/// having to rely on it is worth a texture.
targets: [Option<Target>; 2],
/// Which of [`Self::targets`] the last render wrote.
current: usize,
/// Bound at `@binding(3)` when the edit carries no mask layers.
empty_masks: wgpu::TextureView,
/// TRACES: FR-DEV-3f
/// Bound at `@binding(4)` and `@binding(5)` when no film stock is loaded,
/// which is the state of every photograph in the catalogue by default.
empty_film_curves: wgpu::TextureView,
empty_film_lut: wgpu::TextureView,
/// The loaded stock's tables, once uploaded. See [`Self::set_film`].
film: Option<FilmTextures>,
/// TRACES: FR-DEV-3 | FR-DEV-3d
/// The neighbourhood stage — sharpening, noise reduction, clarity and the
/// rest of FR-DEV-3's detail set, which cannot be fused into the shader
/// above because they read pixels they are not writing.
///
/// It lives here rather than beside this pass because the two are one
/// render: when a detail chain is present the fused pass writes a linear
/// intermediate the runner owns, and the runner's last pass writes
/// [`Self::targets`]. Kept as separate objects, a caller could hold a
/// stale intermediate against a fresh colour result with nothing to tell
/// it apart.
detail: DetailRunner,
/// The bind group layout for a fused pass writing a linear intermediate.
///
/// A second layout rather than a second pass: the only difference is the
/// storage texture's format, which is part of the layout and cannot be
/// varied per bind group. Built once here, so a detail operation being
/// switched on does not build a pipeline layout mid-frame.
linear_bind_group_layout: wgpu::BindGroupLayout,
linear_pipeline_layout: wgpu::PipelineLayout,
/// TRACES: FR-MRG-2
/// A third layout, writing `rgba32float`, for the camera-space tap a
/// merge reads (`OutputMode::CameraLinear`). Same reasoning as the
/// linear one: the format is in the layout, so a format is a layout.
camera_bind_group_layout: wgpu::BindGroupLayout,
camera_pipeline_layout: wgpu::PipelineLayout,
/// The camera-space texture the last `render_camera_linear` wrote.
/// Separate from `targets`: a different format, and a merge reads it
/// back or samples it while the display targets go on being swapped.
camera_target: Option<Target>,
/// TRACES: FR-DEV-3d
/// What the linear intermediate currently holds, and at what size.
///
/// **This is where `Affects::Detail` stops being bookkeeping.** The key is
/// everything the fused dispatch depends on — the caller's
/// `Invalidation::through(Affects::Colour)`, the compiled structure, the
/// uniform values and the output size. When it matches, the colour pass is
/// skipped and only the detail passes run, so dragging a sharpening slider
/// costs a convolution and not a re-render of the whole chain (FR-DEV-3d).
///
/// Cleared by any render that does not write it, so a stale intermediate
/// cannot survive a change of image and be handed to a later detail chain.
colour_key: Option<(u64, u32, u32)>,
/// Fused dispatches actually encoded. Exposed so a test can see the reuse
/// above happening rather than take it on trust.
colour_dispatches: usize,
/// Detail dispatches encoded.
detail_dispatches: usize,
}
struct Target {
texture: wgpu::Texture,
view: wgpu::TextureView,
width: u32,
height: u32,
}
/// The texture format both film tables are uploaded in.
///
/// 32-bit float, and not the half-float the rest of the pipeline prefers: the
/// LUT is half a megabyte either way at the size it is baked at, and a density
/// carries its precision straight into a colour. Halving a table this small
/// would trade the one thing it is for the one thing it is not short of.
const FILM_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// TRACES: FR-DEV-3f
/// A baked film stock, resident on the GPU.
struct FilmTextures {
curves: wgpu::TextureView,
lut: wgpu::TextureView,
/// What the resident tables were built from, so an unchanged stock is not
/// re-uploaded. Every frame would otherwise push half a megabyte across
/// the bus to arrive at the bytes already there.
key: u64,
}
/// A cheap content key for a set of tables.
///
/// Not a cryptographic hash and not trying to be: it decides whether to skip an
/// upload, and the cost of a collision is a stale lookup on a stock the user
/// just changed. Every field that *shapes* the tables goes in whole; the tables
/// themselves are sampled, because two stocks agreeing on the matrix, both
/// domains and every eighth entry are the same stock.
fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |bits: u32| {
h ^= u64::from(bits);
h = h.wrapping_mul(0x1000_0000_01b3);
};
for row in &t.exposure_matrix {
for v in row {
mix(v.to_bits());
}
}
for v in [
t.curve_log_min,
t.curve_log_max,
t.density_max,
t.lut_size as f32,
] {
mix(v.to_bits());
}
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
}
h
}
/// Pad RGB triples to the RGBA the upload wants.
///
/// The alpha is never read — the shader takes `.rgb` from the lookup and
/// indexes the curve by channel — so it is written as one rather than left
/// undefined, which keeps a dump of the texture legible if anyone has to look.
fn to_rgba(triples: &[[f32; 3]]) -> Vec<f32> {
let mut out = Vec::with_capacity(triples.len() * 4);
for t in triples {
out.extend_from_slice(&[t[0], t[1], t[2], 1.0]);
}
out
}
impl AdjustPass {
/// TRACES: FR-DEV-3f
/// Make a baked film stock current, or clear it.
///
/// Separate from `render` rather than another argument to it, because a
/// stock changes when a person picks one and a frame is rendered sixty
/// times a second. Threading half a megabyte through the render path would
/// invite exactly the per-frame upload the key below exists to avoid.
pub fn set_film(&mut self, tables: Option<&dr_pipeline::ops::FilmTables>) {
let Some(t) = tables else {
self.film = None;
return;
};
let key = film_key(t);
if self.film.as_ref().is_some_and(|f| f.key == key) {
return;
}
if !t.is_well_formed() {
// Refused here as well as in the operation, because this is the
// last point before a shader indexes the result. The two checks
// are cheap and the failure they prevent is a driver-dependent
// read past the end of a texture.
log::error!("adjust: refusing malformed film tables");
self.film = None;
return;
}
let curves = self.upload_film(
"adjust-film-curves",
wgpu::TextureDimension::D2,
wgpu::Extent3d {
width: t.curves.len() as u32,
height: 1,
depth_or_array_layers: 1,
},
&to_rgba(&t.curves),
);
let n = t.lut_size as u32;
let lut = self.upload_film(
"adjust-film-lut",
wgpu::TextureDimension::D3,
wgpu::Extent3d {
width: n,
height: n,
depth_or_array_layers: n,
},
&to_rgba(&t.lut),
);
self.film = Some(FilmTextures { curves, lut, key });
}
/// Create a texture and write `data` into it in one go.
fn upload_film(
&self,
label: &str,
dimension: wgpu::TextureDimension,
size: wgpu::Extent3d,
data: &[f32],
) -> wgpu::TextureView {
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size,
mip_level_count: 1,
sample_count: 1,
dimension,
format: FILM_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
self.ctx.queue.write_texture(
wgpu::TexelCopyTextureInfo {
texture: &texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
bytemuck::cast_slice(data),
wgpu::TexelCopyBufferLayout {
offset: 0,
// Four channels of four bytes. Stated from the format rather
// than from the data's length, so a short upload is a wgpu
// error naming the texture instead of a skewed lookup.
bytes_per_row: Some(size.width * 16),
rows_per_image: Some(size.height),
},
size,
);
let mut descriptor = wgpu::TextureViewDescriptor {
label: Some(label),
..Default::default()
};
if dimension == wgpu::TextureDimension::D3 {
descriptor.dimension = Some(wgpu::TextureViewDimension::D3);
}
texture.create_view(&descriptor)
}
/// The curve texture to bind: the loaded stock's, or the placeholder.
fn film_curves_view(&self) -> &wgpu::TextureView {
self.film
.as_ref()
.map_or(&self.empty_film_curves, |f| &f.curves)
}
/// The density lookup to bind: the loaded stock's, or the placeholder.
fn film_lut_view(&self) -> &wgpu::TextureView {
self.film.as_ref().map_or(&self.empty_film_lut, |f| &f.lut)
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// TRACES: FR-MRG-2
/// The camera-space tap's format: full precision, because what it holds
/// is written back as a RAW at the sensor's own scale (FR-MRG-3).
pub const CAMERA_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
pub fn new(ctx: &GpuContext) -> Self {
let bind_group_layout = Self::layout_writing(ctx, Self::FORMAT, "adjust-bgl");
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("adjust-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
// The same layout with an `Rgba16Float` storage texture, for the fused
// pass when a detail stage follows it and it hands on linear working
// values instead of encoding (see `dr_pipeline::OutputMode`). The
// format is part of a bind group layout and cannot be varied per bind
// group, so this is a second layout rather than a second binding —
// built here, once, so that switching sharpening on does not construct
// a pipeline layout in the middle of a frame.
let linear_bind_group_layout =
Self::layout_writing(ctx, crate::detail::INTERMEDIATE_FORMAT, "adjust-linear-bgl");
let linear_pipeline_layout =
ctx.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("adjust-linear-layout"),
bind_group_layouts: &[Some(&linear_bind_group_layout)],
immediate_size: 0,
});
let camera_bind_group_layout =
Self::layout_writing(ctx, Self::CAMERA_FORMAT, "adjust-camera-bgl");
let camera_pipeline_layout =
ctx.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("adjust-camera-layout"),
bind_group_layouts: &[Some(&camera_bind_group_layout)],
immediate_size: 0,
});
// A 1x1 single-layer mask, bound when the edit has no local
// adjustments. The generated shader never samples it — no layer block
// is emitted — but a bind group must still satisfy the layout.
let empty = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-empty-masks"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: crate::MaskArray::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let empty_masks = empty.create_view(&wgpu::TextureViewDescriptor {
label: Some("adjust-empty-masks-view"),
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
// TRACES: FR-DEV-3f
// What binds to the film slots when no stock is loaded, which is the
// state of every photograph in the catalogue by default. The shader
// declares both unconditionally so that one bind group layout serves
// every generated shader; these cost sixteen bytes each and no branch.
let empty_film_curves = ctx
.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-empty-film-curves"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FILM_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let empty_film_lut = ctx
.device
.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-empty-film-lut"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: FILM_FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&wgpu::TextureViewDescriptor {
label: Some("adjust-empty-film-lut-view"),
dimension: Some(wgpu::TextureViewDimension::D3),
..Default::default()
});
Self {
ctx: ctx.clone(),
bind_group_layout,
pipeline_layout,
cache: HashMap::new(),
targets: [None, None],
current: 0,
empty_masks,
empty_film_curves,
empty_film_lut,
film: None,
detail: DetailRunner::new(ctx),
linear_bind_group_layout,
linear_pipeline_layout,
camera_bind_group_layout,
camera_pipeline_layout,
camera_target: None,
colour_key: None,
colour_dispatches: 0,
detail_dispatches: 0,
}
}
/// The fused pass's bind group layout, for a given storage format.
///
/// Two of these exist — one writing `Rgba8Unorm` and one writing
/// `Rgba16Float` — and they differ in exactly one field. Written once and
/// parameterised rather than copied, because two copies of a four-entry
/// layout is how the mask binding comes to be present in one and absent
/// from the other, and a bind group that satisfies neither is a validation
/// error a long way from its cause.
fn layout_writing(
ctx: &GpuContext,
format: wgpu::TextureFormat,
label: &str,
) -> wgpu::BindGroupLayout {
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries: &[
// The demosaiced source.
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
// The local-adjustment masks. Present in every layout
// whether or not the edit has any, because the layout is
// built once here and the generated shader declares the
// binding unconditionally for exactly that reason.
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
},
// TRACES: FR-DEV-3f
// A film stock's characteristic curves, and the density
// lookup carrying everything downstream of them. Present
// in every layout for the reason the masks above are, and
// bound to placeholders when no stock is loaded.
//
// Declared unfilterable, and correctly: the generated
// shader interpolates both by hand with `textureLoad`,
// because this pipeline binds no sampler and adding one
// for two lookups would cost a binding in every shader.
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 5,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
],
})
}
/// Compile a composed shader, or return the cached pipeline.
///
/// Compilation errors carry the generated source, since a stray line
/// number against code nobody wrote is otherwise very hard to act on.
fn pipeline(&mut self, shader: &ComposedShader) -> Result<&wgpu::ComputePipeline, GpuError> {
if !self.cache.contains_key(&shader.structure_hash) {
// A validation error here is a codegen bug, not a user error.
// Push an error scope so it surfaces as a Result rather than a
// panic from wgpu's default handler.
//
// Since wgpu 29 the scope is a guard rather than a device-level
// push/pop pair, which is the better shape: an early return from
// this function pops it on drop instead of leaving a scope open on
// the device for whatever ran next to fall into.
let scope = self
.ctx
.device
.push_error_scope(wgpu::ErrorFilter::Validation);
let module = self
.ctx
.device
.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("adjust-generated"),
source: wgpu::ShaderSource::Wgsl(shader.source.as_str().into()),
});
// The layout matching what this shader was composed to write. The
// structure hash covers the generated source and the source
// carries the storage format, so the two can never disagree — a
// cached pipeline is always paired with the layout it was built
// against.
let layout = match shader.output_mode {
OutputMode::Encoded => &self.pipeline_layout,
OutputMode::LinearWorking => &self.linear_pipeline_layout,
OutputMode::CameraLinear => &self.camera_pipeline_layout,
};
let pipeline =
self.ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("adjust-pipeline"),
layout: Some(layout),
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
if let Some(err) = pollster::block_on(scope.pop()) {
return Err(GpuError::ShaderCompilation(format!(
"{err}\n\n--- generated source ---\n{}",
numbered(&shader.source)
)));
}
self.cache.insert(shader.structure_hash, pipeline);
}
Ok(self
.cache
.get(&shader.structure_hash)
.expect("just inserted"))
}
/// Move to the other output texture and make sure it is the right size.
///
/// The rotation is unconditional; the reallocation is not. Steady-state
/// rendering at one viewport size therefore allocates nothing and simply
/// ping-pongs between two textures — see [`Self::targets`] for why there
/// are two. A resize reallocates whichever one comes up next, so the two
/// converge on the new size over two frames rather than in one lump.
fn ensure_target(&mut self, width: u32, height: u32) {
self.current ^= 1;
let slot = &mut self.targets[self.current];
if slot
.as_ref()
.is_some_and(|t| t.width == width && t.height == height)
{
return;
}
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-output"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
// STORAGE_BINDING to write from compute, TEXTURE_BINDING so the
// compositor can sample it, COPY_SRC for `export_pixels`.
//
// RENDER_ATTACHMENT is never used by this pass and is required
// anyway: Slint rejects an imported texture that lacks it
// (`TextureImportError::InvalidUsage`), because a compositor
// handed a texture has to assume it may need to draw into it. The
// format is likewise not a free choice — `Rgba8Unorm` and
// `Rgba8UnormSrgb` are the only two the import accepts, which is
// why `FORMAT` is what it is.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.targets[self.current] = Some(Target {
texture,
view,
width,
height,
});
}
/// Render one frame at the requested output size.
///
/// `width`/`height` are the *display* size, which is normally far smaller
/// than the image. Rendering at viewport resolution rather than sensor
/// resolution is what keeps slider interaction inside the frame budget
/// (FR-DSP-1).
pub fn render(
&mut self,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> Result<&wgpu::Texture, GpuError> {
self.render_masked(source, shader, width, height, None)
}
/// TRACES: FR-DEV-3
/// Render one frame with local adjustments applied.
///
/// `masks` must be the array [`crate::MaskPass`] rasterised for *this*
/// edit: the generated shader addresses slices by index, and an array
/// built from a different stack applies each layer's adjustment through
/// another layer's mask. Passing `None` is correct only for an edit with
/// no active mask layers.
pub fn render_masked(
&mut self,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
masks: Option<&crate::MaskArray>,
) -> Result<&wgpu::Texture, GpuError> {
if shader.output_mode != OutputMode::Encoded {
// Composed for a detail stage and dispatched without one. The
// shader writes `rgba16float` and this path binds an `rgba8unorm`
// storage texture, which wgpu rejects — but well after the point
// where the mistake is legible. Saying so here names the actual
// error: the edit has a neighbourhood operation and needs
// `render_detailed`.
return Err(GpuError::ShaderCompilation(
"this shader was composed with a detail stage and writes linear \
working values; render it with `render_detailed` and the \
matching chain from `EditGraph::compose_detail`"
.into(),
));
}
// Any render that does not write the linear intermediate leaves
// whatever is in it belonging to some other edit — or some other
// photograph. Forgetting this is how a detail chain comes to be run
// over a stale colour result, so the key is dropped rather than
// reasoned about.
self.colour_key = None;
let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height);
let uniforms = Self::fused_uniforms(source, shader);
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-params"),
contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
// Borrow order: compile first, since `pipeline` takes &mut self.
let _ = self.pipeline(shader)?;
let pipeline = self
.cache
.get(&shader.structure_hash)
.expect("compiled above");
let target = self.targets[self.current].as_ref().expect("ensured above");
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&target.view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(
masks.map_or(&self.empty_masks, |m| m.view()),
),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(self.film_curves_view()),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
},
],
});
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("adjust-encoder"),
});
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("adjust-pass"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
self.ctx.queue.submit(Some(enc.finish()));
self.colour_dispatches += 1;
Ok(&self.targets[self.current]
.as_ref()
.expect("ensured above")
.texture)
}
/// TRACES: FR-DEV-3 | FR-DEV-3d | FR-DEV-4 | FR-DSP-1
/// Render one frame with a neighbourhood stage.
///
/// `shader` and `detail` must be the two halves of **one** composition —
/// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same
/// graph, at the same output space. The fused pass stops at linear working
/// values when a detail stage exists and the last detail pass performs the
/// output transform, so a mismatched pair either encodes twice or not at
/// all.
///
/// An empty `detail` falls through to [`Self::render_masked`], which is
/// the honest thing to do rather than an optimisation: an edit with no
/// active sharpening *is* an ordinary edit, and it should cost exactly
/// what one costs.
///
/// # `colour_key`, and why the caller supplies it
///
/// It is `Invalidation::through(Affects::Colour)` for this edit, mixed
/// with whatever names the photograph — a `VersionId`, typically. When it
/// is unchanged, and the size and the composed shader and its uniforms are
/// unchanged with it, the fused dispatch is **skipped** and the linear
/// intermediate from the previous frame is convolved again. Dragging a
/// sharpening slider then costs the detail passes alone, which is the
/// reuse FR-DEV-3d asks for and the operational meaning of
/// `Affects::Detail`.
///
/// The caller supplies it rather than this pass deriving it because only
/// the caller knows which *image* is on screen. Everything else that goes
/// into the fused dispatch — the shader's structure, its uniform values,
/// the output size — is mixed in here, so a caller cannot make the reuse
/// unsound by supplying a key that is merely coarse. It can only do so by
/// supplying one that fails to distinguish two photographs, which is why
/// the identity of the image is spelled out as its job.
// Eight arguments, and every one of them is a distinct thing the render
// depends on: the image, both halves of the composition, the size, the
// masks and the cache key. Bundling them into a struct would move the
// problem rather than solve it — the caller would fill in the same eight
// fields — and would hide that composing the two halves apart is the one
// mistake this signature exists to make visible.
#[allow(clippy::too_many_arguments)]
pub fn render_detailed(
&mut self,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
masks: Option<&crate::MaskArray>,
detail: &ComposedDetail,
colour_key: u64,
) -> Result<&wgpu::Texture, GpuError> {
if detail.is_empty() {
return self.render_masked(source, shader, width, height, masks);
}
if shader.output_mode != OutputMode::LinearWorking {
return Err(GpuError::ShaderCompilation(
"this detail chain expects a fused pass composed to hand on \
linear working values, but the shader given encodes its own \
output; compose both halves from the same graph"
.into(),
));
}
let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height);
let uniforms = Self::fused_uniforms(source, shader);
let key = Self::colour_signature(colour_key, shader, &uniforms, masks);
let reuse = self.colour_key == Some((key, width, height));
// Compile before borrowing anything: `pipeline` and `colour_target`
// both want `&mut self`, and the second holds its borrow across the
// encode below.
self.pipeline(shader)?;
let colour_view = self
.detail
.colour_target(detail.len(), width, height)
.clone();
// Cloned for the same reason `colour_view` is: `self.detail` is
// borrowed mutably across the encode below, so the film views cannot
// be read off `self` at the point the bind group is built.
let film_curves = self.film_curves_view().clone();
let film_lut = self.film_lut_view().clone();
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("adjust-detail-encoder"),
});
if !reuse {
let params_buf =
self.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-params"),
contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-linear-bg"),
layout: &self.linear_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&colour_view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(
masks.map_or(&self.empty_masks, |m| m.view()),
),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(&film_curves),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&film_lut),
},
],
});
let pipeline = self
.cache
.get(&shader.structure_hash)
.expect("compiled above");
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("adjust-pass"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
drop(pass);
self.colour_dispatches += 1;
}
// One encoder for the colour pass and every detail pass, submitted
// once — the shape `MaskPass::render` established. Submission order is
// the whole of the synchronisation: each pass reads what the previous
// one wrote, through the same queue.
let target_view = self.targets[self.current]
.as_ref()
.expect("ensured above")
.view
.clone();
let ran = self
.detail
.encode(&mut enc, detail, &target_view, width, height)?;
self.ctx.queue.submit(Some(enc.finish()));
self.detail_dispatches += ran;
self.colour_key = Some((key, width, height));
Ok(&self.targets[self.current]
.as_ref()
.expect("ensured above")
.texture)
}
/// The fused pass's uniform block, with the source's own values written in.
///
/// Split out because both render paths need exactly this and a second copy
/// would eventually disagree about where the camera matrix goes — which is
/// silent, and corrupts every operation's uniforms downstream of it.
fn fused_uniforms(source: &DemosaicedImage, shader: &ComposedShader) -> Vec<f32> {
// Base uniforms: the camera matrix and as-shot white balance, which
// every generated shader reads regardless of which operations are
// active. Framing's slots follow them and are filled by the composer,
// which is why only the first sixteen are written here.
let mut uniforms = shader.uniforms.clone();
if uniforms.len() < RESERVED_FIELDS {
uniforms.resize(RESERVED_FIELDS, 0.0);
}
let m = source.color_matrix();
let wb = source.as_shot_wb();
// Rows padded to vec4 for std140 alignment.
uniforms[0..4].copy_from_slice(&[m[0], m[1], m[2], 0.0]);
uniforms[4..8].copy_from_slice(&[m[3], m[4], m[5], 0.0]);
uniforms[8..12].copy_from_slice(&[m[6], m[7], m[8], 0.0]);
// The fourth slot is the non-linear flag, not padding: it tells the
// shader whether to linearise the sampled texel before any operation
// runs. See `DemosaicedImage::is_non_linear`.
let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 };
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]);
// TRACES: FR-DEV-3e
// The camera profile's base curve, packed the way the generated block
// declares it: four x, four y, then the fifth point and the flag. The
// flag is what lets one compiled shader serve a profiled body and an
// unprofiled one, so the pipeline cache is not split in two by which
// camera took the frame.
//
// Written here rather than at the call site so that *both* callers —
// the plain render and the masked one — carry the profile. Filling it
// at one of them was how the two halves of this merge each had it.
let curve = source.base_curve();
let on = if curve.is_identity() { 0.0 } else { 1.0 };
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]);
uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]);
uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]);
uniforms
}
/// TRACES: FR-DEV-3d
/// Everything the fused dispatch depends on, in one integer.
///
/// The caller's edit key, plus the three things the caller does not know
/// about: which pipeline was compiled, what was uploaded to it, and which
/// mask array was bound. Hashing the uniforms rather than trusting the
/// caller's key to cover them is what makes the reuse safe against a
/// caller whose key is coarser than it should be — and the uniforms are
/// parameters and matrix coefficients from the CPU, never rendered floats,
/// so hashing their bit patterns satisfies ARCH §6.13.
fn colour_signature(
caller: u64,
shader: &ComposedShader,
uniforms: &[f32],
masks: Option<&crate::MaskArray>,
) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut mix = |v: u64| {
for byte in v.to_le_bytes() {
h ^= u64::from(byte);
h = h.wrapping_mul(0x100_0000_01b3);
}
};
mix(caller);
mix(shader.structure_hash);
for v in uniforms {
// Negative zero folded onto zero: the two render identically, and
// a slider that reached zero from below must not miss the cache.
mix(u64::from(if *v == 0.0 { 0 } else { v.to_bits() }));
}
match masks {
None => mix(0),
Some(m) => {
let (w, h) = m.size();
mix(1);
mix(u64::from(w));
mix(u64::from(h));
mix(u64::from(m.layers()));
}
}
h
}
/// TRACES: FR-PLAT-AND-5 | NFR-RES-1
/// Give back every allocation this pass is holding only to be fast again.
///
/// What goes, and why each is safe to lose:
///
/// - **The compiled pipelines**, here and in the detail stage. A pure
/// lookup keyed by structure hash with a compile-on-miss behind it, and
/// unbounded until now — nothing ever removed an entry, so a session
/// that visited enough distinct edit structures accumulated shader
/// objects for the life of the process.
/// - **The detail intermediates**, which are viewport-sized `Rgba16Float`
/// and, as `detail.rs` says of them, grow but never shrink.
/// - **The two output textures.** Dropping these does not take the picture
/// off the screen: whatever was handed to the compositor holds its own
/// reference to the `wgpu::Texture`, so releasing ours only means the
/// *next* render allocates rather than reuses. `ensure_target` already
/// treats an empty slot as "allocate", because that is the state it
/// starts in.
///
/// **`colour_key` must be cleared with them, and this is the part that
/// would bite.** The key is the promise that slot 0 of the detail pool
/// still holds the fused colour result, and it is what lets a sharpening
/// slider skip the colour chain (FR-DEV-3d). Freeing the pool while the
/// promise stood would make the next detail-only render sample a
/// just-allocated texture with nothing in it — a silently wrong frame, not
/// a failure, and one that would only appear on a device under memory
/// pressure.
///
/// What deliberately stays: the demosaiced source is not this pass's to
/// drop, the film tables are set once by a caller that will not be asked
/// again, and the bind group layouts are bytes rather than megabytes.
pub fn release_caches(&mut self) {
self.cache.clear();
self.detail.release_caches();
self.targets = [None, None];
self.colour_key = None;
}
/// How many distinct pipelines are compiled. Exposed for tests asserting
/// that slider movement does not recompile.
pub fn cached_pipelines(&self) -> usize {
self.cache.len()
}
/// How many detail-pass pipelines are compiled. As above, for the stage
/// that runs after this one.
pub fn cached_detail_pipelines(&self) -> usize {
self.detail.cached_pipelines()
}
/// TRACES: FR-DEV-3d
/// Fused colour dispatches encoded since this pass was created.
///
/// Exists to be asserted on. The saving `Affects::Detail` buys — a
/// sharpening slider that does not re-run the colour chain — is invisible
/// in the output by construction, since the picture is meant to be
/// identical either way. A counter is the only thing that can see it.
pub fn colour_dispatches(&self) -> usize {
self.colour_dispatches
}
/// Detail dispatches encoded since this pass was created.
pub fn detail_dispatches(&self) -> usize {
self.detail_dispatches
}
/// How many linear intermediates have been allocated. For tests: see
/// [`crate::MaskPass::allocations`] for the regression this catches.
pub fn detail_allocations(&self) -> usize {
self.detail.allocations()
}
/// The texture the last render wrote, if there has been one.
pub fn output(&self) -> Option<&wgpu::Texture> {
self.targets[self.current].as_ref().map(|t| &t.texture)
}
/// TRACES: FR-EXP-9 | AC-8
/// Copy the output to the CPU **for export**.
///
/// This method had a twin, `read_output`, which performed exactly the same
/// transfer for the display path. Spike S1 deleted the twin and left this
/// one, and the difference between them is worth writing down because it
/// is the whole of AC-8.
///
/// Reading pixels back to *display* them is what ARCH §6.1 forbids: the
/// compositor could have sampled that texture where it stood, and the
/// round-trip cost 96% of the frame at 4K — ~7 ms against a 0.28 ms
/// compute pass. There is now no method that does it, which is a stronger
/// guarantee than a feature gate: the display readback cannot be called
/// back into existence by turning something on.
///
/// Reading them back to *encode a file* is not a shortcut around anything.
/// A JPEG is made of bytes on the CPU and there is no path to one that
/// does not pass through here, so this is ungated and belongs in a
/// shipping build.
pub fn export_pixels(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
self.copy_output()
}
/// TRACES: FR-MRG-2
/// Render the camera-space tap: the source after its lens warp and
/// nothing else, at full precision.
///
/// `shader` must come from `EditGraph::compose_camera_linear` — it is
/// refused otherwise, for the reason `render_masked` refuses a linear
/// one: the storage format is in the layout. The profile uniforms are
/// filled neutral here rather than from the source, which is the whole
/// point of the mode (`OutputMode::CameraLinear`): unit white balance,
/// identity matrix, base curve off. The non-linear flag is kept, so a
/// JPEG source is still linearised — camera space for a JPEG is the
/// decoded values made linear, which is the best that exists.
///
/// The texture stays on the device for a merge's warp to sample; see
/// [`Self::camera_texture`] and [`Self::read_camera_linear`].
pub fn render_camera_linear(
&mut self,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> Result<&wgpu::Texture, GpuError> {
if shader.output_mode != OutputMode::CameraLinear {
return Err(GpuError::ShaderCompilation(
"render_camera_linear takes the shader from EditGraph::compose_camera_linear \
and no other; this one writes a different format"
.into(),
));
}
self.colour_key = None;
let (width, height) = (width.max(1), height.max(1));
self.ensure_camera_target(width, height);
let mut uniforms = Self::fused_uniforms(source, shader);
// Neutral profile: the numbers the sensor produced, and only those.
let non_linear = uniforms[15];
uniforms[0..4].copy_from_slice(&[1.0, 0.0, 0.0, 0.0]);
uniforms[4..8].copy_from_slice(&[0.0, 1.0, 0.0, 0.0]);
uniforms[8..12].copy_from_slice(&[0.0, 0.0, 1.0, 0.0]);
uniforms[12..16].copy_from_slice(&[1.0, 1.0, 1.0, non_linear]);
let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET;
uniforms[b + 10] = 0.0;
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-camera-params"),
contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let _ = self.pipeline(shader)?;
let pipeline = self
.cache
.get(&shader.structure_hash)
.expect("compiled above");
let target = self.camera_target.as_ref().expect("ensured above");
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-camera-bg"),
layout: &self.camera_bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&target.view),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.empty_masks),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::TextureView(self.film_curves_view()),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(self.film_lut_view()),
},
],
});
let mut enc = self
.ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("adjust-camera-encoder"),
});
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("adjust-camera-pass"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
self.ctx.queue.submit(Some(enc.finish()));
self.colour_dispatches += 1;
Ok(&self.camera_target.as_ref().expect("ensured above").texture)
}
/// The camera-space texture, if one has been rendered.
pub fn camera_texture(&self) -> Option<&wgpu::Texture> {
self.camera_target.as_ref().map(|t| &t.texture)
}
/// TRACES: FR-MRG-2
/// Read the camera-space tap back: tightly packed RGBA `f32`,
/// `width * height * 4` values, alpha 1.0 everywhere.
pub fn read_camera_linear(&self) -> Result<(Vec<f32>, u32, u32), GpuError> {
let Some(target) = self.camera_target.as_ref() else {
return Err(GpuError::Readback("no camera-space render yet".into()));
};
let (bytes, w, h) = Self::copy_texture(&self.ctx, &target.texture, w_h(target), 16)?;
let floats: Vec<f32> = bytes
.chunks_exact(4)
.map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
.collect();
Ok((floats, w, h))
}
fn ensure_camera_target(&mut self, width: u32, height: u32) {
if self
.camera_target
.as_ref()
.is_some_and(|t| t.width == width && t.height == height)
{
return;
}
let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-camera-output"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::CAMERA_FORMAT,
// Written by compute, sampled by a merge's warp, copied out for
// the CPU. Never handed to the compositor, so no RENDER_ATTACHMENT.
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.camera_target = Some(Target {
texture,
view,
width,
height,
});
}
/// The transfer itself.
fn copy_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
let Some(target) = self.targets[self.current].as_ref() else {
return Err(GpuError::Readback("nothing rendered yet".into()));
};
Self::copy_texture(&self.ctx, &target.texture, w_h(target), 4)
}
/// Copy a whole texture to the CPU, `bytes_per_pixel` wide, rows
/// unpadded. Shared by the display readback and the camera-space one.
fn copy_texture(
ctx: &GpuContext,
texture: &wgpu::Texture,
(w, h): (u32, u32),
bytes_per_pixel: u32,
) -> Result<(Vec<u8>, u32, u32), GpuError> {
let unpadded = w * bytes_per_pixel;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("adjust-readback"),
size: (padded * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
// Polled rather than parked, and bounded rather than spun forever —
// see `readback::await_mapping`, which the histogram's own transfer
// shares for exactly the same reasons.
await_mapping(ctx, &rx)?;
let data = slice.get_mapped_range();
let mut out = Vec::with_capacity((unpadded * h) as usize);
for row in 0..h {
let start = (row * padded) as usize;
out.extend_from_slice(&data[start..start + unpadded as usize]);
}
drop(data);
buf.unmap();
Ok((out, w, h))
}
}
fn w_h(t: &Target) -> (u32, u32) {
(t.width, t.height)
}
/// Number the lines of generated source, so a compiler error can be located.
pub(crate) fn numbered(src: &str) -> String {
src.lines()
.enumerate()
.map(|(i, l)| format!("{:>4} | {l}", i + 1))
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_pipeline::ops::{colour_mixer, exposure, saturation};
use dr_pipeline::EditGraph;
// For `Operation::detail`, which is how `the_whole_chain_at_once_compiles`
// asks the chain which of its operations are neighbourhood operations
// rather than being told a list. Imported anonymously: nothing here names
// the trait, only calls through it.
use crate::Demosaicer;
fn ctx() -> Option<GpuContext> {
match pollster::block_on(GpuContext::new_headless()) {
Ok(c) => Some(c),
Err(e) => {
eprintln!("skipping: no GPU adapter ({e})");
None
}
}
}
/// A flat mid-grey image, so an operation's effect is unambiguous.
fn grey_image(ctx: &GpuContext, level: u16) -> DemosaicedImage {
let size = 16u32;
let mut data = vec![0u16; (size * size) as usize];
for v in data.iter_mut() {
*v = level;
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
// Identity, so the test reasons about the operations alone
// rather than about a camera's colour response.
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
/// A white disc on black, centred in a `w`x`h` frame.
///
/// The one shape that makes anisotropy unmissable: any transform that
/// scales the axes unequally returns it as an ellipse, and the ratio of
/// the ellipse's axes *is* the error.
fn disc_rgba(w: u32, h: u32, radius: f32) -> Vec<u8> {
let mut rgba = vec![0u8; (w * h * 4) as usize];
for y in 0..h {
for x in 0..w {
let dx = x as f32 - w as f32 / 2.0;
let dy = y as f32 - h as f32 / 2.0;
let v = if (dx * dx + dy * dy).sqrt() < radius {
255
} else {
0
};
let i = ((y * w + x) * 4) as usize;
rgba[i] = v;
rgba[i + 1] = v;
rgba[i + 2] = v;
rgba[i + 3] = 255;
}
}
rgba
}
fn read_centre(ctx: &GpuContext, tex: &wgpu::Texture) -> [u8; 4] {
let (w, h) = (tex.width(), tex.height());
read_pixel(ctx, tex, w / 2, h / 2)
}
/// One pixel, by coordinate. What the geometry tests need: proving a
/// rotation moved content requires looking somewhere other than the
/// centre, which every rotation leaves fixed.
fn read_pixel(ctx: &GpuContext, tex: &wgpu::Texture, x: u32, y: u32) -> [u8; 4] {
let w = tex.width();
let h = tex.height();
let unpadded = w * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("adjust-readback"),
size: (padded * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut enc = ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(h),
},
},
wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
);
ctx.queue.submit(Some(enc.finish()));
let slice = buf.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| {
let _ = tx.send(r);
});
ctx.device
.poll(wgpu::PollType::wait_indefinitely())
.expect("poll");
rx.recv().expect("map").expect("map ok");
let data = slice.get_mapped_range();
let off = (y.min(h - 1) * padded + x.min(w - 1) * 4) as usize;
let px = [data[off], data[off + 1], data[off + 2], data[off + 3]];
drop(data);
buf.unmap();
px
}
#[test]
fn a_neutral_graph_produces_a_compilable_shader() {
// The first thing that could go wrong with codegen: the empty case.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
pass.render(&img, &shader, 16, 16)
.expect("a neutral chain must compile");
}
#[test]
fn every_operation_generates_compilable_wgsl() {
// The test that justifies the whole codegen approach. Each operation
// is compiled on its own, so a WGSL error names the operation that
// caused it rather than surfacing only in some combination.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
// Cases derived from the chain itself rather than a hand-written
// list: every parameter of every operation is exercised, and adding
// an operation extends the coverage automatically instead of
// silently going untested.
let probe = EditGraph::default_chain();
for cap in probe.capabilities() {
for p in &cap.params {
let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind else {
continue;
};
// Both extremes: a fragment can be valid at one end of its
// range and not the other.
for value in [min, max] {
let mut g = EditGraph::default_chain();
g.set_param(cap.id, p.id, value);
let shader = g.compose();
// A neighbourhood operation compiles as a *chain*, not
// as a fragment: it contributes nothing to the fused pass,
// and the fused pass in turn stops short of the output
// transform so the last detail pass can perform it. Going
// through `render_detailed` covers both kinds with one
// loop, which is the property that makes this test extend
// itself when an operation is added.
//
// Compiling only the fused half would leave every kernel
// untested here — and worse, `render` refuses a shader
// composed to hand on linear working values, so the
// omission would arrive as "invalid WGSL" against a shader
// that is perfectly valid.
//
// The scale comes from the graph, so the kernel is
// converted the way a real render converts it. Mind the
// size: a radius stated in source pixels can decide there
// is nothing to draw at sixteen pixels
// (`RenderScale::resolves`) and compile its pass-through
// instead of the kernel under test. The chain still
// carries the resolve pass that finishes the render, and
// that generated source is worth compiling too.
let detail = g.compose_detail(img.size(), (16, 16));
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
pass.render_detailed(&img, &shader, 16, 16, None, &detail, key)
.unwrap_or_else(|e| {
panic!(
"{}.{} at {value} generated invalid WGSL:\n{e}",
cap.id, p.id
)
});
}
}
}
}
/// An image bright on one side and dark on the other, so a transform that
/// moves content is visible. A flat grey cannot show a rotation at all.
///
/// `vertical` puts the bright band at the top; otherwise at the left.
fn split_image(ctx: &GpuContext, vertical: bool) -> DemosaicedImage {
let size = 32u32;
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
let near_start = if vertical { y } else { x } < size / 2;
data[(y * size + x) as usize] = if near_start { 12000 } else { 500 };
}
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
#[test]
fn a_quarter_turn_moves_a_vertical_edge_to_a_horizontal_one() {
// The end-to-end check that the coordinate permutation is wired the
// right way round. A left-bright image turned 90° clockwise must come
// out top-bright; getting the sign wrong yields bottom-bright, which
// compiles perfectly and is simply the wrong image.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.rotate_quarters(1);
let (w, h) = g.output_size(32, 32);
let shader = g.compose();
let tex = pass.render(&img, &shader, w, h).expect("render");
let top = read_pixel(&ctx, tex, w / 2, h / 8)[0];
let bottom = read_pixel(&ctx, tex, w / 2, h * 7 / 8)[0];
assert!(
top > bottom + 40,
"a left-bright image turned 90° clockwise should be top-bright, \
got top={top} bottom={bottom}"
);
}
#[test]
fn a_horizontal_flip_swaps_the_sides() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::FLIP_H, 1.0);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
let left = read_pixel(&ctx, tex, 4, 16)[0];
let right = read_pixel(&ctx, tex, 28, 16)[0];
assert!(
right > left + 40,
"flipping a left-bright image should make it right-bright, \
got left={left} right={right}"
);
}
#[test]
fn zooming_shows_only_the_region_looked_at() {
// Zoom is a coordinate map, and a map that type-checks can still
// sample the wrong place. Checked against content: zoomed into the
// bright half the frame must be bright edge to edge, and into the
// dark half, dark — which a wrong origin or extent would break.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let left_near = read_pixel(&ctx, tex, 4, 16)[0];
let left_far = read_pixel(&ctx, tex, 28, 16)[0];
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.8,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let right_near = read_pixel(&ctx, tex, 4, 16)[0];
assert!(
left_far > 100 && left_near > 100,
"zoomed into the bright half, both edges should be bright: \
near={left_near} far={left_far}"
);
assert!(
left_near > right_near + 40,
"zooming to the far side should show the dark half: \
left={left_near} right={right_near}"
);
}
#[test]
fn zooming_does_not_recompile() {
// The property that makes scroll-wheel zoom smooth: a new zoom *level*
// is a uniform upload, never a pipeline build. If the magnitude reached
// the structure hash, every wheel notch would stall on a compile.
//
// Entering the zoom at all is the one exception, and it is deliberate
// — see `zooming_after_an_unzoomed_render_actually_zooms`. So the walk
// below starts already zoomed, and the count is taken from there.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.0,
width: 0.5,
height: 0.5,
});
pass.render(&img, &g.compose(), 32, 32).expect("render");
let baseline = pass.cached_pipelines();
for (i, extent) in [0.4f32, 0.25, 0.125].iter().enumerate() {
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.0,
width: *extent,
height: *extent,
});
pass.render(&img, &g.compose(), 32, 32).expect("render");
assert_eq!(
pass.cached_pipelines(),
baseline,
"zoom step {i} compiled a second pipeline"
);
}
}
#[test]
fn zooming_after_an_unzoomed_render_actually_zooms() {
// The regression: every earlier zoom test set a view *before* the first
// render, so the first pipeline compiled was already the one carrying
// the crop mapping. Real use is the other way round — the image is
// shown fitted, and only then does the wheel turn.
//
// A neutral framing emits a prologue that never reads `u.crop_rect`.
// While zoom was excluded from the structure hash, that neutral
// pipeline stayed cached under the same key once zoomed, so the view
// uploaded on every frame was read by nobody and the canvas never
// changed. This renders unzoomed first and asserts the pixels move.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
// Fitted: the frame spans both halves, so the two edges differ.
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let fitted_left = read_pixel(&ctx, tex, 4, 16)[0];
let fitted_right = read_pixel(&ctx, tex, 28, 16)[0];
assert!(
(i32::from(fitted_left) - i32::from(fitted_right)).abs() > 40,
"the unzoomed frame should span both halves: \
left={fitted_left} right={fitted_right}"
);
// Now zoom into the bright half. Both edges must come up bright.
g.framing_mut().set_view(dr_pipeline::CropRect {
x: 0.0,
y: 0.4,
width: 0.2,
height: 0.2,
});
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let zoomed_left = read_pixel(&ctx, tex, 4, 16)[0];
let zoomed_right = read_pixel(&ctx, tex, 28, 16)[0];
assert!(
zoomed_left > 100 && zoomed_right > 100,
"zooming into the bright half after an unzoomed render must show \
it edge to edge — the neutral pipeline was reused and the view \
was ignored: left={zoomed_left} right={zoomed_right}"
);
}
#[test]
fn cropping_to_one_half_shows_only_that_half() {
// The property a crop exists for, checked against content rather than
// against the output dimensions alone: a crop of the dark side must
// be dark everywhere, edge to edge.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_crop(dr_pipeline::CropRect {
x: 0.5,
y: 0.0,
width: 0.5,
height: 1.0,
});
let (w, h) = g.output_size(32, 32);
assert_eq!((w, h), (16, 32), "half a 32px frame is 16px wide");
let shader = g.compose();
let tex = pass.render(&img, &shader, w, h).expect("render");
assert_eq!((tex.width(), tex.height()), (16, 32));
for x in [1, w / 2, w - 2] {
let v = read_pixel(&ctx, tex, x, h / 2)[0];
assert!(v < 90, "cropped to the dark half, x={x} came out {v}");
}
}
#[test]
fn straightening_darkens_the_exposed_corners() {
// Rotating a frame inside its own bounds leaves no source pixel at the
// corners. They must read black rather than a smeared edge pixel — the
// difference between "the frame is rotated" and "the image is smudged".
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, false);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 30.0);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
// The top-left corner of a 30° rotation is off the source.
let corner = read_pixel(&ctx, tex, 0, 0);
assert_eq!(
corner,
[0, 0, 0, 255],
"an exposed corner must be black and opaque"
);
}
/// TRACES: FR-DEV-20
#[test]
fn a_keystone_reshapes_the_frame_without_exposing_a_corner() {
// The shader half of perspective correction, end to end. A top-bright
// frame with a full vertical keystone spreads its top across the
// output, so the bright half reaches further down than the middle;
// and since the frame is mapped onto a trapezoid *inside* the source,
// no corner is left without a pixel behind it.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = split_image(&ctx, true);
let plain = EditGraph::default_chain().compose();
let tex = pass.render(&img, &plain, 32, 32).expect("render");
let below_middle = read_pixel(&ctx, tex, 16, 19)[0];
assert!(
below_middle < 90,
"unkeyed, row 19 is the dark half: {below_middle}"
);
let mut g = EditGraph::default_chain();
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_V,
100.0,
);
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_H,
100.0,
);
let shader = g.compose();
let tex = pass.render(&img, &shader, 32, 32).expect("render");
for (x, y) in [(0, 0), (31, 0), (0, 31), (31, 31)] {
assert_ne!(
read_pixel(&ctx, tex, x, y),
[0, 0, 0, 255],
"corner ({x},{y}) has no source pixel behind it"
);
}
let mut g = EditGraph::default_chain();
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::KEYSTONE_V,
100.0,
);
let tex = pass.render(&img, &g.compose(), 32, 32).expect("render");
let keyed = read_pixel(&ctx, tex, 16, 19)[0];
assert!(
keyed > 128,
"the spread top half must reach row 19: {keyed}"
);
}
#[test]
fn dragging_the_crop_does_not_recompile() {
// The cache contract for framing, which is what makes an interactive
// crop drag viable: the rect changes every frame, and each frame must
// reuse the compiled pipeline.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
for i in 1..=10 {
let inset = i as f32 * 0.02;
g.set_crop(dr_pipeline::CropRect {
x: inset,
y: inset,
width: 1.0 - 2.0 * inset,
height: 1.0 - 2.0 * inset,
});
let (w, h) = g.output_size(64, 64);
pass.render(&img, &g.compose(), w, h).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
1,
"ten crop rectangles must share one compiled pipeline"
);
}
#[test]
fn straightening_compiles_its_own_pipeline_but_reuses_it() {
// Straightening changes the sampling path from an integer load to a
// bilinear fetch, so it *must* compile a second pipeline — and then
// must stop at two however far the slider travels.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
pass.render(&img, &g.compose(), 32, 32).expect("render");
assert_eq!(pass.cached_pipelines(), 1);
for i in 1..=8 {
g.set_param(
dr_pipeline::framing::ID,
dr_pipeline::framing::ANGLE,
i as f32 * 0.5,
);
pass.render(&img, &g.compose(), 32, 32).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
2,
"straightening compiles one more pipeline, not one per angle"
);
}
/// Tables shaped like a real stock's, with values that are not.
///
/// This test is about whether the largest possible shader compiles and
/// dispatches, not about what it renders — `tests/film_sim.rs` is where
/// the pixels are checked against the model. Flat values keep the two
/// concerns apart.
fn film_test_tables() -> dr_pipeline::ops::FilmTables {
const N: usize = 32;
dr_pipeline::ops::FilmTables {
exposure_matrix: [[5.0, 0.5, 0.2], [0.1, 5.0, 0.3], [0.2, 0.5, 4.0]],
curves: vec![[0.5, 0.5, 0.5]; dr_pipeline::ops::film_sim::CURVE_SAMPLES],
curve_log_min: -3.0,
curve_log_max: 4.0,
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
density_max: 3.0,
lut_size: N,
grain_particles: [0.0; 3],
grain_density_max: [3.0; 3],
grain_uniformity: 0.97,
}
}
#[test]
fn the_whole_chain_at_once_compiles() {
// Individually-valid fragments can still collide when combined —
// duplicate helpers, clashing locals, a malformed uniform block. With
// every operation active this is the largest shader the pipeline can
// generate.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
for cap in EditGraph::default_chain().capabilities() {
for (i, p) in cap.params.iter().enumerate() {
if let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind {
// Stepped away from each parameter's own default by a
// varying fraction. A single shared value would leave the
// tone curve inactive: its neutral is the *relationship*
// between its points, so setting them all alike keeps it
// on the identity diagonal.
let step = (max - min) * (0.15 + 0.05 * (i % 4) as f32);
let v = if p.default + step <= max {
p.default + step
} else {
p.default - step
};
g.set_param(cap.id, p.id, v);
}
}
}
// `film_sim` is the one operation no parameter can activate: it needs
// a stock's measured tables. Loaded here so that "every operation at
// once" means what it says — and so that this test compiles the
// largest shader the pipeline can actually generate, which is the one
// with a film in it.
let tables = film_test_tables();
g.set_film(Some(dr_pipeline::graph::Film {
stock: "under_test".into(),
print: None,
tables: tables.clone(),
}));
pass.set_film(Some(&tables));
let shader = g.compose();
// At 512 rather than the 32 this test used before the detail stage
// existed, and the size is load-bearing twice over. A compositional
// radius is a fraction of the frame, so on a 32-pixel target every
// detail kernel rounds to nothing: the chain would compose no passes,
// leaving half the shader uncompiled, and the exclusive-or below would
// find those operations in neither stage and fail for a reason that is
// not a defect. Shadows the smaller size deliberately.
let (w, h) = g.output_size(512, 512);
let detail = g.compose_detail_for((512, 512), (w, h), dr_types::ColourSpace::Srgb);
assert!(
!detail.is_empty(),
"the detail half composed nothing, so nothing of it was compiled"
);
// Every operation has to reach the pipeline, but they do not all reach
// the same half of it, and which half is not this test's business to
// know: a point operation is a block in the fused shader, and a
// neighbourhood operation is one or more passes of the detail chain
// (`dr_pipeline::detail`) and contributes *no* fused block, because a
// fused fragment is handed a colour with no way back to a coordinate.
//
// Asserted as an exclusive or over the chain rather than as a count,
// so that adding either kind of operation extends this test on its own
// — and so that an operation which somehow managed both, or neither,
// is named rather than showing up as an arithmetic mismatch.
let mut fused_blocks = 0;
for desc in g.descriptors() {
let id = desc.id.0;
let point = shader.source.contains(&format!("---- {id} ----"));
let neighbourhood = detail
.passes
.iter()
.any(|p| p.label.starts_with(&format!("{id}/")));
assert!(
point ^ neighbourhood,
"{id} reaches {} of the two stages; every active operation \
belongs to exactly one",
if point { "both" } else { "neither" }
);
fused_blocks += usize::from(point);
}
// The lens corrections, which are the third kind of block. They are
// not in `descriptors` — they rewrite coordinates rather than
// transform a colour, so they are not operations — and they run ahead
// of the fetch rather than in either stage the loop above sorts into.
let mut warp_blocks = 0;
for desc in g.warp_descriptors() {
let id = desc.id.0;
assert!(
shader.source.contains(&format!("---- warp: {id} ----")),
"{id} was armed above and did not reach the shader"
);
warp_blocks += 1;
}
// The counts the loops above accumulated, plus framing — which emits a
// stage of its own rather than an operation block and is not in
// `descriptors`. Asserted as well as the per-operation exclusive-or
// because the two catch different faults: the XOR catches an operation
// in the wrong stage, this catches a block in the shader that nothing
// in the chain asked for.
assert_eq!(
shader.source.matches("---- ").count(),
fused_blocks + warp_blocks + 1,
"the fused shader carries a block nothing in the chain asked for"
);
assert!(
shader.source.contains("---- framing ----"),
"framing must reach the shader alongside the colour operations"
);
assert!(
!detail.is_empty(),
"with every operation active the detail stage must run"
);
// The other half of the same edit, and it belongs in this test for the
// reason the test exists: the detail passes are generated WGSL too,
// they carry their own uniform blocks, and "everything at once" is
// exactly where a collision between them would show. Rendering the
// fused half alone is no longer even legal — with a neighbourhood
// operation active the fused pass stops at linear working values and
// the last detail pass performs the output transform, which is the
// mismatch `render_detailed` exists to reject.
let key = g.invalidation().through(dr_pipeline::Affects::Colour);
pass.render_detailed(&img, &shader, w, h, None, &detail, key)
.expect("the full chain must compile");
}
#[test]
fn exposure_brightens_the_image() {
// Proves the uniforms actually reach the shader, not merely that it
// compiles.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 2000);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 2.0);
let brighter = g.compose();
let after = {
let t = pass.render(&img, &brighter, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(
after[0] > before[0],
"+2 stops should brighten: {before:?} -> {after:?}"
);
}
#[test]
fn negative_exposure_darkens_the_image() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 8000);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, -2.0);
let darker = g.compose();
let after = {
let t = pass.render(&img, &darker, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(after[0] < before[0], "-2 stops should darken");
}
#[test]
fn full_negative_saturation_produces_grey() {
// A neutral grey source cannot show this, so use a coloured one:
// a strongly red-weighted image must come out with equal channels.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let size = 16u32;
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
// RGGB: make red photosites bright, others dim.
let c = CfaPattern::Rggb.colour_at(x, y);
data[(y * size + x) as usize] = if c == 0 { 12000 } else { 3000 };
}
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
let img = Demosaicer::new(&ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic");
let mut g = EditGraph::default_chain();
g.set_param(saturation::ID, saturation::SATURATION, -100.0);
let shader = g.compose();
let px = {
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let spread = px[0].abs_diff(px[1]).max(px[1].abs_diff(px[2]));
assert!(
spread <= 2,
"-100 saturation must produce grey, got {px:?} (spread {spread})"
);
}
#[test]
fn each_colour_band_gets_its_own_pipeline() {
// The bug this closes, end to end and through one cache: the mixer
// emits code only for the bands that are set, but the cache key was
// the set of *active operations*, which is "colour_mixer" whichever
// band that is. A red adjustment and a blue one hashed alike, so the
// second render reused the first's compiled pipeline and uploaded its
// uniform into the first band's slot — whichever band compiled first
// kept acting and every other slider did nothing.
//
// Ordered red first deliberately: red is the first band declared, and
// is the one users reported as the only one that worked.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = blue_image(&ctx);
// `None` renders the chain untouched, which is the baseline the two
// band settings are measured against.
fn band(
ctx: &GpuContext,
pass: &mut AdjustPass,
img: &DemosaicedImage,
set: Option<(&'static str, f32)>,
) -> [u8; 4] {
let mut g = EditGraph::default_chain();
if let Some((id, v)) = set {
g.set_param(colour_mixer::ID, dr_pipeline::ParamId(id), v);
}
let shader = g.compose();
let t = pass.render(img, &shader, 16, 16).expect("render");
read_centre(ctx, t)
}
let neutral = band(&ctx, &mut pass, &img, None);
// Red first, so its pipeline is the one in the cache when blue asks.
let reds_turn = band(&ctx, &mut pass, &img, Some(("red_sat", 100.0)));
let blues_turn = band(&ctx, &mut pass, &img, Some(("blue_sat", -100.0)));
let spread = |p: [u8; 4]| p[2].abs_diff(p[0]);
assert_eq!(
spread(reds_turn),
spread(neutral),
"a blue pixel is outside the red band, so red must leave it alone"
);
assert!(
spread(blues_turn) + 8 < spread(neutral),
"blue at -100 must desaturate a blue pixel: {neutral:?} -> {blues_turn:?}"
);
}
/// A demosaiced image whose pixels sit at the centre of the blue band.
///
/// Red and green equal, blue well above them, which `rgb_to_hcl` reads as
/// exactly 240 degrees — full weight to blue, none to any other band.
fn blue_image(ctx: &GpuContext) -> DemosaicedImage {
let size = 16u32;
let mut data = vec![0u16; (size * size) as usize];
for y in 0..size {
for x in 0..size {
let c = CfaPattern::Rggb.colour_at(x, y);
data[(y * size + x) as usize] = if c == 2 { 12000 } else { 3000 };
}
}
let raw = RawImage {
width: size,
height: size,
data,
cfa_pattern: CfaPattern::Rggb,
black_level: [0; 4],
white_level: 16383,
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
base_curve: BaseCurve::IDENTITY,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
#[test]
fn moving_a_slider_does_not_recompile() {
// The property the pipeline cache exists for. Recompiling per frame
// would make slider interaction unusable regardless of shader cost.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
for i in 1..=10 {
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.2);
let shader = g.compose();
pass.render(&img, &shader, 16, 16).expect("render");
}
assert_eq!(
pass.cached_pipelines(),
1,
"ten slider positions must share one compiled pipeline"
);
}
#[test]
fn a_different_operation_set_compiles_its_own_pipeline() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
pass.render(&img, &g.compose(), 16, 16).expect("render");
assert_eq!(pass.cached_pipelines(), 1);
g.set_param(saturation::ID, saturation::SATURATION, 40.0);
pass.render(&img, &g.compose(), 16, 16).expect("render");
assert_eq!(pass.cached_pipelines(), 2);
// Returning to the earlier state must reuse, not compile a third.
g.set_param(saturation::ID, saturation::SATURATION, 0.0);
pass.render(&img, &g.compose(), 16, 16).expect("render");
assert_eq!(pass.cached_pipelines(), 2);
}
#[test]
fn output_is_opaque_everywhere() {
// A zero alpha would composite as an invisible image, which reads as
// "nothing rendered" rather than as a bug in this pass.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let t = pass.render(&img, &shader, 16, 16).expect("render");
assert_eq!(read_centre(&ctx, t)[3], 255);
}
/// TRACES: FR-DSP-1 | AC-8
/// A disc on a non-square frame, rendered through a quarter turn.
///
/// Geometry, asserted on real pixels rather than on the generated WGSL —
/// reading the shader and reasoning about which space `p` lives in is
/// exactly how a plausible formula gets written twice.
#[test]
fn a_quarter_turn_keeps_a_circle_circular() {
let Some(ctx) = ctx() else { return };
// 3:2, so an aspect mistake is a 2.25x distortion rather than a
// subtlety. The disc is centred and comfortably inside the frame.
let (w, h) = (180u32, 120u32);
let rgba = disc_rgba(w, h, 40.0);
let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload");
let mut graph = dr_pipeline::EditGraph::default_chain();
graph.rotate_quarters(1);
let (ow, oh) = graph.output_size(w, h);
assert_eq!((ow, oh), (h, w), "a quarter turn swaps the output axes");
let mut pass = AdjustPass::new(&ctx);
pass.render(&src, &graph.compose(), ow, oh).expect("render");
let (pixels, rw, rh) = pass.export_pixels().expect("read back");
// Measure the disc's extent along each axis, at its centre.
let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128;
let across = (0..rw).filter(|&x| lit(x, rh / 2)).count();
let down = (0..rh).filter(|&y| lit(rw / 2, y)).count();
assert!(across > 0 && down > 0, "the disc vanished: {across}x{down}");
let ratio = across as f32 / down as f32;
assert!(
(ratio - 1.0).abs() < 0.08,
"a turned circle came back {across} across by {down} down \
(ratio {ratio:.3}); anything but 1 is the frame being sheared"
);
}
/// The same disc, straightened by a free angle rather than turned.
///
/// A rotation is rigid: a circle stays a circle at any angle. If the
/// straighten happens in a space whose axes carry different scales, the
/// circle comes back as an ellipse — and on a photograph that reads as the
/// frame being sheared.
#[test]
fn straightening_keeps_a_circle_circular() {
let Some(ctx) = ctx() else { return };
let (w, h) = (180u32, 120u32);
let rgba = disc_rgba(w, h, 34.0);
let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload");
let mut graph = dr_pipeline::EditGraph::default_chain();
// A deliberate angle, not a nudge: a shear scales with the angle and
// a degree would hide inside the tolerance.
graph.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 20.0);
let (ow, oh) = graph.output_size(w, h);
let mut pass = AdjustPass::new(&ctx);
pass.render(&src, &graph.compose(), ow, oh).expect("render");
let (pixels, rw, rh) = pass.export_pixels().expect("read back");
let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128;
let across = (0..rw).filter(|&x| lit(x, rh / 2)).count();
let down = (0..rh).filter(|&y| lit(rw / 2, y)).count();
assert!(across > 0 && down > 0, "the disc vanished: {across}x{down}");
let ratio = across as f32 / down as f32;
assert!(
(ratio - 1.0).abs() < 0.08,
"a straightened circle came back {across} across by {down} down \
(ratio {ratio:.3}); a rotation is rigid, so anything but 1 is shear"
);
}
/// TRACES: FR-DEV-3 | FR-DSP-1
/// The same disc again, straightened *and* turned.
///
/// The case neither test above reaches, and the one a portrait photograph
/// hits every time. A quarter turn — the user's or the file's EXIF tag —
/// swaps the frame's axes, so the space the straightening happens in is no
/// longer the source's: measuring a 2:3 frame with a 3:2 aspect stretches
/// one axis against the other by 2.25, and the rotation that follows comes
/// out as a shear. Each transform alone looks right, which is exactly why
/// it survived: only the pair is wrong.
#[test]
fn straightening_a_turned_frame_keeps_a_circle_circular() {
let Some(ctx) = ctx() else { return };
let (w, h) = (180u32, 120u32);
let rgba = disc_rgba(w, h, 34.0);
let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload");
// Every route to a swapped frame: the button, the file's tag, and the
// two composed. All three reach the shader as one permutation, and a
// fix that only covers one of them is not a fix.
for (name, turns, tag) in [
("a user quarter turn", 1, 1u16),
("an EXIF-portrait file", 0, 6),
("both, composed", 2, 6),
] {
let mut graph = dr_pipeline::EditGraph::default_chain();
graph.set_orientation(dr_types::Orientation::from_exif(tag));
graph.rotate_quarters(turns);
graph.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 20.0);
let (ow, oh) = graph.output_size(w, h);
assert_eq!((ow, oh), (h, w), "{name}: the frame should be portrait");
let mut pass = AdjustPass::new(&ctx);
pass.render(&src, &graph.compose(), ow, oh).expect("render");
let (pixels, rw, rh) = pass.export_pixels().expect("read back");
let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128;
let across = (0..rw).filter(|&x| lit(x, rh / 2)).count();
let down = (0..rh).filter(|&y| lit(rw / 2, y)).count();
assert!(across > 0 && down > 0, "{name}: the disc vanished");
let ratio = across as f32 / down as f32;
assert!(
(ratio - 1.0).abs() < 0.08,
"{name}: a straightened circle came back {across} across by \
{down} down (ratio {ratio:.3}); the turn and the angle are \
disagreeing about which frame they act in"
);
}
}
#[test]
fn the_output_is_importable_by_a_compositor() {
// Every condition Slint checks before it will adopt a texture
// (`slint::wgpu_29`: `TextureImportError`). They are asserted here,
// in the crate that owns the descriptor, because failing them does not
// fail a build or a shader — it fails at runtime, on the frame the
// image is handed over, and only where there is a screen to hand it
// to. Nothing else in the test suite would notice.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let t = pass.render(&img, &shader, 16, 16).expect("render");
assert!(
matches!(
t.format(),
wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb
),
"import accepts only the two 8-bit RGBA formats, not {:?}",
t.format()
);
assert!(
t.usage().contains(wgpu::TextureUsages::TEXTURE_BINDING),
"the compositor has to sample it"
);
assert!(
t.usage().contains(wgpu::TextureUsages::RENDER_ATTACHMENT),
"Slint requires this even though the adjust pass never uses it"
);
}
/// TRACES: FR-DSP-1 | AC-8
#[test]
fn consecutive_frames_are_different_textures() {
// Not a detail: the compositor is handed this texture rather than a
// copy of its pixels, and Slint repaints only when the image property
// *changes*. Two images over one texture compare equal, so writing the
// same texture every frame would leave a slider moving the pixels on
// the GPU and nothing at all on screen — the frame would be correct
// and invisible, which is the worst kind of wrong.
//
// No display is needed to catch it, because the equality Slint tests
// is the equality asserted here.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let first = pass.render(&img, &shader, 16, 16).expect("render").clone();
let second = pass.render(&img, &shader, 16, 16).expect("render").clone();
assert_ne!(first, second, "the compositor cannot tell these two apart");
// And back again, so the alternation is a rotation between two rather
// than an allocation per frame — which at 4K would be 33 MB a frame.
let third = pass.render(&img, &shader, 16, 16).expect("render").clone();
assert_eq!(first, third, "a third texture was allocated");
}
#[test]
fn the_output_resizes_with_the_viewport() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let t = pass.render(&img, &shader, 64, 48).expect("render");
assert_eq!((t.width(), t.height()), (64, 48));
let t = pass.render(&img, &shader, 32, 96).expect("render");
assert_eq!((t.width(), t.height()), (32, 96));
}
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
/// decoded JPEG is.
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
let size = 16u32;
let mut data = Vec::with_capacity((size * size) as usize * 4);
for _ in 0..size * size {
data.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
}
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
#[test]
fn a_jpeg_survives_a_neutral_graph_unchanged() {
// The property the whole JPEG path rests on: decoding the transfer
// function on the way in and re-encoding on the way out must be exact
// inverses. If they are not, merely *opening* a JPEG in develop mode
// shifts its tones — the file would be altered by being looked at,
// which is far worse than the panel being disabled.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// Several levels: a transfer-function error is smallest in the
// mid-tones and largest near the ends, so one sample could miss it.
for level in [16u8, 64, 128, 200, 240] {
let img = jpeg_image(&ctx, [level, level, level]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, c) in got[..3].iter().enumerate() {
let delta = (i32::from(*c) - i32::from(level)).abs();
assert!(
delta <= 2,
"channel {i} at level {level} came back {c} (delta {delta}) \
— the transfer functions are not inverses"
);
}
}
}
#[test]
fn a_jpeg_keeps_its_colour_through_a_neutral_graph() {
// Identity colour matrix and neutral white balance, specifically: a
// camera matrix applied to an image already in sRGB primaries would
// skew colour, and this is what catches it. A grey patch cannot —
// every matrix maps neutral to neutral.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
let img = jpeg_image(&ctx, [200, 90, 40]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, expected) in [200u8, 90, 40].iter().enumerate() {
let delta = (i32::from(got[i]) - i32::from(*expected)).abs();
assert!(
delta <= 2,
"channel {i} expected ~{expected}, got {} — colour is being \
transformed on a source that needs no transform",
got[i]
);
}
}
#[test]
fn exposure_brightens_a_jpeg() {
// Proves the operations reach the JPEG path at all, and that they act
// on linearised values: an exposure stop is a multiply, which is only
// meaningful once the gamma encoding is undone.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = jpeg_image(&ctx, [110, 110, 110]);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
let brighter = g.compose();
let after = {
let t = pass.render(&img, &brighter, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(
after[0] > before[0],
"+1 stop should brighten a JPEG: {before:?} -> {after:?}"
);
// One stop on a linear value is a doubling, which after re-encoding
// lands near 1.5x the encoded value rather than 2x. Checking the
// magnitude is what distinguishes "linearised correctly" from
// "doubled the gamma-encoded value", which would blow straight to
// white — the exact bug a brightness-only assertion would miss.
assert!(
after[0] < 255,
"a stop from mid-grey must not clip: {} — the encoding was \
probably not undone before the multiply",
after[0]
);
}
#[test]
fn every_output_colour_space_renders_what_the_colorimetry_predicts() {
// TRACES: FR-EXP-2
// The shader carries constants generated from `dr_types::colour`; this
// recomputes the same conversion on the CPU and demands the GPU agree.
// A transposed matrix, a transfer function applied before the
// primaries, or a clip in the wrong place all compile perfectly and
// simply produce the wrong colour — none of which a "did it compile"
// test would notice.
//
// A saturated patch, deliberately: every one of these spaces maps a
// neutral to itself, so a grey would agree with all four.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let source = [200u8, 90, 40];
let img = jpeg_image(&ctx, source);
// The JPEG path linearises with the sRGB curve, so this is the value
// reaching the output stage.
let linear: Vec<f32> = source
.iter()
.map(|&v| dr_types::Transfer::Srgb.decode(f32::from(v) / 255.0))
.collect();
for space in dr_types::ColourSpace::ALL {
let shader = EditGraph::default_chain().compose_for(space);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
let m = space.from_linear_srgb();
for channel in 0..3 {
let converted = m[channel * 3] * linear[0]
+ m[channel * 3 + 1] * linear[1]
+ m[channel * 3 + 2] * linear[2];
let want = space.transfer().encode(converted.clamp(0.0, 1.0)) * 255.0;
let delta = (f32::from(got[channel]) - want).abs();
// Two levels: the pipeline stores its intermediate in f16 and
// the source itself came from an 8-bit texel, so exactness is
// not on offer. A wrong matrix is out by tens.
assert!(
delta <= 2.0,
"{space:?} channel {channel}: rendered {} against a predicted {want:.1} \
(whole pixel {got:?})",
got[channel]
);
}
}
}
#[test]
fn a_wide_gamut_render_differs_from_an_srgb_one() {
// The companion to the test above, and the one that would fail if the
// output space were accepted and then ignored: predicted values that
// happened to match sRGB's would prove nothing. A saturated red is
// several tens of levels apart in P3.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = jpeg_image(&ctx, [230, 30, 20]);
let srgb = {
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::Srgb);
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let p3 = {
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::DisplayP3);
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
// Less red and more green: the same colour expressed against wider
// primaries needs smaller numbers to reach it.
assert!(
p3[0] < srgb[0] && p3[1] > srgb[1],
"sRGB rendered {srgb:?} and Display P3 {p3:?}"
);
}
#[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
// The two producers must be interchangeable. A mid-grey that is
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
// must render the same, or an edit would mean different things
// depending on which decoder opened the file.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// sRGB 128 linearises to ~0.2159; against a 16383 white level that is
// sample ~3537.
let sensor = grey_image(&ctx, 3537);
let jpeg = jpeg_image(&ctx, [128, 128, 128]);
let from_sensor = {
let t = pass.render(&sensor, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let from_jpeg = {
let t = pass.render(&jpeg, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
assert!(
delta <= 3,
"the same scene value rendered {from_sensor:?} from sensor data \
and {from_jpeg:?} from a JPEG"
);
}
}