`cargo fmt --check` is a required step and had drifted across 45 files. Most of it arrived this week: several operations were written in parallel worktrees and merged by hand, and a hand-merge resolves conflicts without ever running the formatter over the result. No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its own commit so the next reader can skip it wholesale rather than search it for one that matters. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1284 lines
46 KiB
Rust
1284 lines
46 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 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::{MaskSource, MaskStack, Stroke, MAX_LAYERS};
|
||
use wgpu::util::DeviceExt;
|
||
|
||
use crate::{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;
|
||
|
||
/// 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,
|
||
_pad1: [f32; 2],
|
||
}
|
||
|
||
/// 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,
|
||
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,
|
||
}
|
||
|
||
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,
|
||
},
|
||
],
|
||
});
|
||
|
||
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 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)?;
|
||
// 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,
|
||
array: None,
|
||
allocations: 0,
|
||
placeholder,
|
||
empty_subject,
|
||
})
|
||
}
|
||
|
||
/// 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.
|
||
pub fn render(
|
||
&mut self,
|
||
stack: &MaskStack,
|
||
labels: Option<&LabelField>,
|
||
subjects: Option<&SubjectMasks>,
|
||
width: u32,
|
||
height: u32,
|
||
) -> 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.active_count().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.active().enumerate().take(MAX_LAYERS) {
|
||
let field = match (&layer.source, labels) {
|
||
(MaskSource::Regions { .. }, None) => {
|
||
log::warn!(
|
||
"mask layer {} is a region mask with no segmentation loaded; skipping",
|
||
layer.id
|
||
);
|
||
continue;
|
||
}
|
||
(MaskSource::Regions { .. }, Some(f)) => f,
|
||
(_, _) => &self.placeholder,
|
||
};
|
||
|
||
// A subject layer whose instance is missing is skipped for the
|
||
// same reason a region layer 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 layer names: the
|
||
// fields are built per layer, in this same order, because two
|
||
// layers over one subject can carry different morphology.
|
||
let subject = match &layer.source {
|
||
MaskSource::Subject { .. } => match subjects.filter(|s| slot < s.len()) {
|
||
Some(s) => (s, slot),
|
||
None => {
|
||
log::warn!("mask layer {} has no distance field; skipping", layer.id);
|
||
continue;
|
||
}
|
||
},
|
||
_ => (&self.empty_subject, 0),
|
||
};
|
||
|
||
let params = self.params(layer, field, width, height);
|
||
match &layer.source {
|
||
MaskSource::Brush { strokes } => {
|
||
self.draw_brush(&mut encoder, slot as u32, ¶ms, strokes, width, height)
|
||
}
|
||
_ => {
|
||
let selected = self.selection_buffer(layer, field);
|
||
self.draw(
|
||
&mut encoder,
|
||
slot as u32,
|
||
¶ms,
|
||
field,
|
||
&selected,
|
||
subject,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
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,
|
||
layer: &dr_pipeline::mask::MaskLayer,
|
||
field: &LabelField,
|
||
width: u32,
|
||
height: u32,
|
||
) -> MaskParams {
|
||
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,
|
||
_pad1: [0.0, 0.0],
|
||
};
|
||
|
||
match &layer.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.
|
||
MaskSource::Subject { .. } => {
|
||
let short = field_short_edge(width, height);
|
||
MaskParams {
|
||
mode: MODE_SUBJECT,
|
||
// `softness` is the feather half-width in pixels.
|
||
softness: (layer.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(layer) * short,
|
||
falloff: falloff_code(layer.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
|
||
},
|
||
}
|
||
}
|
||
|
||
/// 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,
|
||
slot: u32,
|
||
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 array = self.array.as_ref().expect("array ensured by caller");
|
||
let view = 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()
|
||
});
|
||
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("mask-brush-pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: &view,
|
||
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,
|
||
layer: &dr_pipeline::mask::MaskLayer,
|
||
field: &LabelField,
|
||
) -> wgpu::Buffer {
|
||
let mut flags = vec![0u32; field.region_count.max(1) as usize];
|
||
if let MaskSource::Regions { ids, .. } = &layer.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,
|
||
slot: u32,
|
||
params: &MaskParams,
|
||
field: &LabelField,
|
||
selected: &wgpu::Buffer,
|
||
subject: (&SubjectMasks, usize),
|
||
) {
|
||
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")
|
||
}),
|
||
),
|
||
},
|
||
],
|
||
});
|
||
|
||
// 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");
|
||
let view = 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()
|
||
});
|
||
|
||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||
label: Some("mask-pass"),
|
||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||
view: &view,
|
||
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);
|
||
}
|
||
|
||
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 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(layer: &dr_pipeline::mask::MaskLayer) -> f32 {
|
||
use dr_pipeline::mask::Morphology;
|
||
match layer.morphology {
|
||
Morphology::Dilate => layer.morph_radius,
|
||
Morphology::Erode => -layer.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, 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, 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, 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(*erase, *radius, 0.5, 1.0);
|
||
for &(x, y) in path {
|
||
layer.extend_stroke(x, y);
|
||
}
|
||
layer.end_stroke();
|
||
}
|
||
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, 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, 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, 32, 32).expect("render");
|
||
assert_eq!(pass.allocations(), 1);
|
||
|
||
pass.render(&stack, None, None, 32, 32).expect("render");
|
||
assert_eq!(
|
||
pass.allocations(),
|
||
1,
|
||
"same size and layer count should not reallocate"
|
||
);
|
||
|
||
pass.render(&stack, None, None, 64, 64).expect("render");
|
||
assert_eq!(pass.allocations(), 2, "a resize must reallocate");
|
||
}
|
||
}
|