Build and test / Desktop (Linux) (push) Failing after 38s
Build and test / Layer separation (push) Successful in 24s
Traceability / Requirement traces (push) Successful in 1m3s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 9m54s
Groundwork for spike S1. Importing a texture into a Slint scene requires it
to come from the *same* `wgpu::Device` Slint renders with, and Slint hands
out a device of the version it was compiled against. Slint 1.17 offers
`unstable-wgpu-28` and `unstable-wgpu-29` and nothing older, so wgpu 23 could
never have met it: two semver-incompatible wgpu crates in one tree are two
distinct types, and the device would not typecheck across the gap.
The version is therefore not a free choice, and the manifest now says so —
Slint and wgpu move together or not at all. The Slint requirement is also
corrected from "1.9" to the 1.17 it has actually been resolving to.
Nothing about the render path changes here. The readback bridge is still in
place and still the display path, so this is verified by the tests that
already existed rather than by anything new: 39 dr-gpu tests, which compare
real pixels off a real device, and 888 across the workspace, all passing.
Zero-copy lands separately and small.
What the six releases cost, in full:
- `ImageCopyTexture`/`ImageCopyBuffer`/`ImageDataLayout` became the
`TexelCopy*` names (24).
- `Instance::new` takes the descriptor by value, and `InstanceDescriptor`
lost its `Default` — it carries a boxed display handle now, so a headless
context says `new_without_display_handle` and means it.
- `request_adapter` returns `Result` rather than `Option` (24).
- `DeviceDescriptor` absorbed the API trace from `request_device`'s second
argument and gained `experimental_features` (25).
- `PipelineLayoutDescriptor` takes `Option<&BindGroupLayout>` per slot, and
`push_constant_ranges` became `immediate_size`.
- `Maintain` became `PollType`, and `poll` is fallible.
Two of those are improvements worth having rather than churn. The error scope
is a guard whose `pop` runs on drop, so an early return from the pipeline
compiler no longer leaves a scope open on the device for whatever ran next to
fall into. And a fallible `poll` reports a lost device (NFR-R7) at the point
it happens, where before the map callback simply never arrived and the
failure surfaced later as a readback that spun out its poll limit.
Still to do for S1: dr-ui renders through `renderer-femtovg`, which is
OpenGL. Texture import needs Slint itself rendering on wgpu.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1265 lines
48 KiB
Rust
1265 lines
48 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::{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.
|
|
#[cfg(any(test, feature = "readback"))]
|
|
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>,
|
|
/// 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: &[Some(&bind_group_layout)],
|
|
immediate_size: 0,
|
|
});
|
|
|
|
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.
|
|
//
|
|
// 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"))
|
|
}
|
|
|
|
/// 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. 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.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::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 without blocking, then checked.**
|
|
//
|
|
// `Maintain::Wait` parks the calling thread until the GPU has finished,
|
|
// and this is called from the UI thread — so that park was a frozen
|
|
// interface for the duration of the copy (~7 ms at 4K, per the note
|
|
// above). `Poll` drives the same callbacks without sleeping, so the
|
|
// loop below stays interruptible and the mapping still completes.
|
|
//
|
|
// The bounded spin matters: a lost device would otherwise never
|
|
// deliver the callback and this would hang the app instead of
|
|
// reporting an error.
|
|
let mut mapped = None;
|
|
for _ in 0..READBACK_POLL_LIMIT {
|
|
// A poll error is a lost device, which is exactly the case the
|
|
// bounded spin exists to escape — returning here reports it
|
|
// immediately rather than spinning out the full limit first.
|
|
self.ctx
|
|
.device
|
|
.poll(wgpu::PollType::Poll)
|
|
.map_err(|e| GpuError::Readback(e.to_string()))?;
|
|
match rx.try_recv() {
|
|
Ok(r) => {
|
|
mapped = Some(r);
|
|
break;
|
|
}
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => continue,
|
|
Err(e) => return Err(GpuError::Readback(e.to_string())),
|
|
}
|
|
}
|
|
mapped
|
|
.ok_or_else(|| GpuError::Readback("readback did not complete".into()))?
|
|
.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::{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);
|
|
}
|
|
|
|
#[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 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"
|
|
);
|
|
}
|
|
}
|