//! Rasterising local-adjustment masks (ARCH §5.4). //! //! Turns a [`MaskStack`]'s rules into an r8unorm texture array, one slice per //! active layer, which the composed adjust shader samples. Nothing here reads //! back, and no mask ever exists in CPU memory. //! //! # What runs when //! //! Rasterising is **not** on the slider path. Dragging exposure on a masked //! layer changes uniforms only; the mask array is reused untouched. This pass //! runs when a mask's *shape* changes — a different selection, a moved //! gradient, a new stroke, a resized output — which is what keeps a local //! adjustment as responsive as a global one. //! //! # The two shapes of pass //! //! A parametric mask is a function of the whole frame, so it is one full-screen //! triangle. A brush is not: a stroke reaches a bounded part of the picture, //! and drawing it over the whole frame would cost `pixels × segments` for a //! mark the size of a thumb. So strokes are drawn over their own bounding //! boxes, one draw each, compositing onto the slice with blend state — see the //! second half of `mask.wgsl`. //! //! # The photograph, bound as an input //! //! A range mask (FR-DEV-10) selects by what a pixel *is*, so this pass reads //! the demosaiced source as well as writing masks. It is bound for every draw //! and looked at by two modes; everything else gets a 1x1 placeholder, for the //! reason the label field below does — the bindings are fixed, and a second //! pipeline differing only in what it ignores costs more than a texel. //! //! Nothing is read back and nothing is rasterised on this side. What crosses //! into CPU memory for a range layer is five floats and a matrix. //! //! # The label field //! //! Region masks index a compacted label field uploaded once per segmentation. //! Compacted, rather than the watershed's raw basin roots, because a root is a //! sparse index into pixel space: indexing a per-region array by one would //! need a table the size of the image, where compacted ids index an array of //! `region_count`. The compaction is CPU-side and once per image, which is the //! same place and cadence the region adjacency graph is already built at. use dr_pipeline::mask::{Join, MaskSource, MaskStack, Stroke, MAX_LAYERS}; use wgpu::util::DeviceExt; use crate::{DemosaicedImage, GpuContext, GpuError}; /// Modes understood by `mask.wgsl`. Kept beside the shader's `switch`. const MODE_REGIONS: u32 = 0; const MODE_LINEAR: u32 = 1; const MODE_RADIAL: u32 = 2; const MODE_SUBJECT: u32 = 3; /// Brush layers go through their own entry points rather than the `switch`, so /// this is only ever read by a person looking at a captured frame. const MODE_BRUSH: u32 = 4; /// TRACES: FR-DEV-10 const MODE_LUMINANCE: u32 = 5; /// TRACES: FR-DEV-10 const MODE_COLOUR: u32 = 6; /// Six vertices — two triangles — per stroke. See `vs_brush`. const VERTICES_PER_STROKE: u32 = 6; #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct MaskParams { width: u32, height: u32, label_width: u32, label_height: u32, mode: u32, region_count: u32, feather: f32, /// Which falloff curve a subject layer uses. Kept in step with the /// `switch` in `mask.wgsl` by `falloff_code`. falloff: u32, centre: [f32; 2], axis: [f32; 2], softness: f32, angle: f32, /// TRACES: FR-DEV-10 /// Source texels per mask texel, per axis. See `image_value` in the /// shader for why a range averages its footprint rather than sampling it. source_step: [f32; 2], /// Camera RGB → linear sRGB, one row per `vec4` because that is the /// alignment a uniform gives a three-component vector anyway. Only a /// range mask reads them. cam_to_srgb: [[f32; 4]; 3], /// `rgb`: as-shot white balance. `w`: non-zero for a gamma-encoded source. /// The same packing the generated adjust shader uses, so the two agree by /// construction rather than by inspection. as_shot_wb: [f32; 4], /// Whether this part is turned over before it joins the mask. Read by the /// combine pass and by nothing else — see `fs_combine`. invert: u32, /// A uniform buffer is a multiple of sixteen bytes, and the flag above /// takes four of them. _pad: [u32; 3], } /// One stroke, as `mask.wgsl`'s `StrokeHeader` expects it. /// /// The bounding box is computed here rather than in the shader because the /// vertex stage needs it before there is anything to compute it from — that is /// the whole trick: the box is what stops the fragment shader running over /// pixels the stroke cannot reach. Finding it is a pass over a few hundred /// coordinates, which is not rasterising a mask on the CPU by any reading of /// ARCH §5.4: no pixel is produced, and the output is four floats. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] struct StrokeHeader { lo: [f32; 2], hi: [f32; 2], radius: f32, hardness: f32, flow: f32, first: u32, count: u32, _pad: u32, } /// The strokes of one layer, packed for the shader. /// /// Empty when the layer has nothing to draw, which is not the same as an error: /// a brush layer with no strokes is a mask covering nothing, and a mask /// covering nothing is what an unpainted layer should be. struct StrokeBatch { headers: Vec, points: Vec<[f32; 2]>, /// Whether each header erases, in step with `headers`. Not in the header /// itself because it selects a *pipeline* rather than a value the shader /// reads: add and erase are two blend states over one fragment shader. erases: Vec, } impl StrokeBatch { /// Pack `strokes` for a mask of `width`×`height`. fn pack(strokes: &[Stroke], width: u32, height: u32) -> Self { let short = field_short_edge(width, height); // Back out of shorter-edge units into normalised ones, per axis. The // radius is a fraction of the shorter edge, so on a landscape frame it // is a smaller fraction of the width than of the height, and growing // the box by the same amount in both would clip the ends of a stroke // along the long axis. let margin = |extent: u32| short / extent.max(1) as f32; let (mx, my) = (margin(width), margin(height)); let texel = (1.0 / width.max(1) as f32).max(1.0 / height.max(1) as f32); let mut out = Self { headers: Vec::with_capacity(strokes.len()), points: Vec::new(), erases: Vec::with_capacity(strokes.len()), }; for stroke in strokes { if stroke.points.is_empty() { continue; } let mut lo = [f32::MAX, f32::MAX]; let mut hi = [f32::MIN, f32::MIN]; for &(x, y) in &stroke.points { lo = [lo[0].min(x), lo[1].min(y)]; hi = [hi[0].max(x), hi[1].max(y)]; } // Grown by the radius, or a stroke would be drawn only where its // centre line ran — and a tap, whose box has no area at all, would // draw nothing whatever. let grow = [stroke.radius * mx + texel, stroke.radius * my + texel]; out.headers.push(StrokeHeader { lo: [ (lo[0] - grow[0]).clamp(0.0, 1.0), (lo[1] - grow[1]).clamp(0.0, 1.0), ], hi: [ (hi[0] + grow[0]).clamp(0.0, 1.0), (hi[1] + grow[1]).clamp(0.0, 1.0), ], radius: stroke.radius, hardness: stroke.hardness, flow: stroke.flow, first: out.points.len() as u32, count: stroke.points.len() as u32, _pad: 0, }); out.erases.push(stroke.erase); out.points .extend(stroke.points.iter().map(|&(x, y)| [x, y])); } out } fn is_empty(&self) -> bool { self.headers.is_empty() } } /// The segmentation a region mask indexes into, resident on the GPU. /// /// Uploaded once per image. Holds the compacted label field and nothing else — /// the hierarchy that produced the ids stays on the CPU, where the interactive /// operations (walk up a level, add a region) are cheap graph work. pub struct LabelField { buffer: wgpu::Buffer, width: u32, height: u32, region_count: u32, } impl LabelField { /// Upload a compacted label field. /// /// `labels` is one region id per pixel, every value below `region_count` — /// exactly [`dr_segment::RegionField::labels`]. pub fn upload( ctx: &GpuContext, labels: &[u32], width: u32, height: u32, region_count: u32, ) -> Result { if labels.len() != (width * height) as usize { return Err(GpuError::InvalidMask(format!( "label field is {} entries, expected {}x{}", labels.len(), width, height ))); } let buffer = ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mask-labels"), contents: bytemuck::cast_slice(labels), usage: wgpu::BufferUsages::STORAGE, }); Ok(Self { buffer, width, height, region_count, }) } pub fn region_count(&self) -> u32 { self.region_count } pub fn size(&self) -> (u32, u32) { (self.width, self.height) } } /// Signed distance fields for the subject layers, resident on the GPU. /// /// **One per active layer, in that order** — not one per detected object. Two /// layers can mask the same subject with different morphology, and closing or /// opening rebuilds the field rather than offsetting it, so the field belongs /// to the layer that shaped it. /// /// `R32Float`, because the values are signed distances in pixels and the /// controls read them at sub-pixel precision. That is four bytes a pixel: /// ~7 MB per layer at a 1600 px proxy, which is the price of making grow, /// shrink and feather cost nothing per frame. pub struct SubjectMasks { views: Vec, width: u32, height: u32, } impl SubjectMasks { /// Upload one distance field per active subject layer. pub fn upload( ctx: &GpuContext, fields: &[&[f32]], width: u32, height: u32, ) -> Result { let expected = (width * height) as usize; let mut views = Vec::with_capacity(fields.len()); for (i, field) in fields.iter().enumerate() { if field.len() != expected { return Err(GpuError::InvalidMask(format!( "subject field {i} is {} values, expected {width}x{height}", field.len() ))); } let texture = ctx.device.create_texture_with_data( &ctx.queue, &wgpu::TextureDescriptor { label: Some("subject-distance"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::R32Float, usage: wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }, wgpu::util::TextureDataOrder::LayerMajor, bytemuck::cast_slice(field), ); views.push(texture.create_view(&wgpu::TextureViewDescriptor::default())); } Ok(Self { views, width, height, }) } pub fn len(&self) -> usize { self.views.len() } pub fn is_empty(&self) -> bool { self.views.is_empty() } pub fn size(&self) -> (u32, u32) { (self.width, self.height) } fn view(&self, index: usize) -> Option<&wgpu::TextureView> { self.views.get(index) } } /// The rasterised masks for one edit./// The rasterised masks for one edit. pub struct MaskArray { texture: wgpu::Texture, view: wgpu::TextureView, width: u32, height: u32, layers: u32, } impl MaskArray { pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::R8Unorm; /// The view the adjust shader binds at `@binding(3)`. pub fn view(&self) -> &wgpu::TextureView { &self.view } pub fn layers(&self) -> u32 { self.layers } pub fn size(&self) -> (u32, u32) { (self.width, self.height) } fn matches(&self, width: u32, height: u32, layers: u32) -> bool { self.width == width && self.height == height && self.layers == layers } } /// Rasterises mask layers. pub struct MaskPass { ctx: GpuContext, layout: wgpu::BindGroupLayout, pipeline: wgpu::RenderPipeline, /// The brush's own bindings: the parameters, plus the stroke buffers. /// /// A second layout rather than two more entries on the first, because a /// brush reads neither the label field nor a distance field and the /// parametric masks read no strokes. Sharing one layout would mean binding /// a placeholder in every draw for something that pass provably cannot /// touch. brush_layout: wgpu::BindGroupLayout, /// One fragment shader, two blend states: `dst + a(1 - dst)` to paint and /// `dst(1 - a)` to erase. brush_add: wgpu::RenderPipeline, brush_erase: wgpu::RenderPipeline, /// Reads a part back out of [`Self::scratch`] and blends it into the /// layer's slice. The set operation is the blend state, so these two are /// one shader as well. combine_layout: wgpu::BindGroupLayout, combine_union: wgpu::RenderPipeline, combine_subtract: wgpu::RenderPipeline, /// Where a part is drawn before it is joined. /// /// One texture for the whole stack rather than one per layer, because /// layers rasterise in sequence and a part is read back immediately after /// it is drawn. Allocated the first time a layer has more than one part, /// so a library of unedited masks never pays for it. scratch: Option, array: Option, /// How many times the array texture has been (re)allocated. /// /// Exists to be asserted on. Reallocating per frame instead of per resize /// is the kind of regression that costs a lot of bandwidth and shows up /// nowhere in the output, so the cheap reuse path is worth a test that /// can actually see it. allocations: usize, /// Bound at the subject slot for any layer that is not a subject. empty_subject: SubjectMasks, /// A one-region, always-unselected field, for a stack with no region mask. /// /// The shader's bindings are fixed, so *something* must be bound at the /// label slots even when rasterising a gradient. A placeholder is cheaper /// and far simpler than two pipelines differing only in what they ignore. placeholder: LabelField, /// TRACES: FR-DEV-10 /// Bound at the image slot for every mask that is not a range. /// /// Never sampled by those modes, so its contents do not matter — but it is /// cleared rather than left undefined, because a placeholder whose value /// is arbitrary is one that makes a binding mistake look like a mask that /// nearly works. empty_image: wgpu::TextureView, } impl MaskPass { pub fn new(ctx: &GpuContext) -> Result { let scope = ctx.device.push_error_scope(wgpu::ErrorFilter::Validation); let module = ctx .device .create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("mask"), source: wgpu::ShaderSource::Wgsl(include_str!("shaders/mask.wgsl").into()), }); let layout = ctx .device .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("mask-bgl"), entries: &[ uniform_entry(0), storage_entry(1), storage_entry(2), wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { // `filterable: false`: R32Float cannot be filtered // without an optional feature, and the shader loads // texels and interpolates them itself anyway. sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, // TRACES: FR-DEV-10 // The photograph, for a range mask. Unfilterable for the // same reason the field above is: every read is a // `textureLoad`, and this pipeline binds no sampler. wgpu::BindGroupLayoutEntry { binding: 6, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, ], }); let pipeline_layout = ctx .device .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("mask-layout"), bind_group_layouts: &[Some(&layout)], immediate_size: 0, }); let pipeline = ctx .device .create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("mask-pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[], }, fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs"), compilation_options: Default::default(), targets: &[Some(MaskArray::FORMAT.into())], }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }); let brush_layout = ctx .device .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("mask-brush-bgl"), entries: &[ uniform_entry(0), // Visible to the vertex stage too: the stroke headers are // where the bounding box comes from, and the box is what // the vertex shader draws. wgpu::BindGroupLayoutEntry { visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, ..storage_entry(4) }, storage_entry(5), ], }); let brush_pipeline_layout = ctx.device .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("mask-brush-layout"), bind_group_layouts: &[Some(&brush_layout)], immediate_size: 0, }); let brush = |label, blend| { ctx.device .create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(&brush_pipeline_layout), vertex: wgpu::VertexState { module: &module, entry_point: Some("vs_brush"), compilation_options: Default::default(), buffers: &[], }, fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs_brush"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: MaskArray::FORMAT, blend: Some(blend), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }) }; // Source-over: what the stroke deposits, plus what it did not cover of // whatever was already there. Two strokes at half flow reach three // quarters rather than one, which is what "build up" means. let brush_add = brush( "mask-brush-add", blend_state(wgpu::BlendFactor::One, wgpu::BlendFactor::OneMinusSrc), ); // The pipelines that join one part to the mask so far. The blend // state is the set operation and the shader is the same three // vertices either way — which is why adding a way to combine masks // cost no shader arithmetic at all. let combine_layout = ctx.device .create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("mask-combine-bgl"), entries: &[ uniform_entry(0), wgpu::BindGroupLayoutEntry { binding: 7, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { // Loaded texel by texel at matching size, so // there is nothing to filter and no sampler. sample_type: wgpu::TextureSampleType::Float { filterable: false }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, ], }); let combine_pipeline_layout = ctx.device .create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("mask-combine-layout"), bind_group_layouts: &[Some(&combine_layout)], immediate_size: 0, }); let combine = |label, blend| { ctx.device .create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some(label), layout: Some(&combine_pipeline_layout), vertex: wgpu::VertexState { module: &module, entry_point: Some("vs"), compilation_options: Default::default(), buffers: &[], }, fragment: Some(wgpu::FragmentState { module: &module, entry_point: Some("fs_combine"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: MaskArray::FORMAT, blend: Some(blend), write_mask: wgpu::ColorWrites::ALL, })], }), primitive: wgpu::PrimitiveState::default(), depth_stencil: None, multisample: wgpu::MultisampleState::default(), multiview_mask: None, cache: None, }) }; // `max`, not source-over: a union must not build up where two parts // overlap. Two selections that both half-cover a pixel select it half // — adding them would make the overlap of two soft edges harder than // either, which is a seam exactly where a photographer joined two // things to avoid one. let combine_union = combine( "mask-combine-union", wgpu::BlendState { color: MAX_BLEND, alpha: MAX_BLEND, }, ); // `dst * (1 - src)`, which is the erase blend one level up: what the // mask had, minus what this part covers, in proportion to how much of // it the part covers. let combine_subtract = combine( "mask-combine-subtract", blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc), ); // The same, with the deposit thrown away: coverage is only ever taken // off what earlier strokes on this layer put down. There is no negative // coverage to accumulate, so erasing an unpainted layer is a no-op // rather than a mask that comes back inverted. let brush_erase = brush( "mask-brush-erase", blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::OneMinusSrc), ); if let Some(err) = pollster::block_on(scope.pop()) { return Err(GpuError::ShaderCompilation(err.to_string())); } let placeholder = LabelField::upload(ctx, &[0], 1, 1, 0)?; let empty_image = empty_image(ctx); // Everywhere outside, so a layer that somehow reaches this masks // nothing rather than everything. let empty_subject = SubjectMasks::upload(ctx, &[&[-1.0f32][..]], 1, 1)?; Ok(Self { ctx: ctx.clone(), layout, pipeline, brush_layout, brush_add, brush_erase, combine_layout, combine_union, combine_subtract, scratch: None, array: None, allocations: 0, placeholder, empty_subject, empty_image, }) } /// Rasterise every active layer, returning the array to bind. /// /// `labels` may be `None` when no layer is a region mask; a region layer /// without one is skipped rather than drawn wrong, since a mask that /// silently covers the whole frame would apply an edit everywhere. /// /// `source` is the photograph a range layer measures (FR-DEV-10), and it /// is skipped on the same rule for the same reason: without it the shader /// would read a blank placeholder, and a band that happens to contain /// black would then cover the whole frame. pub fn render( &mut self, stack: &MaskStack, labels: Option<&LabelField>, subjects: Option<&SubjectMasks>, source: Option<&DemosaicedImage>, width: u32, height: u32, ) -> Result<&MaskArray, GpuError> { self.render_revealing(stack, labels, subjects, source, width, height, None) } /// TRACES: FR-DEV-19c /// [`Self::render`], also drawing the layer being looked at. /// /// A selection with no adjustment on it changes no pixel, so it is not /// active and has no slice — which is right until somebody asks to *see* /// it, and that is the state a photographer is in from choosing a subject /// until deciding what to do to it. /// /// `reveal` has to be the same one the shader was composed with and the /// same one the distance fields were built for: all three index this array /// by position in [`MaskStack::rendered`], and two of them disagreeing /// shows as an adjustment applied through another layer's mask. #[allow(clippy::too_many_arguments)] pub fn render_revealing( &mut self, stack: &MaskStack, labels: Option<&LabelField>, subjects: Option<&SubjectMasks>, source: Option<&DemosaicedImage>, width: u32, height: u32, reveal: Option<&dr_pipeline::mask::Reveal>, ) -> Result<&MaskArray, GpuError> { // At least one layer, because a zero-layer texture array is invalid // and the shader binds this slot unconditionally. let active = stack.rendered_count(reveal).clamp(1, MAX_LAYERS) as u32; self.ensure_array(width, height, active)?; let mut encoder = self .ctx .device .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("mask-encoder"), }); for (slot, layer) in stack.rendered(reveal).enumerate().take(MAX_LAYERS) { // **The path a mask with one part takes is the path every mask // took before parts existed**: drawn straight into the layer's // slice, cleared by the draw itself. Nothing about an unedited // library's rendering changes, and the scratch texture is never // allocated for it. // // An inverted base is the exception, because turning a part over // is done where it is read back rather than where it is drawn — // a brush deposits dabs and cannot know what the rest of the // frame is. See `fs_combine`. let direct = layer.parts().len() == 1 && !layer.base().invert; if !direct { self.ensure_scratch(width, height)?; } for (index, part) in layer.parts().iter().enumerate() { let base = index == 0; let field = match (&part.source, labels) { (MaskSource::Regions { .. }, None) => { log::warn!( "mask layer {} is a region mask with no segmentation loaded; skipping", layer.id ); if base { break; } continue; } (MaskSource::Regions { .. }, Some(f)) => f, (_, _) => &self.placeholder, }; // A subject part whose instance is missing is skipped for the // same reason a region part without a segmentation is: an // absent mask that defaults to "everything" would apply the // adjustment to the whole photograph, which is a much louder // failure than none. // // Indexed by *slot*, not by the instance the part names: the // fields are built per layer, in this same order, because two // layers over one subject can carry different morphology. // Which is also why only a base part can have one — a model // part joined to a mask has no field built for it yet, and it // is skipped rather than drawn against a placeholder that // would cover the frame. let subject = match &part.source { // Category alongside Subject: both are model coverage // turned into a distance field, both are built per layer // in this same order, and leaving a category out of here // is precisely the failure the comment above warns about — // it binds the 1x1 placeholder, so the mask covers // everything and the adjustment silently goes global. MaskSource::Subject { .. } | MaskSource::Category { .. } => { match subjects.filter(|s| base && slot < s.len()) { Some(s) => (s, slot), None => { log::warn!( "part {} of mask layer {} has no distance field; skipping", part.id, layer.id ); if base { break; } continue; } } } _ => (&self.empty_subject, 0), }; // TRACES: FR-DEV-10 // A range part with no photograph bound is skipped rather than // drawn against the placeholder, on exactly the rule the two // cases above follow: an absent mask that defaults to // "everything" takes a local adjustment global, which is a far // quieter failure than a part that visibly did not render. let image = match (&part.source, source) { (s, None) if s.is_range() => { log::warn!( "mask layer {} selects a range with no image loaded; skipping", layer.id ); if base { break; } continue; } (_, image) => image, }; let params = self.params(part, field, image, width, height); let target = if direct { self.slice_view(slot as u32) } else { self.scratch_view() }; match &part.source { MaskSource::Brush { strokes } => { self.draw_brush(&mut encoder, &target, ¶ms, strokes, width, height) } _ => { let selected = self.selection_buffer(part, field); self.draw( &mut encoder, &target, ¶ms, field, &selected, subject, image, ); } } if !direct { // The first part joins a cleared slice, so it lands // exactly as it was drawn whichever way it says it joins — // there is nothing yet for a subtraction to take away // from, and a mask that began by subtracting from nothing // would render as empty however it was painted afterwards. let join = if base { Join::Union } else { part.join }; self.combine(&mut encoder, slot as u32, join, base, ¶ms); } } } self.ctx.queue.submit([encoder.finish()]); Ok(self.array.as_ref().expect("array was just ensured")) } /// The currently rasterised array, if any. pub fn array(&self) -> Option<&MaskArray> { self.array.as_ref() } /// How many times the array texture has been allocated. For tests. pub fn allocations(&self) -> usize { self.allocations } fn params( &self, part: &dr_pipeline::mask::MaskPart, field: &LabelField, source: Option<&DemosaicedImage>, width: u32, height: u32, ) -> MaskParams { // TRACES: FR-DEV-10 // How much of the photograph one mask texel covers. One when there is // no image bound, which is a value nothing reads — the range modes are // the only readers and they are skipped in that case. let source_step = match source { Some(image) => { let (sw, sh) = image.size(); [ sw as f32 / width.max(1) as f32, sh as f32 / height.max(1) as f32, ] } None => [1.0, 1.0], }; // Row-major nine, widened to three `vec4`s. Identity where there is no // image, so a range that somehow reached the shader without one would // read camera values rather than nothing — the same defensive choice // the demosaicer makes for an uncalibrated body. let m = source.map_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0], |i| { i.color_matrix() }); let wb = source.map_or([1.0, 1.0, 1.0], |i| i.as_shot_wb()); let non_linear = source.is_some_and(|i| i.is_non_linear()); let base = MaskParams { width, height, label_width: field.width, label_height: field.height, mode: MODE_REGIONS, region_count: field.region_count, feather: 0.0, falloff: 0, centre: [0.5, 0.5], axis: [1.0, 0.0], softness: 0.0, angle: 0.0, source_step, cam_to_srgb: [ [m[0], m[1], m[2], 0.0], [m[3], m[4], m[5], 0.0], [m[6], m[7], m[8], 0.0], ], as_shot_wb: [wb[0], wb[1], wb[2], if non_linear { 1.0 } else { 0.0 }], invert: u32::from(part.invert), _pad: [0; 3], }; match &part.source { // `softness` carries the layer's feather. The model's coverage is // already a soft sigmoid, so zero means "use the edge the model // drew" rather than "hard edge" — the one place in this shader // where zero softness is not a step. // Feather and morphology are in fractions of the frame's shorter // edge; the field is in proxy pixels. Converting here keeps the // stored edit resolution-independent while the shader works in the // units its texture is actually measured in. // A category shares the subject's mode, and that is not a // shortcut: both arrive as a soft coverage buffer at proxy // resolution and both are turned into a distance field before they // reach here. The shader has no way to tell them apart and no // reason to want one — what differs is only which model produced // the coverage. MaskSource::Subject { .. } | MaskSource::Category { .. } => { let short = field_short_edge(width, height); MaskParams { mode: MODE_SUBJECT, // `softness` is the feather half-width in pixels. softness: (part.feather * short).max(0.0), // `angle` carries the morphology offset — reused rather // than padded, since a subject layer has no ellipse to // rotate. angle: morph_offset(part) * short, falloff: falloff_code(part.falloff), ..base } } MaskSource::Regions { .. } => MaskParams { // A pixel of softening at the proxy-to-output ratio, so the // edge is equally soft whatever size the render is. feather: (width as f32 / field.width.max(1) as f32).clamp(0.0, 4.0), ..base }, MaskSource::Linear { centre, angle, width: ramp, } => MaskParams { mode: MODE_LINEAR, centre: [centre.0, centre.1], axis: [angle.cos(), angle.sin()], softness: *ramp, ..base }, MaskSource::Radial { centre, radii, angle, feather, } => MaskParams { mode: MODE_RADIAL, centre: [centre.0, centre.1], axis: [radii.0.max(1e-6), radii.1.max(1e-6)], softness: *feather, angle: *angle, ..base }, // A brush carries everything else per stroke, so the only fields it // reads here are the output dimensions — which it needs for the // aspect ratio, not for a coordinate. MaskSource::Brush { .. } => MaskParams { mode: MODE_BRUSH, ..base }, // TRACES: FR-DEV-10 // A band, carried in the fields the gradients measure geometry // in. Reused rather than given their own, and it is not a // shortcut: `centre` and `axis` are two pairs of floats whose // meaning has always been the mode's to decide, and a range that // added four more would grow the uniform every other mask pays // for. What matters is that nothing here is a *coordinate* — a // range is not a function of position at all. MaskSource::Luminance { lo, hi, softness } => MaskParams { mode: MODE_LUMINANCE, axis: [*lo, *hi], softness: *softness, ..base }, MaskSource::Colour { hue, hue_width, chroma_lo, chroma_hi, softness, } => MaskParams { mode: MODE_COLOUR, centre: [*hue, *hue_width], axis: [*chroma_lo, *chroma_hi], softness: *softness, ..base }, } } /// Paint one brush layer's slice. /// /// The slice is cleared and then the strokes are blended onto it in the /// order they were painted, which is why this is a pass of its own rather /// than a variation on [`Self::draw`]: the accumulating mask *is* the /// attachment, so an erase can take away what an add put down without /// either of them reading the texture. /// /// Consecutive strokes that composite the same way go out as one draw, /// since the only thing that changes between them is the pipeline. A layer /// painted and never erased is therefore one draw call however many strokes /// it holds. fn draw_brush( &self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, params: &MaskParams, strokes: &[Stroke], width: u32, height: u32, ) { let batch = StrokeBatch::pack(strokes, width, height); // Still worth beginning the pass: the slice has to be cleared, or an // unpainted layer would show whatever the last edit left in it. let bind_group = (!batch.is_empty()).then(|| { let params_buf = self.ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mask-brush-params"), contents: bytemuck::bytes_of(params), usage: wgpu::BufferUsages::UNIFORM, }); // Rebuilt per rasterisation rather than kept and patched. This runs // when a mask's shape changes, not per frame, and a few kilobytes // of stroke geometry is cheaper to upload than a residency scheme // is to get wrong. let headers = self .ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mask-strokes"), contents: bytemuck::cast_slice(&batch.headers), usage: wgpu::BufferUsages::STORAGE, }); let points = self .ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mask-stroke-points"), contents: bytemuck::cast_slice(&batch.points), usage: wgpu::BufferUsages::STORAGE, }); self.ctx .device .create_bind_group(&wgpu::BindGroupDescriptor { label: Some("mask-brush-bind"), layout: &self.brush_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: params_buf.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 4, resource: headers.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 5, resource: points.as_entire_binding(), }, ], }) }); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("mask-brush-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { // Nothing at all until a stroke covers it, which is what // makes an unpainted brush layer mask nothing rather than // everything. load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); let Some(bind_group) = bind_group else { return; }; pass.set_bind_group(0, &bind_group, &[]); let mut run = 0; while run < batch.erases.len() { let erases = batch.erases[run]; let mut end = run + 1; while end < batch.erases.len() && batch.erases[end] == erases { end += 1; } pass.set_pipeline(if erases { &self.brush_erase } else { &self.brush_add }); pass.draw( run as u32 * VERTICES_PER_STROKE..end as u32 * VERTICES_PER_STROKE, 0..1, ); run = end; } } /// One byte-flag per region, or a single zero for a non-region layer. fn selection_buffer( &self, part: &dr_pipeline::mask::MaskPart, field: &LabelField, ) -> wgpu::Buffer { let mut flags = vec![0u32; field.region_count.max(1) as usize]; if let MaskSource::Regions { ids, .. } = &part.source { for &id in ids { if let Some(slot) = flags.get_mut(id as usize) { *slot = 1; } } } self.ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mask-selection"), contents: bytemuck::cast_slice(&flags), usage: wgpu::BufferUsages::STORAGE, }) } #[allow(clippy::too_many_arguments)] fn draw( &self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, params: &MaskParams, field: &LabelField, selected: &wgpu::Buffer, subject: (&SubjectMasks, usize), source: Option<&DemosaicedImage>, ) { let params_buf = self .ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mask-params"), contents: bytemuck::bytes_of(params), usage: wgpu::BufferUsages::UNIFORM, }); let bind_group = self .ctx .device .create_bind_group(&wgpu::BindGroupDescriptor { label: Some("mask-bind"), layout: &self.layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: params_buf.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, resource: field.buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 2, resource: selected.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView( subject.0.view(subject.1).unwrap_or_else(|| { self.empty_subject.view(0).expect("placeholder exists") }), ), }, // TRACES: FR-DEV-10 wgpu::BindGroupEntry { binding: 6, resource: wgpu::BindingResource::TextureView( source.map_or(&self.empty_image, |i| i.view()), ), }, ], }); // The array slice is selected by the attachment rather than by a // uniform the shader reads — one fewer value that can disagree with // where the pass actually writes. let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("mask-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { // Cleared rather than loaded: every pixel is written by the // triangle below, and declaring that lets a tiler skip // reading the previous contents in. load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_pipeline(&self.pipeline); pass.set_bind_group(0, &bind_group, &[]); pass.draw(0..3, 0..1); } /// A view of one layer's slice of the array. fn slice_view(&self, slot: u32) -> wgpu::TextureView { // The array slice is selected by the attachment rather than by a // uniform the shader reads — one fewer value that can disagree with // where the pass actually writes. let array = self.array.as_ref().expect("array ensured by caller"); array.texture.create_view(&wgpu::TextureViewDescriptor { label: Some("mask-slice"), dimension: Some(wgpu::TextureViewDimension::D2), base_array_layer: slot, array_layer_count: Some(1), ..Default::default() }) } fn scratch_view(&self) -> wgpu::TextureView { self.scratch .as_ref() .expect("scratch ensured by caller") .texture .create_view(&wgpu::TextureViewDescriptor { label: Some("mask-part"), ..Default::default() }) } /// Blend the part sitting in [`Self::scratch`] into a layer's slice. /// /// `first` clears the slice instead of loading it, which is both cheaper /// on a tiler and the only thing that makes the fold start from nothing /// covered rather than from whatever the last rasterisation left. fn combine( &self, encoder: &mut wgpu::CommandEncoder, slot: u32, join: Join, first: bool, params: &MaskParams, ) { let params_buf = self .ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("mask-combine-params"), contents: bytemuck::bytes_of(params), usage: wgpu::BufferUsages::UNIFORM, }); let bind_group = self .ctx .device .create_bind_group(&wgpu::BindGroupDescriptor { label: Some("mask-combine-bind"), layout: &self.combine_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: params_buf.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 7, resource: wgpu::BindingResource::TextureView(&self.scratch_view()), }, ], }); let target = self.slice_view(slot); let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("mask-combine-pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &target, depth_slice: None, resolve_target: None, ops: wgpu::Operations { load: if first { wgpu::LoadOp::Clear(wgpu::Color::BLACK) } else { wgpu::LoadOp::Load }, store: wgpu::StoreOp::Store, }, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: None, }); pass.set_pipeline(match join { Join::Union => &self.combine_union, Join::Subtract => &self.combine_subtract, }); pass.set_bind_group(0, &bind_group, &[]); pass.draw(0..3, 0..1); } /// The texture a part is drawn in before it is joined. /// /// Allocated on the first mask that has more than one part and kept at the /// rasterisation size, which is the same size the array is: a part and the /// slice it joins are compared texel for texel, so there is nothing to /// scale and nothing to sample between. fn ensure_scratch(&mut self, width: u32, height: u32) -> Result<(), GpuError> { if self .scratch .as_ref() .is_some_and(|s| s.width == width && s.height == height) { return Ok(()); } let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { label: Some("mask-scratch"), size: wgpu::Extent3d { width, height, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: MaskArray::FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); self.scratch = Some(Scratch { texture, width, height, }); Ok(()) } fn ensure_array(&mut self, width: u32, height: u32, layers: u32) -> Result<(), GpuError> { if self .array .as_ref() .is_some_and(|a| a.matches(width, height, layers)) { return Ok(()); } let texture = self.ctx.device.create_texture(&wgpu::TextureDescriptor { label: Some("mask-array"), size: wgpu::Extent3d { width, height, depth_or_array_layers: layers, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: MaskArray::FORMAT, usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }); let view = texture.create_view(&wgpu::TextureViewDescriptor { label: Some("mask-array-view"), dimension: Some(wgpu::TextureViewDimension::D2Array), ..Default::default() }); self.allocations += 1; self.array = Some(MaskArray { texture, view, width, height, layers, }); Ok(()) } } /// The texture one part is drawn into on its way into a layer's slice. struct Scratch { texture: wgpu::Texture, width: u32, height: u32, } /// `max(dst, src)` — the union of two parts. /// /// Not source-over, which would build up: two parts that each half-cover a /// pixel select it half, and adding them would make the overlap of two soft /// edges harder than either of them, drawing a seam exactly where a /// photographer joined two selections to avoid one. const MAX_BLEND: wgpu::BlendComponent = wgpu::BlendComponent { src_factor: wgpu::BlendFactor::One, dst_factor: wgpu::BlendFactor::One, operation: wgpu::BlendOperation::Max, }; /// TRACES: FR-DEV-10 /// A single black texel, bound at the image slot for a mask that is not a /// range. /// /// Written rather than merely allocated. Undefined contents would be read by /// nothing today, but a binding mistake in a range mask would then produce /// whatever the driver left in memory — a mask that flickers between builds /// and machines, which is the hardest shape of bug this pass could have. fn empty_image(ctx: &GpuContext) -> wgpu::TextureView { let texture = ctx.device.create_texture_with_data( &ctx.queue, &wgpu::TextureDescriptor { label: Some("mask-empty-image"), size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: DemosaicedImage::FORMAT, usage: wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }, wgpu::util::TextureDataOrder::LayerMajor, // Four half-floats of zero. Rgba16Float, so eight bytes. &[0u8; 8], ); texture.create_view(&wgpu::TextureViewDescriptor::default()) } /// The shorter edge of the space the mask is rasterised in. /// /// Feather and morphology are stored as fractions of it, so the same edit is /// the same edge whether it renders to a viewport or to a 24 MP export. fn field_short_edge(width: u32, height: u32) -> f32 { width.min(height).max(1) as f32 } /// How far the boundary moves, in fractions of the shorter edge. /// /// Zero for closing and opening: those are folded into the field itself when /// it is built, because their second half acts on a shape the original field /// does not describe. fn morph_offset(part: &dr_pipeline::mask::MaskPart) -> f32 { use dr_pipeline::mask::Morphology; match part.morphology { Morphology::Dilate => part.morph_radius, Morphology::Erode => -part.morph_radius, Morphology::None | Morphology::Close | Morphology::Open => 0.0, } } /// Kept in step with the `switch` in `mask.wgsl`. fn falloff_code(falloff: dr_pipeline::mask::Falloff) -> u32 { use dr_pipeline::mask::Falloff; match falloff { Falloff::Hard => 0, Falloff::Linear => 1, Falloff::Smooth => 2, Falloff::Gaussian => 3, Falloff::Exponential => 4, } } /// `src * src_factor + dst * dst_factor`, on both components. /// /// The mask is a single channel, so the alpha component is never written — but /// a target still has to declare one, and declaring something different there /// would be a difference nothing could observe and everything could be confused /// by. fn blend_state(src: wgpu::BlendFactor, dst: wgpu::BlendFactor) -> wgpu::BlendState { let component = wgpu::BlendComponent { src_factor: src, dst_factor: dst, operation: wgpu::BlendOperation::Add, }; wgpu::BlendState { color: component, alpha: component, } } fn uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None, }, count: None, } } fn storage_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None, }, count: None, } } #[cfg(test)] mod tests { use super::*; use dr_pipeline::descriptor::ParamId; use dr_pipeline::mask::MaskLayer; fn ctx() -> Option { pollster::block_on(GpuContext::new_headless()).ok() } /// A 4x2 label field: regions 0 and 1 left, 2 and 3 right. fn labels() -> (Vec, u32, u32, u32) { (vec![0, 0, 2, 2, 1, 1, 3, 3], 4, 2, 4) } fn lit(source: MaskSource) -> MaskLayer { let mut layer = MaskLayer::new("m1", source); layer.set_param("exposure", ParamId("exposure"), 1.0); layer } #[test] fn a_label_field_of_the_wrong_size_is_rejected() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; assert!(LabelField::upload(&ctx, &[0, 1, 2], 4, 2, 4).is_err()); } #[test] fn region_masks_rasterise_to_the_selected_regions() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let (data, w, h, n) = labels(); let field = LabelField::upload(&ctx, &data, w, h, n).expect("upload"); let mut stack = MaskStack::new(); stack.push(lit(MaskSource::Regions { signature: 1, level: 4, ids: vec![0, 1], })); let mut pass = MaskPass::new(&ctx).expect("mask pass"); let array = pass .render(&stack, Some(&field), None, None, w, h) .expect("render"); assert_eq!(array.size(), (w, h)); assert_eq!(array.layers(), 1); } /// A region layer with no segmentation must produce nothing rather than /// an all-covering mask, which would apply the edit to the whole frame. #[test] fn a_region_layer_without_labels_is_skipped() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(lit(MaskSource::Regions { signature: 1, level: 4, ids: vec![0], })); let mut pass = MaskPass::new(&ctx).expect("mask pass"); assert!(pass.render(&stack, None, None, None, 8, 8).is_ok()); } #[test] fn gradients_need_no_segmentation() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(lit(MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2, })); stack.push(lit(MaskSource::Radial { centre: (0.5, 0.5), radii: (0.3, 0.2), angle: 0.0, feather: 0.5, })); let mut pass = MaskPass::new(&ctx).expect("mask pass"); let array = pass .render(&stack, None, None, None, 16, 16) .expect("render"); assert_eq!(array.layers(), 2, "one slice per active layer"); } /// One gesture: whether it erases, its radius, and its path. type Gesture = (bool, f32, Vec<(f32, f32)>); fn painted(gestures: &[Gesture]) -> MaskLayer { let mut layer = lit(MaskSource::brush()); for (erase, radius, path) in gestures { layer.begin_stroke(0, *erase, *radius, 0.5, 1.0); for &(x, y) in path { layer.extend_stroke(0, x, y); } layer.end_stroke(0); } layer } /// A brush is the one mask that needs nothing uploaded first — no /// segmentation, no distance field, no label. Requiring one would mean a /// photograph could not be painted on until a model had run over it. #[test] fn a_brush_needs_no_segmentation() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(painted(&[(false, 0.1, vec![(0.2, 0.2), (0.8, 0.8)])])); let mut pass = MaskPass::new(&ctx).expect("mask pass"); let array = pass .render(&stack, None, None, None, 32, 32) .expect("render"); assert_eq!(array.layers(), 1); } /// The box a stroke is drawn over has to be grown by its radius. Packed /// from the points alone, a tap's box has no area at all and the stroke /// would be silently missing from the mask. #[test] fn a_taps_box_has_room_for_its_dab() { let layer = painted(&[(false, 0.25, vec![(0.5, 0.5)])]); let batch = StrokeBatch::pack(layer.strokes(), 64, 32); assert_eq!(batch.headers.len(), 1); let h = &batch.headers[0]; assert!( h.hi[0] - h.lo[0] > 0.2, "wide enough for the dab: {h:?}", h = (h.lo, h.hi) ); assert!( h.hi[1] - h.lo[1] > h.hi[0] - h.lo[0], "and taller than it is wide in normalised units, since the radius \ is a fraction of the shorter edge" ); } /// The pipeline is chosen per stroke, so the packed order has to be the /// painted order — an erase that ended up before its add would put paint /// back that the user removed. #[test] fn packing_keeps_the_painted_order() { let layer = painted(&[ (false, 0.1, vec![(0.2, 0.5), (0.4, 0.5)]), (true, 0.1, vec![(0.3, 0.5)]), (false, 0.1, vec![(0.8, 0.5)]), ]); let batch = StrokeBatch::pack(layer.strokes(), 32, 32); assert_eq!(batch.erases, [false, true, false]); assert_eq!(batch.headers[0].first, 0); assert_eq!(batch.headers[1].first, batch.headers[0].count); } #[test] fn an_empty_stack_still_yields_a_bindable_array() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut pass = MaskPass::new(&ctx).expect("mask pass"); let array = pass .render(&MaskStack::new(), None, None, None, 8, 8) .expect("render"); assert_eq!( array.layers(), 1, "the adjust shader binds this slot whether or not it reads it" ); } #[test] fn the_array_is_reused_when_nothing_changed() { let Some(ctx) = ctx() else { eprintln!("no adapter; skipping"); return; }; let mut stack = MaskStack::new(); stack.push(lit(MaskSource::Linear { centre: (0.5, 0.5), angle: 0.0, width: 0.2, })); let mut pass = MaskPass::new(&ctx).expect("mask pass"); pass.render(&stack, None, None, None, 32, 32) .expect("render"); assert_eq!(pass.allocations(), 1); pass.render(&stack, None, None, None, 32, 32) .expect("render"); assert_eq!( pass.allocations(), 1, "same size and layer count should not reallocate" ); pass.render(&stack, None, None, None, 64, 64) .expect("render"); assert_eq!(pass.allocations(), 2, "a resize must reallocate"); } }