Files
DarkRoom/core/dr-gpu/src/adjust.rs
T
dtourolle cfff6a3302 Merge branch 'zero-copy-display'
# Conflicts:
#	core/dr-gpu/src/adjust.rs
#	ui/dr-ui/src/develop.rs
2026-08-17 10:04:14 +02:00

1436 lines
56 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::ComposedShader;
use wgpu::util::DeviceExt;
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;
/// How many non-blocking polls a readback gets before it is called failed.
///
/// A bound rather than a spin forever: if the device is lost the map callback
/// never arrives, and an unbounded loop would hang the interface rather than
/// surfacing the error. Set far above any plausible completion — the copy this
/// waits on is milliseconds — so it is reached only when something is wrong.
///
/// Ungated along with [`AdjustPass::export_pixels`], the one readback that
/// survives S1: an export reads pixels back in a shipping build, and a lost
/// device mid-export must surface as an error rather than a hung interface.
const READBACK_POLL_LIMIT: u32 = 100_000;
/// 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,
}
struct Target {
texture: wgpu::Texture,
view: wgpu::TextureView,
width: u32,
height: u32,
}
impl AdjustPass {
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
pub fn new(ctx: &GpuContext) -> Self {
let bind_group_layout =
ctx.device
.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("adjust-bgl"),
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: Self::FORMAT,
view_dimension: wgpu::TextureViewDimension::D2,
},
count: None,
},
],
});
let pipeline_layout = ctx
.device
.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("adjust-layout"),
bind_group_layouts: &[Some(&bind_group_layout)],
immediate_size: 0,
});
Self {
ctx: ctx.clone(),
bind_group_layout,
pipeline_layout,
cache: HashMap::new(),
targets: [None, None],
current: 0,
}
}
/// 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()),
});
let pipeline =
self.ctx
.device
.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("adjust-pipeline"),
layout: Some(&self.pipeline_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> {
let (width, height) = (width.max(1), height.max(1));
self.ensure_target(width, height);
// 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]);
let params_buf = self
.ctx
.device
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("adjust-params"),
contents: bytemuck::cast_slice(&uniforms),
usage: wgpu::BufferUsages::UNIFORM,
});
// Borrow order: compile first, since `pipeline` takes &mut self.
let _ = self.pipeline(shader)?;
let pipeline = self
.cache
.get(&shader.structure_hash)
.expect("compiled above");
let target = self.targets[self.current].as_ref().expect("ensured above");
let bind_group = self
.ctx
.device
.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("adjust-bg"),
layout: &self.bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source.view()),
},
wgpu::BindGroupEntry {
binding: 1,
resource: params_buf.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&target.view),
},
],
});
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()));
Ok(&self.targets[self.current]
.as_ref()
.expect("ensured above")
.texture)
}
/// 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()
}
/// 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()
}
/// 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()));
};
let (w, h) = (target.width, target.height);
let unpadded = w * 4;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let buf = self.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 = self.ctx.device.create_command_encoder(&Default::default());
enc.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &target.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,
},
);
self.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(&self.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))
}
}
/// Number the lines of generated source, so a compiler error can be located.
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::{exposure, saturation};
use dr_pipeline::EditGraph;
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]),
crop: CropRect {
x: 0,
y: 0,
width: size,
height: size,
},
};
Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic")
}
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();
pass.render(&img, &shader, 16, 16).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]),
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"
);
}
#[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"
);
}
#[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);
}
}
}
let shader = g.compose();
assert_eq!(
shader.source.matches("---- ").count(),
// Every operation, plus framing — which emits a stage of its own
// rather than an operation block, and is not in `descriptors`.
g.descriptors().len() + 1,
"every operation and the framing should be active"
);
assert!(
shader.source.contains("---- framing ----"),
"framing must reach the shader alongside the colour operations"
);
// Cropped, so the render is against an output size that is not the
// source size — the case where a wrong dispatch or a wrong texture
// allocation would show up.
let (w, h) = g.output_size(32, 32);
pass.render(&img, &shader, w, h)
.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]),
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 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
#[test]
fn the_output_is_importable_by_a_compositor() {
// Every condition Slint checks before it will adopt a texture
// (`slint::wgpu_29`: `TextureImportError`). They are asserted here,
// in the crate that owns the descriptor, because failing them does not
// fail a build or a shader — it fails at runtime, on the frame the
// image is handed over, and only where there is a screen to hand it
// to. Nothing else in the test suite would notice.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let t = pass.render(&img, &shader, 16, 16).expect("render");
assert!(
matches!(
t.format(),
wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb
),
"import accepts only the two 8-bit RGBA formats, not {:?}",
t.format()
);
assert!(
t.usage().contains(wgpu::TextureUsages::TEXTURE_BINDING),
"the compositor has to sample it"
);
assert!(
t.usage().contains(wgpu::TextureUsages::RENDER_ATTACHMENT),
"Slint requires this even though the adjust pass never uses it"
);
}
/// TRACES: FR-DSP-1 | AC-8
#[test]
fn consecutive_frames_are_different_textures() {
// Not a detail: the compositor is handed this texture rather than a
// copy of its pixels, and Slint repaints only when the image property
// *changes*. Two images over one texture compare equal, so writing the
// same texture every frame would leave a slider moving the pixels on
// the GPU and nothing at all on screen — the frame would be correct
// and invisible, which is the worst kind of wrong.
//
// No display is needed to catch it, because the equality Slint tests
// is the equality asserted here.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let first = pass.render(&img, &shader, 16, 16).expect("render").clone();
let second = pass.render(&img, &shader, 16, 16).expect("render").clone();
assert_ne!(first, second, "the compositor cannot tell these two apart");
// And back again, so the alternation is a rotation between two rather
// than an allocation per frame — which at 4K would be 33 MB a frame.
let third = pass.render(&img, &shader, 16, 16).expect("render").clone();
assert_eq!(first, third, "a third texture was allocated");
}
#[test]
fn the_output_resizes_with_the_viewport() {
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = grey_image(&ctx, 4000);
let shader = EditGraph::default_chain().compose();
let t = pass.render(&img, &shader, 64, 48).expect("render");
assert_eq!((t.width(), t.height()), (64, 48));
let t = pass.render(&img, &shader, 32, 96).expect("render");
assert_eq!((t.width(), t.height()), (32, 96));
}
/// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a
/// decoded JPEG is.
fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage {
let size = 16u32;
let mut data = Vec::with_capacity((size * size) as usize * 4);
for _ in 0..size * size {
data.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
}
DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload")
}
#[test]
fn a_jpeg_survives_a_neutral_graph_unchanged() {
// The property the whole JPEG path rests on: decoding the transfer
// function on the way in and re-encoding on the way out must be exact
// inverses. If they are not, merely *opening* a JPEG in develop mode
// shifts its tones — the file would be altered by being looked at,
// which is far worse than the panel being disabled.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// Several levels: a transfer-function error is smallest in the
// mid-tones and largest near the ends, so one sample could miss it.
for level in [16u8, 64, 128, 200, 240] {
let img = jpeg_image(&ctx, [level, level, level]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, c) in got[..3].iter().enumerate() {
let delta = (i32::from(*c) - i32::from(level)).abs();
assert!(
delta <= 2,
"channel {i} at level {level} came back {c} (delta {delta}) \
— the transfer functions are not inverses"
);
}
}
}
#[test]
fn a_jpeg_keeps_its_colour_through_a_neutral_graph() {
// Identity colour matrix and neutral white balance, specifically: a
// camera matrix applied to an image already in sRGB primaries would
// skew colour, and this is what catches it. A grey patch cannot —
// every matrix maps neutral to neutral.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
let img = jpeg_image(&ctx, [200, 90, 40]);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
for (i, expected) in [200u8, 90, 40].iter().enumerate() {
let delta = (i32::from(got[i]) - i32::from(*expected)).abs();
assert!(
delta <= 2,
"channel {i} expected ~{expected}, got {} — colour is being \
transformed on a source that needs no transform",
got[i]
);
}
}
#[test]
fn exposure_brightens_a_jpeg() {
// Proves the operations reach the JPEG path at all, and that they act
// on linearised values: an exposure stop is a multiply, which is only
// meaningful once the gamma encoding is undone.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = jpeg_image(&ctx, [110, 110, 110]);
let neutral = EditGraph::default_chain().compose();
let before = {
let t = pass.render(&img, &neutral, 16, 16).expect("render");
read_centre(&ctx, t)
};
let mut g = EditGraph::default_chain();
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
let brighter = g.compose();
let after = {
let t = pass.render(&img, &brighter, 16, 16).expect("render");
read_centre(&ctx, t)
};
assert!(
after[0] > before[0],
"+1 stop should brighten a JPEG: {before:?} -> {after:?}"
);
// One stop on a linear value is a doubling, which after re-encoding
// lands near 1.5x the encoded value rather than 2x. Checking the
// magnitude is what distinguishes "linearised correctly" from
// "doubled the gamma-encoded value", which would blow straight to
// white — the exact bug a brightness-only assertion would miss.
assert!(
after[0] < 255,
"a stop from mid-grey must not clip: {} — the encoding was \
probably not undone before the multiply",
after[0]
);
}
#[test]
fn every_output_colour_space_renders_what_the_colorimetry_predicts() {
// TRACES: FR-EXP-2
// The shader carries constants generated from `dr_types::colour`; this
// recomputes the same conversion on the CPU and demands the GPU agree.
// A transposed matrix, a transfer function applied before the
// primaries, or a clip in the wrong place all compile perfectly and
// simply produce the wrong colour — none of which a "did it compile"
// test would notice.
//
// A saturated patch, deliberately: every one of these spaces maps a
// neutral to itself, so a grey would agree with all four.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let source = [200u8, 90, 40];
let img = jpeg_image(&ctx, source);
// The JPEG path linearises with the sRGB curve, so this is the value
// reaching the output stage.
let linear: Vec<f32> = source
.iter()
.map(|&v| dr_types::Transfer::Srgb.decode(f32::from(v) / 255.0))
.collect();
for space in dr_types::ColourSpace::ALL {
let shader = EditGraph::default_chain().compose_for(space);
let t = pass.render(&img, &shader, 16, 16).expect("render");
let got = read_centre(&ctx, t);
let m = space.from_linear_srgb();
for channel in 0..3 {
let converted = m[channel * 3] * linear[0]
+ m[channel * 3 + 1] * linear[1]
+ m[channel * 3 + 2] * linear[2];
let want = space.transfer().encode(converted.clamp(0.0, 1.0)) * 255.0;
let delta = (f32::from(got[channel]) - want).abs();
// Two levels: the pipeline stores its intermediate in f16 and
// the source itself came from an 8-bit texel, so exactness is
// not on offer. A wrong matrix is out by tens.
assert!(
delta <= 2.0,
"{space:?} channel {channel}: rendered {} against a predicted {want:.1} \
(whole pixel {got:?})",
got[channel]
);
}
}
}
#[test]
fn a_wide_gamut_render_differs_from_an_srgb_one() {
// The companion to the test above, and the one that would fail if the
// output space were accepted and then ignored: predicted values that
// happened to match sRGB's would prove nothing. A saturated red is
// several tens of levels apart in P3.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let img = jpeg_image(&ctx, [230, 30, 20]);
let srgb = {
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::Srgb);
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let p3 = {
let shader = EditGraph::default_chain().compose_for(dr_types::ColourSpace::DisplayP3);
let t = pass.render(&img, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
// Less red and more green: the same colour expressed against wider
// primaries needs smaller numbers to reach it.
assert!(
p3[0] < srgb[0] && p3[1] > srgb[1],
"sRGB rendered {srgb:?} and Display P3 {p3:?}"
);
}
#[test]
fn a_jpeg_and_sensor_data_agree_on_the_same_scene_value() {
// The two producers must be interchangeable. A mid-grey that is
// linearly 0.216 (sRGB 128) arriving as sensor data and as a JPEG
// must render the same, or an edit would mean different things
// depending on which decoder opened the file.
let Some(ctx) = ctx() else { return };
let mut pass = AdjustPass::new(&ctx);
let shader = EditGraph::default_chain().compose();
// sRGB 128 linearises to ~0.2159; against a 16383 white level that is
// sample ~3537.
let sensor = grey_image(&ctx, 3537);
let jpeg = jpeg_image(&ctx, [128, 128, 128]);
let from_sensor = {
let t = pass.render(&sensor, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let from_jpeg = {
let t = pass.render(&jpeg, &shader, 16, 16).expect("render");
read_centre(&ctx, t)
};
let delta = (i32::from(from_sensor[0]) - i32::from(from_jpeg[0])).abs();
assert!(
delta <= 3,
"the same scene value rendered {from_sensor:?} from sensor data \
and {from_jpeg:?} from a JPEG"
);
}
}