//! The adjust pass — runs `dr-pipeline`'s generated shader. //! //! Takes the demosaiced texture, applies the composed operation chain, and //! writes a display-ready RGBA8 texture. One dispatch, whatever the number of //! active operations, because the operations were fused into one shader //! before they got here. //! //! # The pipeline cache //! //! Compiling a shader takes milliseconds — fine once, ruinous per frame while //! a slider is moving. Pipelines are therefore cached by the composed //! shader's `structure_hash`, which covers the operation set and their order //! but not their values. Dragging a slider re-uploads a uniform buffer and //! reuses the compiled pipeline; enabling an operation compiles once and then //! also reuses. use std::collections::HashMap; use dr_pipeline::detail::ComposedDetail; use dr_pipeline::{ComposedShader, OutputMode}; use wgpu::util::DeviceExt; use crate::detail::DetailRunner; use crate::readback::await_mapping; use crate::{DemosaicedImage, GpuContext, GpuError}; /// Leading floats the composer reserves before any operation's own uniforms: /// three padded matrix rows, the as-shot white balance, and framing's block. /// /// Imported rather than restated. It was a local literal, which was a latent /// bug of exactly the kind that is invisible until it is severe: growing the /// reserved block on the pipeline side would leave this short, and every /// operation's uniforms would silently shift out from under the shader that /// reads them. const RESERVED_FIELDS: usize = dr_pipeline::RESERVED_UNIFORM_FIELDS; /// TRACES: FR-DEV-3e /// The two crates must agree on how many points a base curve has. /// /// `dr-decode` reads them from the profile database and `dr-pipeline` declares /// the uniform slots; this file is the only place the two meet, and it packs /// them by index. A disagreement would not fail to compile — it would upload a /// curve with a point missing or a stale float in it, which renders as a /// plausible-looking wrong tone response. Cheaper to catch here, at build time. const _: () = assert!(dr_decode::base_curve::POINTS == dr_pipeline::BASE_CURVE_POINTS); /// Runs composed operation chains against demosaiced images. pub struct AdjustPass { ctx: GpuContext, bind_group_layout: wgpu::BindGroupLayout, pipeline_layout: wgpu::PipelineLayout, /// Compiled pipelines by structure hash (ARCH §5.6). cache: HashMap, /// 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; 2], /// Which of [`Self::targets`] the last render wrote. current: usize, /// Bound at `@binding(3)` when the edit carries no mask layers. empty_masks: wgpu::TextureView, /// TRACES: FR-DEV-3 | FR-DEV-3d /// The neighbourhood stage — sharpening, noise reduction, clarity and the /// rest of FR-DEV-3's detail set, which cannot be fused into the shader /// above because they read pixels they are not writing. /// /// It lives here rather than beside this pass because the two are one /// render: when a detail chain is present the fused pass writes a linear /// intermediate the runner owns, and the runner's last pass writes /// [`Self::targets`]. Kept as separate objects, a caller could hold a /// stale intermediate against a fresh colour result with nothing to tell /// it apart. detail: DetailRunner, /// The bind group layout for a fused pass writing a linear intermediate. /// /// A second layout rather than a second pass: the only difference is the /// storage texture's format, which is part of the layout and cannot be /// varied per bind group. Built once here, so a detail operation being /// switched on does not build a pipeline layout mid-frame. linear_bind_group_layout: wgpu::BindGroupLayout, linear_pipeline_layout: wgpu::PipelineLayout, /// TRACES: FR-DEV-3d /// What the linear intermediate currently holds, and at what size. /// /// **This is where `Affects::Detail` stops being bookkeeping.** The key is /// everything the fused dispatch depends on — the caller's /// `Invalidation::through(Affects::Colour)`, the compiled structure, the /// uniform values and the output size. When it matches, the colour pass is /// skipped and only the detail passes run, so dragging a sharpening slider /// costs a convolution and not a re-render of the whole chain (FR-DEV-3d). /// /// Cleared by any render that does not write it, so a stale intermediate /// cannot survive a change of image and be handed to a later detail chain. colour_key: Option<(u64, u32, u32)>, /// Fused dispatches actually encoded. Exposed so a test can see the reuse /// above happening rather than take it on trust. colour_dispatches: usize, /// Detail dispatches encoded. detail_dispatches: usize, } struct Target { texture: wgpu::Texture, view: wgpu::TextureView, width: u32, height: u32, } impl AdjustPass { pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; pub fn new(ctx: &GpuContext) -> Self { let bind_group_layout = Self::layout_writing(ctx, Self::FORMAT, "adjust-bgl"); let pipeline_layout = ctx .device .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("adjust-layout"), bind_group_layouts: &[Some(&bind_group_layout)], immediate_size: 0, }); // The same layout with an `Rgba16Float` storage texture, for the fused // pass when a detail stage follows it and it hands on linear working // values instead of encoding (see `dr_pipeline::OutputMode`). The // format is part of a bind group layout and cannot be varied per bind // group, so this is a second layout rather than a second binding — // built here, once, so that switching sharpening on does not construct // a pipeline layout in the middle of a frame. let linear_bind_group_layout = Self::layout_writing(ctx, crate::detail::INTERMEDIATE_FORMAT, "adjust-linear-bgl"); let linear_pipeline_layout = ctx.device .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("adjust-linear-layout"), bind_group_layouts: &[Some(&linear_bind_group_layout)], immediate_size: 0, }); // A 1x1 single-layer mask, bound when the edit has no local // adjustments. The generated shader never samples it — no layer block // is emitted — but a bind group must still satisfy the layout. let empty = ctx.device.create_texture(&wgpu::TextureDescriptor { label: Some("adjust-empty-masks"), size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: crate::MaskArray::FORMAT, usage: wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let empty_masks = empty.create_view(&wgpu::TextureViewDescriptor { label: Some("adjust-empty-masks-view"), dimension: Some(wgpu::TextureViewDimension::D2Array), ..Default::default() }); Self { ctx: ctx.clone(), bind_group_layout, pipeline_layout, cache: HashMap::new(), targets: [None, None], current: 0, empty_masks, detail: DetailRunner::new(ctx), linear_bind_group_layout, linear_pipeline_layout, colour_key: None, colour_dispatches: 0, detail_dispatches: 0, } } /// The fused pass's bind group layout, for a given storage format. /// /// Two of these exist — one writing `Rgba8Unorm` and one writing /// `Rgba16Float` — and they differ in exactly one field. Written once and /// parameterised rather than copied, because two copies of a four-entry /// layout is how the mask binding comes to be present in one and absent /// from the other, and a bind group that satisfies neither is a validation /// error a long way from its cause. fn layout_writing( ctx: &GpuContext, format: wgpu::TextureFormat, label: &str, ) -> wgpu::BindGroupLayout { ctx.device .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some(label), entries: &[ // The demosaiced source. wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, }, wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::StorageTexture { access: wgpu::StorageTextureAccess::WriteOnly, format, view_dimension: wgpu::TextureViewDimension::D2, }, count: None, }, // The local-adjustment masks. Present in every layout // whether or not the edit has any, because the layout is // built once here and the generated shader declares the // binding unconditionally for exactly that reason. wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2Array, multisampled: false, }, count: None, }, ], }) } /// Compile a composed shader, or return the cached pipeline. /// /// Compilation errors carry the generated source, since a stray line /// number against code nobody wrote is otherwise very hard to act on. fn pipeline(&mut self, shader: &ComposedShader) -> Result<&wgpu::ComputePipeline, GpuError> { if !self.cache.contains_key(&shader.structure_hash) { // A validation error here is a codegen bug, not a user error. // Push an error scope so it surfaces as a Result rather than a // panic from wgpu's default handler. // // Since wgpu 29 the scope is a guard rather than a device-level // push/pop pair, which is the better shape: an early return from // this function pops it on drop instead of leaving a scope open on // the device for whatever ran next to fall into. let scope = self .ctx .device .push_error_scope(wgpu::ErrorFilter::Validation); let module = self .ctx .device .create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("adjust-generated"), source: wgpu::ShaderSource::Wgsl(shader.source.as_str().into()), }); // The layout matching what this shader was composed to write. The // structure hash covers the generated source and the source // carries the storage format, so the two can never disagree — a // cached pipeline is always paired with the layout it was built // against. let layout = match shader.output_mode { OutputMode::Encoded => &self.pipeline_layout, OutputMode::LinearWorking => &self.linear_pipeline_layout, }; let pipeline = self.ctx .device .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some("adjust-pipeline"), layout: Some(layout), module: &module, entry_point: Some("main"), compilation_options: Default::default(), cache: None, }); if let Some(err) = pollster::block_on(scope.pop()) { return Err(GpuError::ShaderCompilation(format!( "{err}\n\n--- generated source ---\n{}", numbered(&shader.source) ))); } self.cache.insert(shader.structure_hash, pipeline); } Ok(self .cache .get(&shader.structure_hash) .expect("just inserted")) } /// Move to the other output texture and make sure it is the right size. /// /// The rotation is unconditional; the reallocation is not. Steady-state /// rendering at one viewport size therefore allocates nothing and simply /// ping-pongs between two textures — see [`Self::targets`] for why there /// are two. A resize reallocates whichever one comes up next, so the two /// converge on the new size over two frames rather than in one lump. fn ensure_target(&mut self, width: u32, height: u32) { self.current ^= 1; let slot = &mut self.targets[self.current]; if slot .as_ref() .is_some_and(|t| t.width == width && t.height == height) { return; } let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { label: Some("adjust-output"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: Self::FORMAT, // STORAGE_BINDING to write from compute, TEXTURE_BINDING so the // compositor can sample it, COPY_SRC for `export_pixels`. // // RENDER_ATTACHMENT is never used by this pass and is required // anyway: Slint rejects an imported texture that lacks it // (`TextureImportError::InvalidUsage`), because a compositor // handed a texture has to assume it may need to draw into it. The // format is likewise not a free choice — `Rgba8Unorm` and // `Rgba8UnormSrgb` are the only two the import accepts, which is // why `FORMAT` is what it is. usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); let view = texture.create_view(&Default::default()); self.targets[self.current] = Some(Target { texture, view, width, height, }); } /// Render one frame at the requested output size. /// /// `width`/`height` are the *display* size, which is normally far smaller /// than the image. Rendering at viewport resolution rather than sensor /// resolution is what keeps slider interaction inside the frame budget /// (FR-DSP-1). pub fn render( &mut self, source: &DemosaicedImage, shader: &ComposedShader, width: u32, height: u32, ) -> Result<&wgpu::Texture, GpuError> { self.render_masked(source, shader, width, height, None) } /// TRACES: FR-DEV-3 /// Render one frame with local adjustments applied. /// /// `masks` must be the array [`crate::MaskPass`] rasterised for *this* /// edit: the generated shader addresses slices by index, and an array /// built from a different stack applies each layer's adjustment through /// another layer's mask. Passing `None` is correct only for an edit with /// no active mask layers. pub fn render_masked( &mut self, source: &DemosaicedImage, shader: &ComposedShader, width: u32, height: u32, masks: Option<&crate::MaskArray>, ) -> Result<&wgpu::Texture, GpuError> { if shader.output_mode != OutputMode::Encoded { // Composed for a detail stage and dispatched without one. The // shader writes `rgba16float` and this path binds an `rgba8unorm` // storage texture, which wgpu rejects — but well after the point // where the mistake is legible. Saying so here names the actual // error: the edit has a neighbourhood operation and needs // `render_detailed`. return Err(GpuError::ShaderCompilation( "this shader was composed with a detail stage and writes linear \ working values; render it with `render_detailed` and the \ matching chain from `EditGraph::compose_detail`" .into(), )); } // Any render that does not write the linear intermediate leaves // whatever is in it belonging to some other edit — or some other // photograph. Forgetting this is how a detail chain comes to be run // over a stale colour result, so the key is dropped rather than // reasoned about. self.colour_key = None; let (width, height) = (width.max(1), height.max(1)); self.ensure_target(width, height); let uniforms = Self::fused_uniforms(source, shader); let params_buf = self .ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adjust-params"), contents: bytemuck::cast_slice(&uniforms), usage: wgpu::BufferUsages::UNIFORM, }); // Borrow order: compile first, since `pipeline` takes &mut self. let _ = self.pipeline(shader)?; let pipeline = self .cache .get(&shader.structure_hash) .expect("compiled above"); let target = self.targets[self.current].as_ref().expect("ensured above"); let bind_group = self .ctx .device .create_bind_group(&wgpu::BindGroupDescriptor { label: Some("adjust-bg"), layout: &self.bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(source.view()), }, wgpu::BindGroupEntry { binding: 1, resource: params_buf.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&target.view), }, wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView( masks.map_or(&self.empty_masks, |m| m.view()), ), }, ], }); let mut enc = self .ctx .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("adjust-encoder"), }); { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("adjust-pass"), timestamp_writes: None, }); pass.set_pipeline(pipeline); pass.set_bind_group(0, &bind_group, &[]); pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); } self.ctx.queue.submit(Some(enc.finish())); self.colour_dispatches += 1; Ok(&self.targets[self.current] .as_ref() .expect("ensured above") .texture) } /// TRACES: FR-DEV-3 | FR-DEV-3d | FR-DEV-4 | FR-DSP-1 /// Render one frame with a neighbourhood stage. /// /// `shader` and `detail` must be the two halves of **one** composition — /// `EditGraph::compose_for` and `EditGraph::compose_detail_for` on the same /// graph, at the same output space. The fused pass stops at linear working /// values when a detail stage exists and the last detail pass performs the /// output transform, so a mismatched pair either encodes twice or not at /// all. /// /// An empty `detail` falls through to [`Self::render_masked`], which is /// the honest thing to do rather than an optimisation: an edit with no /// active sharpening *is* an ordinary edit, and it should cost exactly /// what one costs. /// /// # `colour_key`, and why the caller supplies it /// /// It is `Invalidation::through(Affects::Colour)` for this edit, mixed /// with whatever names the photograph — a `VersionId`, typically. When it /// is unchanged, and the size and the composed shader and its uniforms are /// unchanged with it, the fused dispatch is **skipped** and the linear /// intermediate from the previous frame is convolved again. Dragging a /// sharpening slider then costs the detail passes alone, which is the /// reuse FR-DEV-3d asks for and the operational meaning of /// `Affects::Detail`. /// /// The caller supplies it rather than this pass deriving it because only /// the caller knows which *image* is on screen. Everything else that goes /// into the fused dispatch — the shader's structure, its uniform values, /// the output size — is mixed in here, so a caller cannot make the reuse /// unsound by supplying a key that is merely coarse. It can only do so by /// supplying one that fails to distinguish two photographs, which is why /// the identity of the image is spelled out as its job. // Eight arguments, and every one of them is a distinct thing the render // depends on: the image, both halves of the composition, the size, the // masks and the cache key. Bundling them into a struct would move the // problem rather than solve it — the caller would fill in the same eight // fields — and would hide that composing the two halves apart is the one // mistake this signature exists to make visible. #[allow(clippy::too_many_arguments)] pub fn render_detailed( &mut self, source: &DemosaicedImage, shader: &ComposedShader, width: u32, height: u32, masks: Option<&crate::MaskArray>, detail: &ComposedDetail, colour_key: u64, ) -> Result<&wgpu::Texture, GpuError> { if detail.is_empty() { return self.render_masked(source, shader, width, height, masks); } if shader.output_mode != OutputMode::LinearWorking { return Err(GpuError::ShaderCompilation( "this detail chain expects a fused pass composed to hand on \ linear working values, but the shader given encodes its own \ output; compose both halves from the same graph" .into(), )); } let (width, height) = (width.max(1), height.max(1)); self.ensure_target(width, height); let uniforms = Self::fused_uniforms(source, shader); let key = Self::colour_signature(colour_key, shader, &uniforms, masks); let reuse = self.colour_key == Some((key, width, height)); // Compile before borrowing anything: `pipeline` and `colour_target` // both want `&mut self`, and the second holds its borrow across the // encode below. self.pipeline(shader)?; let colour_view = self .detail .colour_target(detail.len(), width, height) .clone(); let mut enc = self .ctx .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("adjust-detail-encoder"), }); if !reuse { let params_buf = self .ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("adjust-params"), contents: bytemuck::cast_slice(&uniforms), usage: wgpu::BufferUsages::UNIFORM, }); let bind_group = self .ctx .device .create_bind_group(&wgpu::BindGroupDescriptor { label: Some("adjust-linear-bg"), layout: &self.linear_bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(source.view()), }, wgpu::BindGroupEntry { binding: 1, resource: params_buf.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&colour_view), }, wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView( masks.map_or(&self.empty_masks, |m| m.view()), ), }, ], }); let pipeline = self .cache .get(&shader.structure_hash) .expect("compiled above"); let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("adjust-pass"), timestamp_writes: None, }); pass.set_pipeline(pipeline); pass.set_bind_group(0, &bind_group, &[]); pass.dispatch_workgroups(width.div_ceil(8), height.div_ceil(8), 1); drop(pass); self.colour_dispatches += 1; } // One encoder for the colour pass and every detail pass, submitted // once — the shape `MaskPass::render` established. Submission order is // the whole of the synchronisation: each pass reads what the previous // one wrote, through the same queue. let target_view = self.targets[self.current] .as_ref() .expect("ensured above") .view .clone(); let ran = self .detail .encode(&mut enc, detail, &target_view, width, height)?; self.ctx.queue.submit(Some(enc.finish())); self.detail_dispatches += ran; self.colour_key = Some((key, width, height)); Ok(&self.targets[self.current] .as_ref() .expect("ensured above") .texture) } /// The fused pass's uniform block, with the source's own values written in. /// /// Split out because both render paths need exactly this and a second copy /// would eventually disagree about where the camera matrix goes — which is /// silent, and corrupts every operation's uniforms downstream of it. fn fused_uniforms(source: &DemosaicedImage, shader: &ComposedShader) -> Vec { // Base uniforms: the camera matrix and as-shot white balance, which // every generated shader reads regardless of which operations are // active. Framing's slots follow them and are filled by the composer, // which is why only the first sixteen are written here. let mut uniforms = shader.uniforms.clone(); if uniforms.len() < RESERVED_FIELDS { uniforms.resize(RESERVED_FIELDS, 0.0); } let m = source.color_matrix(); let wb = source.as_shot_wb(); // Rows padded to vec4 for std140 alignment. uniforms[0..4].copy_from_slice(&[m[0], m[1], m[2], 0.0]); uniforms[4..8].copy_from_slice(&[m[3], m[4], m[5], 0.0]); uniforms[8..12].copy_from_slice(&[m[6], m[7], m[8], 0.0]); // The fourth slot is the non-linear flag, not padding: it tells the // shader whether to linearise the sampled texel before any operation // runs. See `DemosaicedImage::is_non_linear`. let non_linear = if source.is_non_linear() { 1.0 } else { 0.0 }; uniforms[12..16].copy_from_slice(&[wb[0], wb[1], wb[2], non_linear]); // TRACES: FR-DEV-3e // The camera profile's base curve, packed the way the generated block // declares it: four x, four y, then the fifth point and the flag. The // flag is what lets one compiled shader serve a profiled body and an // unprofiled one, so the pipeline cache is not split in two by which // camera took the frame. // // Written here rather than at the call site so that *both* callers — // the plain render and the masked one — carry the profile. Filling it // at one of them was how the two halves of this merge each had it. let curve = source.base_curve(); let on = if curve.is_identity() { 0.0 } else { 1.0 }; let b = dr_pipeline::BASE_CURVE_UNIFORM_OFFSET; uniforms[b..b + 4].copy_from_slice(&curve.xs[0..4]); uniforms[b + 4..b + 8].copy_from_slice(&curve.ys[0..4]); uniforms[b + 8..b + 12].copy_from_slice(&[curve.xs[4], curve.ys[4], on, 0.0]); uniforms } /// TRACES: FR-DEV-3d /// Everything the fused dispatch depends on, in one integer. /// /// The caller's edit key, plus the three things the caller does not know /// about: which pipeline was compiled, what was uploaded to it, and which /// mask array was bound. Hashing the uniforms rather than trusting the /// caller's key to cover them is what makes the reuse safe against a /// caller whose key is coarser than it should be — and the uniforms are /// parameters and matrix coefficients from the CPU, never rendered floats, /// so hashing their bit patterns satisfies ARCH §6.13. fn colour_signature( caller: u64, shader: &ComposedShader, uniforms: &[f32], masks: Option<&crate::MaskArray>, ) -> u64 { let mut h: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |v: u64| { for byte in v.to_le_bytes() { h ^= u64::from(byte); h = h.wrapping_mul(0x100_0000_01b3); } }; mix(caller); mix(shader.structure_hash); for v in uniforms { // Negative zero folded onto zero: the two render identically, and // a slider that reached zero from below must not miss the cache. mix(u64::from(if *v == 0.0 { 0 } else { v.to_bits() })); } match masks { None => mix(0), Some(m) => { let (w, h) = m.size(); mix(1); mix(u64::from(w)); mix(u64::from(h)); mix(u64::from(m.layers())); } } h } /// How many distinct pipelines are compiled. Exposed for tests asserting /// that slider movement does not recompile. pub fn cached_pipelines(&self) -> usize { self.cache.len() } /// How many detail-pass pipelines are compiled. As above, for the stage /// that runs after this one. pub fn cached_detail_pipelines(&self) -> usize { self.detail.cached_pipelines() } /// TRACES: FR-DEV-3d /// Fused colour dispatches encoded since this pass was created. /// /// Exists to be asserted on. The saving `Affects::Detail` buys — a /// sharpening slider that does not re-run the colour chain — is invisible /// in the output by construction, since the picture is meant to be /// identical either way. A counter is the only thing that can see it. pub fn colour_dispatches(&self) -> usize { self.colour_dispatches } /// Detail dispatches encoded since this pass was created. pub fn detail_dispatches(&self) -> usize { self.detail_dispatches } /// How many linear intermediates have been allocated. For tests: see /// [`crate::MaskPass::allocations`] for the regression this catches. pub fn detail_allocations(&self) -> usize { self.detail.allocations() } /// The texture the last render wrote, if there has been one. pub fn output(&self) -> Option<&wgpu::Texture> { self.targets[self.current].as_ref().map(|t| &t.texture) } /// TRACES: FR-EXP-9 | AC-8 /// Copy the output to the CPU **for export**. /// /// This method had a twin, `read_output`, which performed exactly the same /// transfer for the display path. Spike S1 deleted the twin and left this /// one, and the difference between them is worth writing down because it /// is the whole of AC-8. /// /// Reading pixels back to *display* them is what ARCH §6.1 forbids: the /// compositor could have sampled that texture where it stood, and the /// round-trip cost 96% of the frame at 4K — ~7 ms against a 0.28 ms /// compute pass. There is now no method that does it, which is a stronger /// guarantee than a feature gate: the display readback cannot be called /// back into existence by turning something on. /// /// Reading them back to *encode a file* is not a shortcut around anything. /// A JPEG is made of bytes on the CPU and there is no path to one that /// does not pass through here, so this is ungated and belongs in a /// shipping build. pub fn export_pixels(&self) -> Result<(Vec, u32, u32), GpuError> { self.copy_output() } /// The transfer itself. fn copy_output(&self) -> Result<(Vec, 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. pub(crate) fn numbered(src: &str) -> String { src.lines() .enumerate() .map(|(i, l)| format!("{:>4} | {l}", i + 1)) .collect::>() .join("\n") } #[cfg(test)] mod tests { use super::*; use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage}; use dr_pipeline::ops::{colour_mixer, exposure, saturation}; use dr_pipeline::EditGraph; use crate::Demosaicer; fn ctx() -> Option { match pollster::block_on(GpuContext::new_headless()) { Ok(c) => Some(c), Err(e) => { eprintln!("skipping: no GPU adapter ({e})"); None } } } /// A flat mid-grey image, so an operation's effect is unambiguous. fn grey_image(ctx: &GpuContext, level: u16) -> DemosaicedImage { let size = 16u32; let mut data = vec![0u16; (size * size) as usize]; for v in data.iter_mut() { *v = level; } let raw = RawImage { width: size, height: size, data, cfa_pattern: CfaPattern::Rggb, black_level: [0; 4], white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], // Identity, so the test reasons about the operations alone // rather than about a camera's colour response. color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, width: size, height: size, }, }; Demosaicer::new(ctx) .expect("demosaicer") .run(&raw) .expect("demosaic") } /// A white disc on black, centred in a `w`x`h` frame. /// /// The one shape that makes anisotropy unmissable: any transform that /// scales the axes unequally returns it as an ellipse, and the ratio of /// the ellipse's axes *is* the error. fn disc_rgba(w: u32, h: u32, radius: f32) -> Vec { let mut rgba = vec![0u8; (w * h * 4) as usize]; for y in 0..h { for x in 0..w { let dx = x as f32 - w as f32 / 2.0; let dy = y as f32 - h as f32 / 2.0; let v = if (dx * dx + dy * dy).sqrt() < radius { 255 } else { 0 }; let i = ((y * w + x) * 4) as usize; rgba[i] = v; rgba[i + 1] = v; rgba[i + 2] = v; rgba[i + 3] = 255; } } rgba } fn read_centre(ctx: &GpuContext, tex: &wgpu::Texture) -> [u8; 4] { let (w, h) = (tex.width(), tex.height()); read_pixel(ctx, tex, w / 2, h / 2) } /// One pixel, by coordinate. What the geometry tests need: proving a /// rotation moved content requires looking somewhere other than the /// centre, which every rotation leaves fixed. fn read_pixel(ctx: &GpuContext, tex: &wgpu::Texture, x: u32, y: u32) -> [u8; 4] { let w = tex.width(); let h = tex.height(); let unpadded = w * 4; let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; let padded = unpadded.div_ceil(align) * align; let buf = ctx.device.create_buffer(&wgpu::BufferDescriptor { label: Some("adjust-readback"), size: (padded * h) as u64, usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); let mut enc = ctx.device.create_command_encoder(&Default::default()); enc.copy_texture_to_buffer( wgpu::TexelCopyTextureInfo { texture: tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, wgpu::TexelCopyBufferInfo { buffer: &buf, layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h), }, }, wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1, }, ); ctx.queue.submit(Some(enc.finish())); let slice = buf.slice(..); let (tx, rx) = std::sync::mpsc::channel(); slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); }); ctx.device .poll(wgpu::PollType::wait_indefinitely()) .expect("poll"); rx.recv().expect("map").expect("map ok"); let data = slice.get_mapped_range(); let off = (y.min(h - 1) * padded + x.min(w - 1) * 4) as usize; let px = [data[off], data[off + 1], data[off + 2], data[off + 3]]; drop(data); buf.unmap(); px } #[test] fn a_neutral_graph_produces_a_compilable_shader() { // The first thing that could go wrong with codegen: the empty case. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); let shader = EditGraph::default_chain().compose(); pass.render(&img, &shader, 16, 16) .expect("a neutral chain must compile"); } #[test] fn every_operation_generates_compilable_wgsl() { // The test that justifies the whole codegen approach. Each operation // is compiled on its own, so a WGSL error names the operation that // caused it rather than surfacing only in some combination. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); // Cases derived from the chain itself rather than a hand-written // list: every parameter of every operation is exercised, and adding // an operation extends the coverage automatically instead of // silently going untested. let probe = EditGraph::default_chain(); for cap in probe.capabilities() { for p in &cap.params { let dr_pipeline::ParamKind::Scalar { min, max, .. } = p.kind else { continue; }; // Both extremes: a fragment can be valid at one end of its // range and not the other. for value in [min, max] { let mut g = EditGraph::default_chain(); g.set_param(cap.id, p.id, value); let shader = g.compose(); // Through the detail stage rather than through `render`, // because a neighbourhood operation contributes no fused // fragment: its WGSL is generated per resolution and lives // in dispatches of its own. Compiling only the fused half // would leave every kernel in the chain untested here — // and worse, `render` refuses a shader composed to hand on // linear working values, so the omission would arrive as // "invalid WGSL" against a shader that is perfectly valid. // // The scale comes from the graph, so the kernel really is // converted the way a render converts it. The image is // 16×16 and so is the target, which puts the ratio at 1.0 // and keeps an acutance operation from declining to draw // (`RenderScale::resolves`) and compiling its pass-through // instead of the kernel this test exists to check. let scale = g.render_scale(img.size(), (16, 16)); let detail = g.compose_detail(scale); let key = g.invalidation().through(dr_pipeline::Affects::Colour); pass.render_detailed(&img, &shader, 16, 16, None, &detail, key) .unwrap_or_else(|e| { panic!( "{}.{} at {value} generated invalid WGSL:\n{e}", cap.id, p.id ) }); } } } } /// An image bright on one side and dark on the other, so a transform that /// moves content is visible. A flat grey cannot show a rotation at all. /// /// `vertical` puts the bright band at the top; otherwise at the left. fn split_image(ctx: &GpuContext, vertical: bool) -> DemosaicedImage { let size = 32u32; let mut data = vec![0u16; (size * size) as usize]; for y in 0..size { for x in 0..size { let near_start = if vertical { y } else { x } < size / 2; data[(y * size + x) as usize] = if near_start { 12000 } else { 500 }; } } let raw = RawImage { width: size, height: size, data, cfa_pattern: CfaPattern::Rggb, black_level: [0; 4], white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), base_curve: BaseCurve::IDENTITY, 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(); // 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); let scale = g.render_scale(img.size(), (w, h)); let detail = g.compose_detail(scale); // A neighbourhood operation is active and yet emits no fused block: it // reads pixels it is not writing, so it is a dispatch of its own. The // ones that are come from the detail chain rather than from a list // here, which keeps the count exact as sharpening, noise reduction and // clarity arrive instead of loosening it to an inequality. let neighbourhood: std::collections::BTreeSet<&str> = detail .passes .iter() .map(|p| p.label.split('/').next().expect("/")) .collect(); assert_eq!( shader.source.matches("---- ").count(), // Every fusable operation, plus framing — which emits a stage of // its own rather than an operation block, and is not in // `descriptors` — less the ones that run after this shader. g.descriptors().len() + 1 - neighbourhood.len(), "every fusable operation and the framing should be active" ); assert!( shader.source.contains("---- framing ----"), "framing must reach the shader alongside the colour operations" ); // Both halves, from the one graph: with a detail stage present the // fused pass stops at linear working values and the last detail pass // performs the output transform, so rendering only the first half is // not a smaller test — it is a texture format the driver rejects. let key = g.invalidation().through(dr_pipeline::Affects::Colour); pass.render_detailed(&img, &shader, w, h, None, &detail, key) .expect("the full chain must compile"); } #[test] fn exposure_brightens_the_image() { // Proves the uniforms actually reach the shader, not merely that it // compiles. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 2000); let neutral = EditGraph::default_chain().compose(); let before = { let t = pass.render(&img, &neutral, 16, 16).expect("render"); read_centre(&ctx, t) }; let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 2.0); let brighter = g.compose(); let after = { let t = pass.render(&img, &brighter, 16, 16).expect("render"); read_centre(&ctx, t) }; assert!( after[0] > before[0], "+2 stops should brighten: {before:?} -> {after:?}" ); } #[test] fn negative_exposure_darkens_the_image() { let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 8000); let neutral = EditGraph::default_chain().compose(); let before = { let t = pass.render(&img, &neutral, 16, 16).expect("render"); read_centre(&ctx, t) }; let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, -2.0); let darker = g.compose(); let after = { let t = pass.render(&img, &darker, 16, 16).expect("render"); read_centre(&ctx, t) }; assert!(after[0] < before[0], "-2 stops should darken"); } #[test] fn full_negative_saturation_produces_grey() { // A neutral grey source cannot show this, so use a coloured one: // a strongly red-weighted image must come out with equal channels. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let size = 16u32; let mut data = vec![0u16; (size * size) as usize]; for y in 0..size { for x in 0..size { // RGGB: make red photosites bright, others dim. let c = CfaPattern::Rggb.colour_at(x, y); data[(y * size + x) as usize] = if c == 0 { 12000 } else { 3000 }; } } let raw = RawImage { width: size, height: size, data, cfa_pattern: CfaPattern::Rggb, black_level: [0; 4], white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, width: size, height: size, }, }; let img = Demosaicer::new(&ctx) .expect("demosaicer") .run(&raw) .expect("demosaic"); let mut g = EditGraph::default_chain(); g.set_param(saturation::ID, saturation::SATURATION, -100.0); let shader = g.compose(); let px = { let t = pass.render(&img, &shader, 16, 16).expect("render"); read_centre(&ctx, t) }; let spread = px[0].abs_diff(px[1]).max(px[1].abs_diff(px[2])); assert!( spread <= 2, "-100 saturation must produce grey, got {px:?} (spread {spread})" ); } #[test] fn each_colour_band_gets_its_own_pipeline() { // The bug this closes, end to end and through one cache: the mixer // emits code only for the bands that are set, but the cache key was // the set of *active operations*, which is "colour_mixer" whichever // band that is. A red adjustment and a blue one hashed alike, so the // second render reused the first's compiled pipeline and uploaded its // uniform into the first band's slot — whichever band compiled first // kept acting and every other slider did nothing. // // Ordered red first deliberately: red is the first band declared, and // is the one users reported as the only one that worked. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = blue_image(&ctx); // `None` renders the chain untouched, which is the baseline the two // band settings are measured against. fn band( ctx: &GpuContext, pass: &mut AdjustPass, img: &DemosaicedImage, set: Option<(&'static str, f32)>, ) -> [u8; 4] { let mut g = EditGraph::default_chain(); if let Some((id, v)) = set { g.set_param(colour_mixer::ID, dr_pipeline::ParamId(id), v); } let shader = g.compose(); let t = pass.render(img, &shader, 16, 16).expect("render"); read_centre(ctx, t) } let neutral = band(&ctx, &mut pass, &img, None); // Red first, so its pipeline is the one in the cache when blue asks. let reds_turn = band(&ctx, &mut pass, &img, Some(("red_sat", 100.0))); let blues_turn = band(&ctx, &mut pass, &img, Some(("blue_sat", -100.0))); let spread = |p: [u8; 4]| p[2].abs_diff(p[0]); assert_eq!( spread(reds_turn), spread(neutral), "a blue pixel is outside the red band, so red must leave it alone" ); assert!( spread(blues_turn) + 8 < spread(neutral), "blue at -100 must desaturate a blue pixel: {neutral:?} -> {blues_turn:?}" ); } /// A demosaiced image whose pixels sit at the centre of the blue band. /// /// Red and green equal, blue well above them, which `rgb_to_hcl` reads as /// exactly 240 degrees — full weight to blue, none to any other band. fn blue_image(ctx: &GpuContext) -> DemosaicedImage { let size = 16u32; let mut data = vec![0u16; (size * size) as usize]; for y in 0..size { for x in 0..size { let c = CfaPattern::Rggb.colour_at(x, y); data[(y * size + x) as usize] = if c == 2 { 12000 } else { 3000 }; } } let raw = RawImage { width: size, height: size, data, cfa_pattern: CfaPattern::Rggb, black_level: [0; 4], white_level: 16383, wb_coeffs: [1.0, 1.0, 1.0, 1.0], color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]), base_curve: BaseCurve::IDENTITY, crop: CropRect { x: 0, y: 0, width: size, height: size, }, }; Demosaicer::new(ctx) .expect("demosaicer") .run(&raw) .expect("demosaic") } #[test] fn moving_a_slider_does_not_recompile() { // The property the pipeline cache exists for. Recompiling per frame // would make slider interaction unusable regardless of shader cost. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); let mut g = EditGraph::default_chain(); for i in 1..=10 { g.set_param(exposure::ID, exposure::EXPOSURE, i as f32 * 0.2); let shader = g.compose(); pass.render(&img, &shader, 16, 16).expect("render"); } assert_eq!( pass.cached_pipelines(), 1, "ten slider positions must share one compiled pipeline" ); } #[test] fn a_different_operation_set_compiles_its_own_pipeline() { let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); pass.render(&img, &g.compose(), 16, 16).expect("render"); assert_eq!(pass.cached_pipelines(), 1); g.set_param(saturation::ID, saturation::SATURATION, 40.0); pass.render(&img, &g.compose(), 16, 16).expect("render"); assert_eq!(pass.cached_pipelines(), 2); // Returning to the earlier state must reuse, not compile a third. g.set_param(saturation::ID, saturation::SATURATION, 0.0); pass.render(&img, &g.compose(), 16, 16).expect("render"); assert_eq!(pass.cached_pipelines(), 2); } #[test] fn output_is_opaque_everywhere() { // A zero alpha would composite as an invisible image, which reads as // "nothing rendered" rather than as a bug in this pass. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); let shader = EditGraph::default_chain().compose(); let t = pass.render(&img, &shader, 16, 16).expect("render"); assert_eq!(read_centre(&ctx, t)[3], 255); } /// TRACES: FR-DSP-1 | AC-8 /// A disc on a non-square frame, rendered through a quarter turn. /// /// Geometry, asserted on real pixels rather than on the generated WGSL — /// reading the shader and reasoning about which space `p` lives in is /// exactly how a plausible formula gets written twice. #[test] fn a_quarter_turn_keeps_a_circle_circular() { let Some(ctx) = ctx() else { return }; // 3:2, so an aspect mistake is a 2.25x distortion rather than a // subtlety. The disc is centred and comfortably inside the frame. let (w, h) = (180u32, 120u32); let rgba = disc_rgba(w, h, 40.0); let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload"); let mut graph = dr_pipeline::EditGraph::default_chain(); graph.rotate_quarters(1); let (ow, oh) = graph.output_size(w, h); assert_eq!((ow, oh), (h, w), "a quarter turn swaps the output axes"); let mut pass = AdjustPass::new(&ctx); pass.render(&src, &graph.compose(), ow, oh).expect("render"); let (pixels, rw, rh) = pass.export_pixels().expect("read back"); // Measure the disc's extent along each axis, at its centre. let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128; let across = (0..rw).filter(|&x| lit(x, rh / 2)).count(); let down = (0..rh).filter(|&y| lit(rw / 2, y)).count(); assert!(across > 0 && down > 0, "the disc vanished: {across}x{down}"); let ratio = across as f32 / down as f32; assert!( (ratio - 1.0).abs() < 0.08, "a turned circle came back {across} across by {down} down \ (ratio {ratio:.3}); anything but 1 is the frame being sheared" ); } /// The same disc, straightened by a free angle rather than turned. /// /// A rotation is rigid: a circle stays a circle at any angle. If the /// straighten happens in a space whose axes carry different scales, the /// circle comes back as an ellipse — and on a photograph that reads as the /// frame being sheared. #[test] fn straightening_keeps_a_circle_circular() { let Some(ctx) = ctx() else { return }; let (w, h) = (180u32, 120u32); let rgba = disc_rgba(w, h, 34.0); let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload"); let mut graph = dr_pipeline::EditGraph::default_chain(); // A deliberate angle, not a nudge: a shear scales with the angle and // a degree would hide inside the tolerance. graph.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 20.0); let (ow, oh) = graph.output_size(w, h); let mut pass = AdjustPass::new(&ctx); pass.render(&src, &graph.compose(), ow, oh).expect("render"); let (pixels, rw, rh) = pass.export_pixels().expect("read back"); let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128; let across = (0..rw).filter(|&x| lit(x, rh / 2)).count(); let down = (0..rh).filter(|&y| lit(rw / 2, y)).count(); assert!(across > 0 && down > 0, "the disc vanished: {across}x{down}"); let ratio = across as f32 / down as f32; assert!( (ratio - 1.0).abs() < 0.08, "a straightened circle came back {across} across by {down} down \ (ratio {ratio:.3}); a rotation is rigid, so anything but 1 is shear" ); } /// TRACES: FR-DEV-3 | FR-DSP-1 /// The same disc again, straightened *and* turned. /// /// The case neither test above reaches, and the one a portrait photograph /// hits every time. A quarter turn — the user's or the file's EXIF tag — /// swaps the frame's axes, so the space the straightening happens in is no /// longer the source's: measuring a 2:3 frame with a 3:2 aspect stretches /// one axis against the other by 2.25, and the rotation that follows comes /// out as a shear. Each transform alone looks right, which is exactly why /// it survived: only the pair is wrong. #[test] fn straightening_a_turned_frame_keeps_a_circle_circular() { let Some(ctx) = ctx() else { return }; let (w, h) = (180u32, 120u32); let rgba = disc_rgba(w, h, 34.0); let src = DemosaicedImage::from_rgba8(&ctx, &rgba, w, h).expect("upload"); // Every route to a swapped frame: the button, the file's tag, and the // two composed. All three reach the shader as one permutation, and a // fix that only covers one of them is not a fix. for (name, turns, tag) in [ ("a user quarter turn", 1, 1u16), ("an EXIF-portrait file", 0, 6), ("both, composed", 2, 6), ] { let mut graph = dr_pipeline::EditGraph::default_chain(); graph.set_orientation(dr_types::Orientation::from_exif(tag)); graph.rotate_quarters(turns); graph.set_param(dr_pipeline::framing::ID, dr_pipeline::framing::ANGLE, 20.0); let (ow, oh) = graph.output_size(w, h); assert_eq!((ow, oh), (h, w), "{name}: the frame should be portrait"); let mut pass = AdjustPass::new(&ctx); pass.render(&src, &graph.compose(), ow, oh).expect("render"); let (pixels, rw, rh) = pass.export_pixels().expect("read back"); let lit = |x: u32, y: u32| pixels[((y * rw + x) * 4) as usize] > 128; let across = (0..rw).filter(|&x| lit(x, rh / 2)).count(); let down = (0..rh).filter(|&y| lit(rw / 2, y)).count(); assert!(across > 0 && down > 0, "{name}: the disc vanished"); let ratio = across as f32 / down as f32; assert!( (ratio - 1.0).abs() < 0.08, "{name}: a straightened circle came back {across} across by \ {down} down (ratio {ratio:.3}); the turn and the angle are \ disagreeing about which frame they act in" ); } } #[test] fn the_output_is_importable_by_a_compositor() { // Every condition Slint checks before it will adopt a texture // (`slint::wgpu_29`: `TextureImportError`). They are asserted here, // in the crate that owns the descriptor, because failing them does not // fail a build or a shader — it fails at runtime, on the frame the // image is handed over, and only where there is a screen to hand it // to. Nothing else in the test suite would notice. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); let shader = EditGraph::default_chain().compose(); let t = pass.render(&img, &shader, 16, 16).expect("render"); assert!( matches!( t.format(), wgpu::TextureFormat::Rgba8Unorm | wgpu::TextureFormat::Rgba8UnormSrgb ), "import accepts only the two 8-bit RGBA formats, not {:?}", t.format() ); assert!( t.usage().contains(wgpu::TextureUsages::TEXTURE_BINDING), "the compositor has to sample it" ); assert!( t.usage().contains(wgpu::TextureUsages::RENDER_ATTACHMENT), "Slint requires this even though the adjust pass never uses it" ); } /// TRACES: FR-DSP-1 | AC-8 #[test] fn consecutive_frames_are_different_textures() { // Not a detail: the compositor is handed this texture rather than a // copy of its pixels, and Slint repaints only when the image property // *changes*. Two images over one texture compare equal, so writing the // same texture every frame would leave a slider moving the pixels on // the GPU and nothing at all on screen — the frame would be correct // and invisible, which is the worst kind of wrong. // // No display is needed to catch it, because the equality Slint tests // is the equality asserted here. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); let shader = EditGraph::default_chain().compose(); let first = pass.render(&img, &shader, 16, 16).expect("render").clone(); let second = pass.render(&img, &shader, 16, 16).expect("render").clone(); assert_ne!(first, second, "the compositor cannot tell these two apart"); // And back again, so the alternation is a rotation between two rather // than an allocation per frame — which at 4K would be 33 MB a frame. let third = pass.render(&img, &shader, 16, 16).expect("render").clone(); assert_eq!(first, third, "a third texture was allocated"); } #[test] fn the_output_resizes_with_the_viewport() { let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = grey_image(&ctx, 4000); let shader = EditGraph::default_chain().compose(); let t = pass.render(&img, &shader, 64, 48).expect("render"); assert_eq!((t.width(), t.height()), (64, 48)); let t = pass.render(&img, &shader, 32, 96).expect("render"); assert_eq!((t.width(), t.height()), (32, 96)); } /// A flat RGBA8 image on the JPEG path — already gamma-encoded, as a /// decoded JPEG is. fn jpeg_image(ctx: &GpuContext, rgb: [u8; 3]) -> DemosaicedImage { let size = 16u32; let mut data = Vec::with_capacity((size * size) as usize * 4); for _ in 0..size * size { data.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]); } DemosaicedImage::from_rgba8(ctx, &data, size, size).expect("upload") } #[test] fn a_jpeg_survives_a_neutral_graph_unchanged() { // The property the whole JPEG path rests on: decoding the transfer // function on the way in and re-encoding on the way out must be exact // inverses. If they are not, merely *opening* a JPEG in develop mode // shifts its tones — the file would be altered by being looked at, // which is far worse than the panel being disabled. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let shader = EditGraph::default_chain().compose(); // Several levels: a transfer-function error is smallest in the // mid-tones and largest near the ends, so one sample could miss it. for level in [16u8, 64, 128, 200, 240] { let img = jpeg_image(&ctx, [level, level, level]); let t = pass.render(&img, &shader, 16, 16).expect("render"); let got = read_centre(&ctx, t); for (i, c) in got[..3].iter().enumerate() { let delta = (i32::from(*c) - i32::from(level)).abs(); assert!( delta <= 2, "channel {i} at level {level} came back {c} (delta {delta}) \ — the transfer functions are not inverses" ); } } } #[test] fn a_jpeg_keeps_its_colour_through_a_neutral_graph() { // Identity colour matrix and neutral white balance, specifically: a // camera matrix applied to an image already in sRGB primaries would // skew colour, and this is what catches it. A grey patch cannot — // every matrix maps neutral to neutral. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let shader = EditGraph::default_chain().compose(); let img = jpeg_image(&ctx, [200, 90, 40]); let t = pass.render(&img, &shader, 16, 16).expect("render"); let got = read_centre(&ctx, t); for (i, expected) in [200u8, 90, 40].iter().enumerate() { let delta = (i32::from(got[i]) - i32::from(*expected)).abs(); assert!( delta <= 2, "channel {i} expected ~{expected}, got {} — colour is being \ transformed on a source that needs no transform", got[i] ); } } #[test] fn exposure_brightens_a_jpeg() { // Proves the operations reach the JPEG path at all, and that they act // on linearised values: an exposure stop is a multiply, which is only // meaningful once the gamma encoding is undone. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let img = jpeg_image(&ctx, [110, 110, 110]); let neutral = EditGraph::default_chain().compose(); let before = { let t = pass.render(&img, &neutral, 16, 16).expect("render"); read_centre(&ctx, t) }; let mut g = EditGraph::default_chain(); g.set_param(exposure::ID, exposure::EXPOSURE, 1.0); let brighter = g.compose(); let after = { let t = pass.render(&img, &brighter, 16, 16).expect("render"); read_centre(&ctx, t) }; assert!( after[0] > before[0], "+1 stop should brighten a JPEG: {before:?} -> {after:?}" ); // One stop on a linear value is a doubling, which after re-encoding // lands near 1.5x the encoded value rather than 2x. Checking the // magnitude is what distinguishes "linearised correctly" from // "doubled the gamma-encoded value", which would blow straight to // white — the exact bug a brightness-only assertion would miss. assert!( after[0] < 255, "a stop from mid-grey must not clip: {} — the encoding was \ probably not undone before the multiply", after[0] ); } #[test] fn every_output_colour_space_renders_what_the_colorimetry_predicts() { // TRACES: FR-EXP-2 // The shader carries constants generated from `dr_types::colour`; this // recomputes the same conversion on the CPU and demands the GPU agree. // A transposed matrix, a transfer function applied before the // primaries, or a clip in the wrong place all compile perfectly and // simply produce the wrong colour — none of which a "did it compile" // test would notice. // // A saturated patch, deliberately: every one of these spaces maps a // neutral to itself, so a grey would agree with all four. let Some(ctx) = ctx() else { return }; let mut pass = AdjustPass::new(&ctx); let source = [200u8, 90, 40]; let img = jpeg_image(&ctx, source); // The JPEG path linearises with the sRGB curve, so this is the value // reaching the output stage. let linear: Vec = 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" ); } }