Add the develop pipeline: demosaic and seven raw adjustments

Decode through display, on the GPU: black/white normalisation, Bayer
demosaic, camera colour transform, and the first seven adjustment
operations — white balance, exposure, highlights/shadows, blacks/whites,
brilliance, vibrance, saturation.

Composable shaders. Each operation contributes a WGSL fragment rather
than owning a pass, and dr-pipeline fuses the *active* ones into a single
compute shader. One texture read and one write per frame regardless of
how many adjustments are in play, while the operations stay independent
in Rust — adding one is a new file, with no central shader to edit. An
operation at neutral settings contributes no code, no uniform and no
branch. Uniforms are prefixed per operation so two may both declare
`amount`; helpers dedupe by name from a single source of truth.

Pipelines cache on a structure hash covering the op-set and its order but
not the values, so dragging a slider uploads uniforms and reuses the
compiled pipeline. Measured on a 24 MP CR2: 0.60 ms re-render, one
pipeline compiled across ten slider positions.

The UI is generated, not written. EditGraph::capabilities() reports
parameters with their kinds, ranges, defaults and current values; the
panel builds one control per entry chosen by ParamKind. No file in ui/
names an operation, and dr-pipeline has no wgpu dependency, so codegen is
testable without a device (ARCH §6.5a).

Three defects found against real files, each silent:

- rawler 0.7.2's `xyz_to_cam` is all zeros — deprecated and no longer
  populated. The live matrices are in `color_matrix`, keyed by
  illuminant. Reading the old field yields no colour transform at all.
- `cam_to_xyz_normalized()` returns all NaN on any Bayer sensor: it
  divides each of four rows by its own sum, and the unused fourth
  (emerald) row sums to zero. Inverting the 3x3 ourselves avoids it.
  `wb_coeffs[3]` is NaN for the same reason and is normalised at decode.
- As-shot white balance reached the uniform block but no shader read it,
  so the first render of a real CR2 came out violently green. Green
  photosites collect roughly twice the signal of red and blue. Now
  applied unconditionally before any operation, with tests on ordering.

Demosaic is Malvar-He-Cutler rather than bilinear: gradient-corrected
interpolation at one 5x5 neighbourhood per pixel, where bilinear leaves
visible zippering on any high-contrast edge at 1:1. Two of the four
packed CFA constants were wrong on the first attempt, so all four layouts
are asserted to reconstruct the same colour. Crop origins at odd
coordinates re-phase the pattern; without that, red and blue swap.

X-Trans reports GpuError::UnsupportedCfa rather than approximating with
the Bayer path, which would look like a corrupt file.

206 tests, including GPU tests proving every operation and the full
seven-operation chain generate compilable WGSL.

Known gaps: the display path still reads back to the CPU each frame,
which ARCH §6.1 forbids and AC-8 asserts against — it is gated behind the
`readback` feature and waits on spike S1 wiring Slint's texture import.
Curve shapes are a first draft and want tuning against real photographs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 11:37:58 +02:00
co-authored by Claude Opus 5
parent cc1c5c892d
commit 78e3e6b846
29 changed files with 5865 additions and 68 deletions
+768
View File
@@ -0,0 +1,768 @@
//! 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::{DemosaicedImage, GpuContext, GpuError};
/// Number of leading floats in the generated uniform block that the composer
/// reserves for base parameters — three padded matrix rows and the as-shot
/// white balance. Must match `BASE_UNIFORM_FIELDS` in dr-pipeline.
const BASE_FIELDS: usize = 16;
/// 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>,
/// Output texture, reallocated only when the size changes.
target: Option<Target>,
}
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: &[&bind_group_layout],
push_constant_ranges: &[],
});
Self {
ctx: ctx.clone(),
bind_group_layout,
pipeline_layout,
cache: HashMap::new(),
target: 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.
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(self.ctx.device.pop_error_scope()) {
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"))
}
/// Ensure the output texture matches the requested size.
fn ensure_target(&mut self, width: u32, height: u32) {
let matches = self
.target
.as_ref()
.is_some_and(|t| t.width == width && t.height == height);
if matches {
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,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
self.target = 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.
let mut uniforms = shader.uniforms.clone();
if uniforms.len() < BASE_FIELDS {
uniforms.resize(BASE_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]);
uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], 0.0]);
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.target.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.target.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()
}
pub fn output(&self) -> Option<&wgpu::Texture> {
self.target.as_ref().map(|t| &t.texture)
}
/// Copy the output to the CPU as tightly packed RGBA8.
///
/// **A temporary bridge, not the display path.** ARCH §6.1 forbids this
/// round-trip in production and AC-8 asserts it does not happen; it
/// exists only because Slint's texture-import path is unwired until
/// spike S1. Measured cost at 4K is ~7 ms against a 0.28 ms compute pass
/// — 96% of the frame — so this must go, and the `readback` feature gate
/// keeps it out of a shipping build.
#[cfg(any(test, feature = "readback"))]
pub fn read_output(&self) -> Result<(Vec<u8>, u32, u32), GpuError> {
let Some(target) = self.target.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::ImageCopyTexture {
texture: &target.texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::ImageCopyBuffer {
buffer: &buf,
layout: wgpu::ImageDataLayout {
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);
});
self.ctx.device.poll(wgpu::Maintain::Wait);
rx.recv()
.map_err(|e| GpuError::Readback(e.to_string()))?
.map_err(|e| GpuError::Readback(e.to_string()))?;
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::{colour, exposure, tone, white_balance};
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 = 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::ImageCopyTexture {
texture: tex,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::ImageCopyBuffer {
buffer: &buf,
layout: wgpu::ImageDataLayout {
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::Maintain::Wait);
rx.recv().expect("map").expect("map ok");
let data = slice.get_mapped_range();
let off = ((h / 2) * padded + (w / 2) * 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);
// A name paired with the edit that activates one operation. Boxed
// closures rather than a type alias: the list is read top to bottom
// as a table of what is covered.
#[allow(clippy::type_complexity)]
let cases: Vec<(&str, Box<dyn Fn(&mut EditGraph)>)> = vec![
(
"white_balance.temperature",
Box::new(|g: &mut EditGraph| {
g.set_param(white_balance::ID, white_balance::TEMPERATURE, 60.0)
}),
),
(
"white_balance.tint",
Box::new(|g: &mut EditGraph| {
g.set_param(white_balance::ID, white_balance::TINT, -40.0)
}),
),
(
"exposure",
Box::new(|g: &mut EditGraph| g.set_param(exposure::ID, exposure::EXPOSURE, 1.5)),
),
(
"highlights",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::HIGHLIGHTS, -70.0)
}),
),
(
"shadows",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::SHADOWS, 70.0)
}),
),
(
"blacks",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::BLACKS_WHITES_ID, tone::BLACKS, -50.0)
}),
),
(
"whites",
Box::new(|g: &mut EditGraph| {
g.set_param(tone::BLACKS_WHITES_ID, tone::WHITES, 50.0)
}),
),
(
"brilliance",
Box::new(|g: &mut EditGraph| {
g.set_param(colour::BRILLIANCE_ID, colour::BRILLIANCE, 60.0)
}),
),
(
"vibrance",
Box::new(|g: &mut EditGraph| {
g.set_param(colour::VIBRANCE_ID, colour::VIBRANCE, 60.0)
}),
),
(
"saturation",
Box::new(|g: &mut EditGraph| {
g.set_param(colour::SATURATION_ID, colour::SATURATION, 60.0)
}),
),
];
for (name, apply) in cases {
let mut g = EditGraph::default_chain();
apply(&mut g);
let shader = g.compose();
pass.render(&img, &shader, 16, 16)
.unwrap_or_else(|e| panic!("{name} generated invalid WGSL:\n{e}"));
}
}
#[test]
fn the_whole_chain_at_once_compiles() {
// Individually-valid fragments can still collide when combined —
// duplicate helpers, clashing locals, a malformed uniform block.
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(white_balance::ID, white_balance::TEMPERATURE, 30.0);
g.set_param(white_balance::ID, white_balance::TINT, -20.0);
g.set_param(exposure::ID, exposure::EXPOSURE, 0.8);
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::HIGHLIGHTS, -60.0);
g.set_param(tone::HIGHLIGHTS_SHADOWS_ID, tone::SHADOWS, 40.0);
g.set_param(tone::BLACKS_WHITES_ID, tone::BLACKS, -25.0);
g.set_param(tone::BLACKS_WHITES_ID, tone::WHITES, 35.0);
g.set_param(colour::BRILLIANCE_ID, colour::BRILLIANCE, 45.0);
g.set_param(colour::VIBRANCE_ID, colour::VIBRANCE, 55.0);
g.set_param(colour::SATURATION_ID, colour::SATURATION, 15.0);
let shader = g.compose();
assert_eq!(shader.source.matches("---- ").count(), 7);
pass.render(&img, &shader, 32, 32)
.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(colour::SATURATION_ID, colour::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(colour::SATURATION_ID, colour::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(colour::SATURATION_ID, colour::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);
}
#[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));
}
}