refactor(hatch): unify capability backends

Keep storage and texture transports behind one renderer lifecycle so Pipeline no longer branches by backend.
This commit is contained in:
Hakan Seven committed 2026-07-29 13:40:46 +03:00
1 parent dc7d03dbe1
commit d80637cda7
7 files changed
+385 -322

No files matched your search

+596
View File
@@ -0,0 +1,596 @@
// Hatch rendering — the single, canonical GPU hatch renderer. One draw
// call for all hatches, per-instance data fetched from storage buffers in
// the vertex shader. There is NO per-family cap here: every pattern line
// family is uploaded (`family_count` per instance) and the fragment shader
// loops over all of them, so complex baked patterns (ROOFING, GRAVEL,
// CAROLINA-LEDGESTONE with dozens–hundreds of families) draw in full,
// matching the PDF export. Unavailable on devices that lack vertex-stage
// storage buffers. The old per-hatch renderer with the
// 16-family cap now lives in `wipeout_gpu.rs` and serves only wipeouts.
//
// Data layout — three storage buffers fed from `HatchModel`s:
//
// InstanceBuffer (binding 0) : HatchInstance[] (128 B each)
// color, color2, mode, gradient,
// pattern angle/scale, world_origin,
// boundary_offset, boundary_count,
// family_offset, family_count,
// dash_offset, dash_count, aabb,
// visibility flag (CPU writes; GPU
// skip)
// BoundaryBuffer (binding 1) : vec4<f32>[] (all boundary
// verts concatenated; NaN markers
// preserved as separators just like
// the per-hatch path)
// FamilyBuffer (binding 2) : LineFamilyGpu[] (all line
// families concatenated)
// DashBuffer (binding 3) : f32[] (all dash
// lengths concatenated)
//
// The vertex buffer holds each hatch's tessellated boundary triangles as
// per-vertex local-space positions (`local_xy`) plus a per-vertex
// `instance_index` (u32 attribute) — instance_index lets us avoid relying
// on `@builtin(instance_index)` for portability. An instance whose
// boundary failed to tessellate instead gets an AABB quad here, with
// `poly_test == 1` on its `HatchInstance` so the fragment shader still
// runs the `in_polygon` test to clip the quad to the real shape; on the
// tessellated fast path (`poly_test == 0`) the triangles already bound
// the fill and the fragment shader skips that test. When the visibility
// flag is 0, the vertex shader returns a degenerate position and the
// fragment shader runs zero times for that instance.
//
// Two storage usages — vertex shader reads InstanceBuffer + Boundary
// for the AABB / boundary range; fragment shader reads
// InstanceBuffer + Boundary + Family + Dash. Both stages share group
// 1 with `read_only` access.
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
use iced::wgpu;
use iced::wgpu::util::DeviceExt;
// ── GPU structs ────────────────────────────────────────────────────────────
//
// Layout matches the WGSL `HatchInstance` exactly. `repr(C)` + manual
// padding keeps WGSL's 16-byte alignment rules satisfied for arrays of
// this struct.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct HatchInstance {
pub color: [f32; 4], // 0
pub color2: [f32; 4], // 16 (gradient end)
pub aabb: [f32; 4], // 32 (local-space xmin,ymin,xmax,ymax)
pub world_origin: [f32; 2], // 48 (anchor high half, added back in VS)
pub world_origin_low: [f32; 2], // 56 (anchor low residual — double-single)
pub angle_offset: f32, // 64
pub scale: f32, // 68
pub grad_cos: f32, // 72
pub grad_sin: f32, // 76
pub grad_min: f32, // 80
pub grad_range: f32, // 84
pub mode: u32, // 88 (0=pattern, 1=solid, 2=gradient)
pub visible: u32, // 92 (CPU sets to 0 to skip)
pub boundary_offset: u32, // 96 (first boundary vert index)
pub boundary_count: u32, // 100
pub family_offset: u32, // 104
pub family_count: u32, // 108
/// Signed draw-order depth (-1,1); 0.0 = neutral. Applied as a clip-z
/// bias in the vertex shader so this fill orders against other types.
pub draw_depth: f32, // 112
/// 1 = run the per-fragment `in_polygon` boundary test (fallback path for
/// an instance whose boundary failed to tessellate); 0 = the rasterized
/// triangles already bound the fill, skip the test.
pub poly_test: u32, // 116
/// Gradient shape (`GradientKind::shader_kind`), bit 4 = inverted stops.
pub grad_kind: u32, // 120
pub _pad2: u32, // 124
}
const _: () = assert!(std::mem::size_of::<HatchInstance>() == 128);
/// Split a hatch's f64 world-origin anchor into double-single (high, low) f32
/// pairs so the GPU keeps sub-unit precision at UTM-scale coordinates.
#[inline]
fn split_origin_ds(o: [f64; 2]) -> ([f32; 2], [f32; 2]) {
let hx = o[0] as f32;
let hy = o[1] as f32;
([hx, hy], [(o[0] - hx as f64) as f32, (o[1] - hy as f64) as f32])
}
/// Mirrors the per-family struct used by the existing per-hatch shader,
/// but the dash slice lives in a separate concatenated DashBuffer (the
/// old shader had it embedded). `dash_offset` / `dash_count` index into
/// that flat array.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct LineFamilyGpu {
pub cos_a: f32, // 0
pub sin_a: f32, // 4
pub x0: f32, // 8
pub y0: f32, // 12
pub dx: f32, // 16
pub dy: f32, // 20
pub perp_step: f32, // 24
pub along_step: f32, // 28
pub line_width: f32, // 32
pub period: f32, // 36
pub n_dashes: u32, // 40
pub dash_offset: u32, // 44
}
const _: () = assert!(std::mem::size_of::<LineFamilyGpu>() == 48);
/// Per-vertex data — tessellated boundary triangles in local (world_origin-
/// relative) space, each carrying its instance index (avoids relying on
/// `@builtin(instance_index)` across backends). Instances whose boundary
/// failed to tessellate emit an AABB quad here instead (see `build`).
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct HatchVertex {
pub local_xy: [f32; 2], // 0 — local-space position
pub instance_index: u32, // 8 — index into InstanceBuffer
}
impl HatchVertex {
pub(super) fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HatchVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x2,
},
wgpu::VertexAttribute {
offset: 8,
shader_location: 1,
format: wgpu::VertexFormat::Uint32,
},
],
}
}
}
// ── Batch builder ──────────────────────────────────────────────────────────
/// Pack a list of `HatchModel`s into the four concatenated storage
/// buffers + the per-vertex buffer needed by `hatch.wgsl`.
/// Returns `None` when the input slice is empty (caller skips the
/// hatch render pass entirely).
pub(super) struct StorageHatchBatch {
pub vertex_buffer: wgpu::Buffer,
pub vertex_count: u32,
// The four storage buffers below are referenced via `bind_group` —
// dropping them would invalidate it, but the bind group is the
// only direct consumer. Keep them as fields to keep ownership in
// one place; `#[allow(dead_code)]` silences the read-never warning.
#[allow(dead_code)] pub instance_buffer: wgpu::Buffer,
#[allow(dead_code)] pub boundary_buffer: wgpu::Buffer,
#[allow(dead_code)] pub family_buffer: wgpu::Buffer,
#[allow(dead_code)] pub dash_buffer: wgpu::Buffer,
/// Per-instance visibility flag (1=draw, 0=skip). Stored in its
/// own small storage buffer so per-frame updates don't have to
/// touch the large `instance_buffer`. Vertex shader reads
/// `visibility[instance_index]` — when 0 it emits an out-of-NDC
/// clip position so the GPU clips the primitive before the
/// fragment stage runs.
pub visibility_buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
#[allow(dead_code)] pub instance_count: u32,
/// CPU mirror — `update_visibility` re-uploads this whole slice
/// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches
/// per pan tick. Far cheaper than touching the 128 B-per-instance
/// data.
pub visibility: Vec<u32>,
/// CPU-side mirror of each instance's local-space AABB (world-
/// offset-subtracted, world_origin already added back). Used by
/// `compute_hatch_lod` to evaluate the sub-pixel + frustum cull
/// without reading back from the GPU.
pub instance_aabbs: Vec<[f32; 4]>,
}
impl StorageHatchBatch {
/// One-time build from the full hatch list. Re-uploaded only when
/// `geometry_epoch` advances (mirrors the existing per-hatch
/// upload trigger). Per-frame visibility flips go through
/// [`upload_visibility`].
pub(super) fn build(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel],
) -> Option<Self> {
if hatches.is_empty() {
return None;
}
let mut instances: Vec<HatchInstance> = Vec::with_capacity(hatches.len());
let mut boundary: Vec<[f32; 4]> = Vec::new();
let mut families: Vec<LineFamilyGpu> = Vec::new();
let mut dashes: Vec<f32> = Vec::new();
for h in hatches {
let boundary_offset = boundary.len() as u32;
for &[x, y] in h.boundary.iter() {
// NaN sub-loop separators become the finite sentinel on the
// GPU: Intel drivers fold the shader NaN self-compare to
// `true`, turning separators into vertices and bleeding the
// fill outside its boundary (#386, #416).
if x.is_finite() && y.is_finite() {
boundary.push([x, y, 0.0, 0.0]);
} else {
let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP;
boundary.push([s, s, 0.0, 0.0]);
}
}
let boundary_count = boundary.len() as u32 - boundary_offset;
let family_offset = families.len() as u32;
let mut family_count = 0u32;
let mut grad_kind = 0u32;
let (mode, color2, grad_cos, grad_sin, grad_min, grad_range) = match &h.pattern {
HatchPattern::Solid => (1u32, [0.0; 4], 0.0, 0.0, 0.0, 1.0),
HatchPattern::Gradient { angle_deg, color2, kind, invert } => {
grad_kind = kind.shader_kind() | if *invert { 16 } else { 0 };
if kind.radial() {
// Radial fill: the boundary is stored relative to its
// centre (`world_origin`), so the centre is the local
// origin; radius = the farthest boundary vertex. mode 3.
let radius = radial_radius(&h.boundary);
(3u32, *color2, 0.0, 0.0, 0.0, radius)
} else {
let r = angle_deg.to_radians();
// Gradient projection range (proj_min / proj_range) —
// computed at upload time, identical to per-hatch path.
let (gmin, gmax) = boundary_projection_range(&h.boundary, r);
let grange = (gmax - gmin).max(1.0);
(2u32, *color2, r.cos(), r.sin(), gmin, grange)
}
}
HatchPattern::Pattern(fams) => {
for fam in fams {
let dash_offset = dashes.len() as u32;
for &d in &fam.dashes {
dashes.push(d);
}
let n_dashes = (dashes.len() as u32 - dash_offset).min(u32::MAX);
// QCAD PAT local-frame convention (mirrors
// the storage batch convention: `dy` is
// the perpendicular spacing, `dx` is the along-line
// phase shift — both in family-local coords. The
// shader applies cos_off/sin_off to rotate them.
let perp_step = fam.dy;
let along_step = fam.dx;
// Screen-space derivative drives 1-px line width
// in the shader; this stored field is unused.
let line_width = 0.0_f32;
let period: f32 = fam.dashes.iter().map(|d| d.abs()).sum();
families.push(LineFamilyGpu {
cos_a: fam.angle_deg.to_radians().cos(),
sin_a: fam.angle_deg.to_radians().sin(),
x0: fam.x0,
y0: fam.y0,
dx: fam.dx,
dy: fam.dy,
perp_step,
along_step,
line_width,
period: if n_dashes > 0 { period } else { 0.0 },
n_dashes,
dash_offset,
});
family_count += 1;
}
(0u32, [0.0; 4], 0.0, 0.0, 0.0, 1.0)
}
};
// Boundary AABB in local space (matches the corner quad
// emitted by the vertex shader). The verts are already in
// `world_origin`-relative coords (see scene/mod.rs hatch
// packing), so this AABB lives in that frame.
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
for &[x, y] in h.boundary.iter() {
if x.is_finite() && y.is_finite() {
if x < min_x { min_x = x; }
if y < min_y { min_y = y; }
if x > max_x { max_x = x; }
if y > max_y { max_y = y; }
}
}
if !min_x.is_finite() {
// Empty / all-NaN — skip but keep the slot so indices
// stay in lockstep with the input list (visibility=0).
let (wo_hi, wo_lo) = split_origin_ds(h.world_origin);
instances.push(HatchInstance {
color: h.color,
color2,
aabb: [0.0, 0.0, 0.0, 0.0],
world_origin: wo_hi,
world_origin_low: wo_lo,
angle_offset: h.angle_offset,
scale: h.scale.max(1e-6),
grad_cos,
grad_sin,
grad_min,
grad_range,
mode,
visible: 0,
boundary_offset,
boundary_count,
family_offset,
family_count,
draw_depth: h.draw_depth,
poly_test: 1,
grad_kind,
_pad2: 0,
});
continue;
}
// Pad the AABB so the quad covers any pattern halo + the
// family origin. Mirrors the per-hatch shader's quad sizing
// logic — `diag * 0.8 + max_spacing * 2 * scale`.
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
// `perp_step.abs()` per family — uses the same QCAD local-
// frame convention as `LineFamilyGpu.perp_step` above so
// the quad padding matches what the shader will sample.
let max_spacing = match &h.pattern {
HatchPattern::Pattern(fs) => fs
.iter()
.map(|f| f.dy.abs())
.fold(0.0f32, f32::max),
_ => 5.0,
};
let pad = (diag * 0.8 + max_spacing * 2.0 * h.scale).max(1.0);
let (wo_hi, wo_lo) = split_origin_ds(h.world_origin);
instances.push(HatchInstance {
color: h.color,
color2,
aabb: [min_x - pad, min_y - pad, max_x + pad, max_y + pad],
world_origin: wo_hi,
world_origin_low: wo_lo,
angle_offset: h.angle_offset,
scale: h.scale.max(1e-6),
grad_cos,
grad_sin,
grad_min,
grad_range,
mode,
visible: 1,
boundary_offset,
boundary_count,
family_offset,
family_count,
draw_depth: h.draw_depth,
// Always resolve inside/outside per fragment on the GPU (the
// boundary is uploaded below). Dropping CPU pre-triangulation
// keeps the vertex buffer at 6 verts/hatch regardless of
// boundary complexity, so a hatch-dense drawing can't overflow
// the device buffer limit. Each instance draws its AABB quad.
poly_test: 1,
grad_kind,
_pad2: 0,
});
}
// Empty fallbacks — storage buffers can't be zero-sized.
if boundary.is_empty() {
boundary.push([0.0; 4]);
}
if families.is_empty() {
families.push(LineFamilyGpu::default_filler());
}
if dashes.is_empty() {
dashes.push(0.0);
}
// Vertex buffer — one AABB quad (two triangles, BL,BR,TL, BR,TR,TL) per
// instance; the GPU shader clips each fragment to the boundary via the
// in_polygon test (poly_test == 1). 6 verts/hatch, independent of
// boundary complexity, so the buffer can never overflow the device
// limit no matter how dense the drawing.
let mut verts: Vec<HatchVertex> = Vec::with_capacity(instances.len() * 6);
for (i, inst) in instances.iter().enumerate() {
let [xmin, ymin, xmax, ymax] = inst.aabb;
let quad = [
[xmin, ymin], [xmax, ymin], [xmin, ymax],
[xmax, ymin], [xmax, ymax], [xmin, ymax],
];
for c in quad {
verts.push(HatchVertex { local_xy: c, instance_index: i as u32 });
}
}
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.vertex"),
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.instances"),
contents: bytemuck::cast_slice(&instances),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
});
let boundary_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.boundary"),
contents: bytemuck::cast_slice(&boundary),
usage: wgpu::BufferUsages::STORAGE,
});
let family_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.families"),
contents: bytemuck::cast_slice(&families),
usage: wgpu::BufferUsages::STORAGE,
});
let dash_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.dashes"),
contents: bytemuck::cast_slice(&dashes),
usage: wgpu::BufferUsages::STORAGE,
});
let visibility: Vec<u32> = instances.iter().map(|i| i.visible).collect();
let visibility_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.visibility"),
contents: bytemuck::cast_slice(&visibility),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("hatch.bg"),
layout: bgl,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: instance_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: boundary_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: family_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: dash_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: visibility_buffer.as_entire_binding(),
},
],
});
// CPU AABB mirror — local-space rect with world_origin added
// back, ready for `aabb_offscreen` / `aabb_below_pixel` to
// compare against the camera view_proj.
let instance_aabbs: Vec<[f32; 4]> = instances
.iter()
.map(|i| {
let ox = i.world_origin[0];
let oy = i.world_origin[1];
[
i.aabb[0] + ox,
i.aabb[1] + oy,
i.aabb[2] + ox,
i.aabb[3] + oy,
]
})
.collect();
Some(Self {
vertex_buffer,
vertex_count: verts.len() as u32,
instance_buffer,
boundary_buffer,
family_buffer,
dash_buffer,
visibility_buffer,
bind_group,
instance_count: instances.len() as u32,
visibility,
instance_aabbs,
})
}
/// Push the CPU `visibility` slice to GPU. Call when any
/// element changes (typically per-frame from compute_hatch_lod).
pub(super) fn upload_visibility(&self, queue: &wgpu::Queue) {
queue.write_buffer(
&self.visibility_buffer,
0,
bytemuck::cast_slice(&self.visibility),
);
}
/// Group-1 bind group layout — shared by the pipeline so it can be
/// constructed once at startup. All four bindings are read-only
/// storage and visible to both VS (AABB+visibility lookup) and FS
/// (boundary / family / dash sampling).
#[allow(dead_code)]
pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hatch.bgl"),
entries: &[entry(0), entry(1), entry(2), entry(3), entry(4)],
})
}
}
impl LineFamilyGpu {
fn default_filler() -> Self {
Self {
cos_a: 1.0,
sin_a: 0.0,
x0: 0.0,
y0: 0.0,
dx: 1.0,
dy: 0.0,
perp_step: 1.0,
along_step: 1.0,
line_width: 0.0,
period: 0.0,
n_dashes: 0,
dash_offset: 0,
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Project every boundary vertex onto the gradient direction
/// `(cos θ, sin θ)` and return the (min, max) projection. Used to set
/// up the gradient's normalized parameter range. Same math as the
/// per-hatch path; duplicated here to keep this module self-contained.
fn boundary_projection_range(boundary: &[[f32; 2]], theta: f32) -> (f32, f32) {
let (cs, sn) = (theta.cos(), theta.sin());
let mut lo = f32::INFINITY;
let mut hi = f32::NEG_INFINITY;
for &[x, y] in boundary {
if !x.is_finite() || !y.is_finite() {
continue;
}
let p = x * cs + y * sn;
if p < lo { lo = p; }
if p > hi { hi = p; }
}
if !lo.is_finite() {
return (0.0, 1.0);
}
(lo, hi)
}
/// Radius of a radial gradient — the distance from the boundary centre (the
/// local origin, since vertices are stored relative to it) to the farthest
/// vertex, so `t = 1` (the end colour) reaches the corners.
fn radial_radius(boundary: &[[f32; 2]]) -> f32 {
boundary
.iter()
.filter(|p| p[0].is_finite() && p[1].is_finite())
.map(|p| (p[0] * p[0] + p[1] * p[1]).sqrt())
.fold(0.0_f32, f32::max)
.max(1.0)
}
// PatFamily is re-exported by hatch_model so we don't need to import
// it explicitly anywhere else — but rust needs the type referenced to
// confirm the layout assumption above.
#[allow(dead_code)]
fn _assert_patfamily_fields(f: &PatFamily) -> (f32, f32, f32) {
(f.angle_deg, f.x0, f.y0)
}