Files
DarkRoom/core/dr-gpu/src/adjust.rs
T
dtourolle 0007fa459f Develop a linear DNG from windows and reduced copies of it
DemosaicedImage::linear_rgb16_window uploads part of a linear DNG, or a
box-reduced copy of it, and says where it sits in the frame; size() now
reports the frame and texture_size() the texels, and the fused pass
writes the window into the shader's uniforms. EditGraph::source_region
finds the part of the source a view reads, and tiles::plan cuts a render
too large for one texture into halo-grown, grid-aligned tiles.

The GPU test renders frames a tile at a time from their own windows and
compares them with the whole: identical for point operations, within one
code value when straightened with clarity on.
2026-09-27 17:35:16 -04:00

3360 lines
140 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;
/// 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)>,
/// The source texels the fused pass read on an earlier frame, kept so a
/// slider drag at fit reads them contiguously. See [`SampleCache`].
sample: SampleCache,
/// 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,
/// View passes encoded — one per render with a detail stage (D19).
view_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
/// What a cached sample is valid for: the composer's
/// [`ComposedShader::sample_key`], the source image, and the render size.
type SampleKey = (u64, u64, u32, u32);
/// The largest render the sample cache is kept for, in pixels.
///
/// 4K and a little over, which is every develop view there is. An export
/// renders a whole sensor once and gains nothing from a cache it will not
/// read again; without a ceiling, two exports of the same frame in a row
/// would park a full-resolution copy of it on the device.
const SAMPLE_CACHE_MAX_PIXELS: u64 = 3840 * 2400;
/// How the fused dispatch gets its source colour this frame.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum SampleUse {
/// From the source, as it always did.
Direct,
/// From the source, and stored in the cache for the frames after.
Write,
/// From the cache.
Read,
}
/// TRACES: NFR-P5
/// The fused pass's gather from the source, remembered across frames.
///
/// At fit, each output pixel of the fused pass reads one texel of a source
/// three or four times its width, on a stride, and the memory system fetches
/// the neighbours it skips along with it. On a 60 MP source that gather was
/// most of the fused pass: 10.9 ms of a 2560 x 1600 frame against 3.8 ms for
/// the same work reading contiguously (RTX 3050, clocks held down). But which
/// texel a pixel reads depends on the framing and nothing else, and a slider
/// drag does not move the framing. So the shader writes what it gathered to a
/// render-sized texture on one frame and reads it back contiguously on every
/// frame after, until the framing, the image or the size changes.
///
/// Bit-for-bit the same picture: the source is `rgba16float` and so is the
/// cache, so the stored texel is the texel. Only the whole-texel sampling path
/// takes part — [`ComposedShader::sample_key`] is `None` when the sample is
/// interpolated.
///
/// **Written on the second frame with a key, not the first.** A drag of the
/// crop or of a zoom changes the key on every frame, and a cache written then
/// is never read — it would add a write per frame to exactly the gestures that
/// can least afford one. Waiting for the key to repeat once costs a slider drag
/// one uncached frame and costs a crop drag nothing.
struct SampleCache {
/// The cache itself, `rgba16float`, sampled and written as storage.
target: Option<Target>,
/// What `target` holds, once a dispatch has written it.
holds: Option<SampleKey>,
/// The key the previous fused dispatch had, cached or not.
last: Option<SampleKey>,
/// What the most recent plan decided. Read by the tests, which have no
/// other way to tell a cached frame from an uncached one — the point being
/// that the pictures are identical.
last_use: SampleUse,
/// Bound at `@binding(6)` when the cache is not being read.
no_sampled: wgpu::TextureView,
/// Bound at `@binding(7)` when the cache is not being written.
no_sample_out: wgpu::TextureView,
}
impl SampleCache {
const FORMAT: wgpu::TextureFormat = DemosaicedImage::FORMAT;
fn new(ctx: &GpuContext) -> Self {
let placeholder = |label, usage| {
ctx.device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage,
view_formats: &[],
})
.create_view(&Default::default())
};
Self {
target: None,
holds: None,
last: None,
last_use: SampleUse::Direct,
no_sampled: placeholder("adjust-no-sampled", wgpu::TextureUsages::TEXTURE_BINDING),
no_sample_out: placeholder(
"adjust-no-sample-out",
wgpu::TextureUsages::STORAGE_BINDING,
),
}
}
/// Decide how this dispatch samples, on the assumption that it will be
/// submitted — call it after anything that can still fail.
fn plan(
&mut self,
ctx: &GpuContext,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> SampleUse {
self.last_use = self.decide(ctx, source, shader, width, height);
self.last_use
}
fn decide(
&mut self,
ctx: &GpuContext,
source: &DemosaicedImage,
shader: &ComposedShader,
width: u32,
height: u32,
) -> SampleUse {
let key = shader
.sample_key
.filter(|_| u64::from(width) * u64::from(height) <= SAMPLE_CACHE_MAX_PIXELS)
.map(|k| (k, source.id(), width, height));
let last = std::mem::replace(&mut self.last, key);
let Some(key) = key else {
return SampleUse::Direct;
};
if self.holds == Some(key) {
return SampleUse::Read;
}
if last != Some(key) {
return SampleUse::Direct;
}
if !self
.target
.as_ref()
.is_some_and(|t| t.width == width && t.height == height)
{
let texture = ctx.device.create_texture(&wgpu::TextureDescriptor {
label: Some("adjust-sample-cache"),
size: wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: Self::FORMAT,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.target = Some(Target {
texture,
view,
width,
height,
});
}
// Marked as held now: the dispatch that writes it is submitted before
// any that could read it, and one queue orders the two.
self.holds = Some(key);
SampleUse::Write
}
/// Write the flags for `usage` into a fused uniform block.
fn flag(usage: SampleUse, uniforms: &mut [f32]) {
let o = dr_pipeline::SAMPLE_CACHE_UNIFORM_OFFSET;
uniforms[o] = if usage == SampleUse::Read { 1.0 } else { 0.0 };
uniforms[o + 1] = if usage == SampleUse::Write { 1.0 } else { 0.0 };
}
/// The views for `@binding(6)` and `@binding(7)`.
fn views(&self, usage: SampleUse) -> (wgpu::TextureView, wgpu::TextureView) {
let cache = || {
self.target
.as_ref()
.expect("planned with a target")
.view
.clone()
};
match usage {
SampleUse::Direct => (self.no_sampled.clone(), self.no_sample_out.clone()),
SampleUse::Write => (self.no_sampled.clone(), cache()),
SampleUse::Read => (cache(), self.no_sample_out.clone()),
}
}
/// Forget everything; the next render starts over.
fn release(&mut self) {
self.target = None;
self.holds = None;
self.last = None;
}
}
/// 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,
t.curves.len() as f32,
t.lut.len() as f32,
] {
mix(v.to_bits());
}
for v in &t.push_stations {
mix(v.to_bits());
}
// The paper's balance is left out on purpose: it reaches the shader as
// uniforms, not texels, and it is what moves when the photograph's
// exposure does — keying on it would re-upload a megabyte per tick of a
// slider that changes three floats.
if let Some(p) = &t.paper {
for v in [p.log_min, p.log_max, p.density_max] {
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;
}
// One row per curve — the film's at each measured push, then the
// paper's — and one cube per stage stacked in depth. The shader reads
// the layout from `FilmTables`' uniforms, not from these sizes.
let samples = dr_pipeline::ops::film_sim::CURVE_SAMPLES as u32;
let curves = self.upload_film(
"adjust-film-curves",
wgpu::TextureDimension::D2,
wgpu::Extent3d {
width: samples,
height: t.curves.len() as u32 / samples,
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: t.lut.len() as u32 / (n * 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,
sample: SampleCache::new(ctx),
colour_dispatches: 0,
detail_dispatches: 0,
view_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,
},
// The sample cache, read and written. Present in every
// layout for the reason the masks are, and bound to
// placeholders whenever the flags leave it alone. See
// `SampleCache`.
wgpu::BindGroupLayoutEntry {
binding: 6,
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: 7,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::StorageTexture {
access: wgpu::StorageTextureAccess::WriteOnly,
format: SampleCache::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
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);
// Compile first: `pipeline` takes &mut self, and the sample cache's
// plan below assumes the dispatch it plans for is submitted, so nothing
// after it may fail.
let _ = self.pipeline(shader)?;
let mut uniforms = Self::fused_uniforms(source, shader);
let sampling = self.sample.plan(&self.ctx, source, shader, width, height);
SampleCache::flag(sampling, &mut uniforms);
let (sampled, sample_out) = self.sample.views(sampling);
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 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()),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
],
});
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` on the same
/// graph. The fused pass stops at linear working values when a detail
/// stage exists, and its view pass ([`ComposedShader::view`]) performs the
/// view transform and the output transform after the last detail pass
/// (D19), so a mismatched pair either encodes twice or not at all.
///
/// An encoded shader with 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> {
// An edit with no detail stage encodes in the fused pass, and is an
// ordinary render. Decided from the shader rather than from the chain:
// an active kernel too fine for this render emits no pass, and its
// fused pass has still stopped at linear values for the view pass.
if shader.output_mode == OutputMode::Encoded && detail.is_empty() {
return self.render_masked(source, shader, width, height, masks);
}
let view = match (shader.output_mode, shader.view.as_deref()) {
(OutputMode::LinearWorking, Some(view)) => view,
_ => {
return Err(GpuError::ShaderCompilation(
"this detail chain expects a fused pass composed to hand on \
linear working values, with its view pass, 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)?;
self.pipeline(view)?;
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"),
});
let mut sampling = SampleUse::Direct;
if !reuse {
let mut uniforms = uniforms;
sampling = self.sample.plan(&self.ctx, source, shader, width, height);
SampleCache::flag(sampling, &mut uniforms);
let (sampled, sample_out) = self.sample.views(sampling);
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),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&sample_out),
},
],
});
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, every detail pass and the view 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 (ran, result) = match self.detail.encode(&mut enc, detail, width, height) {
Ok(done) => done,
Err(e) => {
// Nothing is submitted, so a cache this frame was to write
// holds nothing, and must not be read as though it did.
if sampling == SampleUse::Write {
self.sample.release();
}
return Err(e);
}
};
// TRACES: FR-DEV-3j
// The view pass: the view transform and the output transform, after
// every kernel (D19). Its own uniform block, filled from the source
// like the fused pass's — it reads the non-linear flag and the film
// settings there — with the sample cache off, because the colour it
// reads is the detail stage's result, bound where the cache would be.
let view_uniforms = Self::fused_uniforms(source, view);
let view_params = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-view-params"),
contents: bytemuck::cast_slice(&view_uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
let (_, no_sample_out) = self.sample.views(SampleUse::Direct);
let target_view = self.targets[self.current]
.as_ref()
.expect("ensured above")
.view
.clone();
let view_bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-view-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: view_params.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(&film_curves),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::TextureView(&film_lut),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&result),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&no_sample_out),
},
],
});
{
let pipeline = self
.cache
.get(&view.structure_hash)
.expect("compiled above");
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("adjust-view-pass"),
timestamp_writes: None,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &view_bind_group, &[]);
pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1);
}
self.view_dispatches += 1;
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-DSP-2 | NFR-RES-2
// Which part of the photograph the texture holds. The whole of it for
// every source that fits in one texture, which writes back exactly
// what the composer put there.
let w = dr_pipeline::SOURCE_WINDOW_UNIFORM_OFFSET;
uniforms[w..w + dr_pipeline::SOURCE_WINDOW_UNIFORM_FIELDS]
.copy_from_slice(&source.window_uniforms());
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;
self.sample.release();
}
/// 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
}
/// TRACES: FR-DEV-3j
/// View passes encoded since this pass was created: one for every render
/// that had a detail stage, since the view transform follows it.
pub fn view_dispatches(&self) -> usize {
self.view_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 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()),
},
// The sample cache is the display's; a camera-space tap
// reads its source directly (its flags are zero).
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sampled),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&self.sample.no_sample_out),
},
],
});
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::{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]),
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]),
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,
push_stations: vec![0.0],
paper: None,
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
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((512, 512), (w, h));
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;
// A stock is loaded here, and a stock is a rendering: the view
// transform it replaces is correctly in neither half (FR-DEV-3j).
if id == dr_pipeline::ops::view_transform::ID.0 {
assert!(!shader.source.contains("---- view_transform ----"));
continue;
}
// A view operation is in the view pass when a detail stage
// follows, which it does here (D19).
let block = format!("---- {id} ----");
let point = shader.source.contains(&block)
|| shader
.view
.as_ref()
.is_some_and(|v| v.source.contains(&block));
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.
//
// The view pass repeats the prologue — framing and the warps — for
// the positions it publishes, so only its operation blocks count.
let view_blocks = shader
.view
.as_ref()
.map_or(0, |v| v.source.matches("---- ").count() - (warp_blocks + 1));
assert_eq!(
shader.source.matches("---- ").count() + view_blocks,
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]),
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]),
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.
/// A frame with a different value at every pixel, several times the size
/// of the renders below, so that a fit view reads it on a stride and a
/// texel read from the wrong place cannot go unnoticed.
fn busy_image(ctx: &GpuContext) -> DemosaicedImage {
let (w, h) = (97u32, 61u32);
let mut data = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let n = (x.wrapping_mul(2_654_435_761) ^ y.wrapping_mul(1_640_531_527)) >> 7;
data.extend_from_slice(&[n as u8, (n >> 8) as u8, (x * 2 + y) as u8, 255]);
}
}
DemosaicedImage::from_rgba8(ctx, &data, w, h).expect("upload")
}
/// Render `g` with a pass that has never seen it, which reads the source
/// directly by construction: the reference a cached frame must equal.
fn fresh(ctx: &GpuContext, img: &DemosaicedImage, g: &EditGraph) -> Vec<u8> {
let mut pass = AdjustPass::new(ctx);
let detail = g.compose_detail(img.size(), (23, 15));
pass.render_detailed(img, &g.compose(), 23, 15, None, &detail, 1)
.expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct);
pass.export_pixels().expect("read").0
}
#[test]
fn a_cached_sample_is_the_same_picture() {
// TRACES: NFR-P5
// The sample cache's whole claim: a frame that reads the source through
// it is bit-for-bit the frame that reads the source directly. Walked
// through each state — direct, writing, reading, reading after a
// slider moved, and direct again once the framing moves — against a
// fresh pass each time.
let Some(ctx) = ctx() else { return };
let img = busy_image(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut g = EditGraph::default_chain();
let frame = |pass: &mut AdjustPass, g: &EditGraph, key: u64| {
let detail = g.compose_detail(img.size(), (23, 15));
pass.render_detailed(&img, &g.compose(), 23, 15, None, &detail, key)
.expect("render");
(pass.sample.last_use, pass.export_pixels().expect("read").0)
};
for (i, (expected, value)) in [
(SampleUse::Direct, 0.3),
(SampleUse::Write, 0.4),
(SampleUse::Read, 0.5),
(SampleUse::Read, -0.7),
]
.into_iter()
.enumerate()
{
g.set_param(exposure::ID, exposure::EXPOSURE, value);
let (used, pixels) = frame(&mut pass, &g, i as u64);
assert_eq!(used, expected, "frame {i}");
assert_eq!(pixels, fresh(&ctx, &img, &g), "frame {i} ({used:?})");
}
// A neighbourhood operation: the fused pass writes the linear
// intermediate instead, through the same sampling.
g.set_param(
dr_pipeline::ops::noise_reduction::ID,
dr_pipeline::ops::noise_reduction::CHROMA,
60.0,
);
for i in 10..13 {
g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.01);
let (used, pixels) = frame(&mut pass, &g, i);
assert_eq!(used, SampleUse::Read, "detail frame {i}");
assert_eq!(pixels, fresh(&ctx, &img, &g), "detail frame {i}");
}
// The framing moves: what was cached is for the old framing.
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::CROP_W, 0.6);
let (used, pixels) = frame(&mut pass, &g, 20);
assert_eq!(used, SampleUse::Direct, "a new framing reads directly");
assert_eq!(pixels, fresh(&ctx, &img, &g));
let (used, _) = frame(&mut pass, &g, 21);
assert_eq!(used, SampleUse::Write, "and caches once it holds still");
let (used, pixels) = frame(&mut pass, &g, 22);
assert_eq!(used, SampleUse::Read);
assert_eq!(pixels, fresh(&ctx, &img, &g));
}
#[test]
fn a_cache_is_not_read_for_another_image_or_size() {
// The key is the composer's half plus the two things only this side
// knows. A second photograph with the same edit and the same framing
// must not be shown the first one's texels.
let Some(ctx) = ctx() else { return };
let (a, b) = (busy_image(&ctx), grey_image(&ctx, 8000));
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
for _ in 0..3 {
pass.render(&a, &shader, 23, 15).expect("render");
}
assert_eq!(pass.sample.last_use, SampleUse::Read);
pass.render(&b, &shader, 23, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another image");
pass.render(&b, &shader, 23, 15).expect("render");
pass.render(&b, &shader, 24, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct, "another size");
}
#[test]
fn a_straightened_frame_samples_directly() {
// Interpolated: the sample is a blend of four texels, which the cache's
// format could not hold exactly, so the composer offers no key.
let Some(ctx) = ctx() else { return };
let img = busy_image(&ctx);
let mut pass = AdjustPass::new(&ctx);
let mut g = EditGraph::default_chain();
g.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 3.0);
let shader = g.compose();
assert!(shader.sample_key.is_none());
for _ in 0..3 {
pass.render(&img, &shader, 23, 15).expect("render");
assert_eq!(pass.sample.last_use, SampleUse::Direct);
}
}
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_differ_by_exactly_the_view_transform() {
// TRACES: FR-DEV-3j
// The two producers must be interchangeable up to the rendering. A
// mid-grey that is linearly 0.216 (sRGB 128) arriving as sensor data
// is scene-referred and goes through the view transform; arriving as
// a JPEG it is already a rendering and must come out as it went in.
// Before D19 the fixture's identity base curve made both unrendered
// and this asserted they matched; what it guards is unchanged — the
// linearisation of each agrees — but the rendering between them is
// now always there for sensor data.
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 scene = 3537.0 / 16383.0;
let viewed = dr_pipeline::view::Sigmoid::default_curve().channel(scene);
let expected = (dr_types::Transfer::Srgb.encode(viewed) * 255.0).round() as i32;
let delta = (i32::from(from_sensor[0]) - expected).abs();
assert!(
delta <= 3,
"sensor data rendered {from_sensor:?}, expected about {expected}"
);
let delta = (i32::from(from_jpeg[0]) - 128).abs();
assert!(
delta <= 3,
"a JPEG was rendered again: {from_jpeg:?} from sRGB 128"
);
}
}