DemosaicedImage::linear_rgb16_window uploads part of a linear DNG, or a box-reduced copy of it, and says where it sits in the frame; size() now reports the frame and texture_size() the texels, and the fused pass writes the window into the shader's uniforms. EditGraph::source_region finds the part of the source a view reads, and tiles::plan cuts a render too large for one texture into halo-grown, grid-aligned tiles. The GPU test renders frames a tile at a time from their own windows and compares them with the whole: identical for point operations, within one code value when straightened with clarity on.
1851 lines
70 KiB
Rust
1851 lines
70 KiB
Rust
//! 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<StrokeHeader>,
|
||
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<bool>,
|
||
}
|
||
|
||
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<Self, GpuError> {
|
||
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<wgpu::TextureView>,
|
||
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<Self, GpuError> {
|
||
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,
|
||
combine_intersect: 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<Scratch>,
|
||
array: Option<MaskArray>,
|
||
/// 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<Self, GpuError> {
|
||
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),
|
||
);
|
||
// TRACES: FR-DEV-19a
|
||
// `dst * src`: what the mask had, kept only in proportion to how much
|
||
// of it this part also covers. The same three vertices and the same
|
||
// scratch, so a third set operation is a third blend state and
|
||
// nothing more — which is what `Join::apply` states on the CPU and
|
||
// `the_joins_match_their_definition` holds this to.
|
||
let combine_intersect = combine(
|
||
"mask-combine-intersect",
|
||
blend_state(wgpu::BlendFactor::Zero, wgpu::BlendFactor::Src),
|
||
);
|
||
|
||
// 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,
|
||
combine_intersect,
|
||
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`.
|
||
// TRACES: FR-DEV-19a
|
||
// The shown parts, not the parts: a hidden one is skipped here
|
||
// and nowhere else, and the first *shown* part is the one that
|
||
// opens the fold. Which can leave nothing — a revealed layer with
|
||
// every part hidden — and that clears the slice rather than
|
||
// leaving whatever the last rasterisation put there to be read
|
||
// back as this mask.
|
||
let shown: Vec<&dr_pipeline::mask::MaskPart> = layer.shown_parts().collect();
|
||
if shown.is_empty() {
|
||
self.clear_slice(&mut encoder, slot as u32);
|
||
continue;
|
||
}
|
||
let direct = shown.len() == 1 && !shown[0].invert;
|
||
if !direct {
|
||
self.ensure_scratch(width, height)?;
|
||
}
|
||
|
||
for (index, part) in shown.iter().copied().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.texture_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,
|
||
Join::Intersect => &self.combine_intersect,
|
||
});
|
||
pass.set_bind_group(0, &bind_group, &[]);
|
||
pass.draw(0..3, 0..1);
|
||
}
|
||
|
||
/// Leave a layer's slice covering nothing.
|
||
///
|
||
/// A pass that clears and draws nothing, for the one case where a layer
|
||
/// reaches the array with no part to draw: every part hidden while the
|
||
/// layer is being revealed. The slice has to be written, because the
|
||
/// shader reads it whatever this function did.
|
||
fn clear_slice(&self, encoder: &mut wgpu::CommandEncoder, slot: u32) {
|
||
let target = self.slice_view(slot);
|
||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("mask-clear-pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: &target,
|
||
depth_slice: None,
|
||
resolve_target: None,
|
||
ops: wgpu::Operations {
|
||
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,
|
||
});
|
||
}
|
||
|
||
/// 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<GpuContext> {
|
||
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, 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");
|
||
}
|
||
}
|