fix(hatch): clip fill with kernel mesh

Resolve boundary topology before rendering so corner-aligned pattern rows cannot flip shader parity. Chunk indexed geometry to device buffer limits and retain the texture compatibility transport.

Refs #489
This commit is contained in:
Hakan Seven 2026-08-13 00:28:50 +03:00
commit 5b9fffdfba
9 changed files with 259 additions and 416 deletions

2
Cargo.lock generated
View file

@ -891,7 +891,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cadkernel"
version = "0.1.0"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=1b84d6c#1b84d6c926d8053690a9975ccad6814800790008"
source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8d05593#8d05593221cf6158076c38e98fef54ab767b81d9"
dependencies = [
"acadrust",
"cavalier_contours",

View file

@ -32,7 +32,7 @@ rfd = "0.17"
clap = { version = "4", features = ["derive"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4736846", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "1b84d6c", features = ["acis", "offset"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8d05593", features = ["acis", "offset"] }
acadifc = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "47b5603", optional = true }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"

View file

@ -1,8 +1,5 @@
// HatchModel — CPU-side hatch fill data; rendered entirely on the GPU.
//
// The boundary is a closed polygon in world XY coordinates.
// The GPU fragment shader performs point-in-polygon and hatch-line tests so
// no line geometry is ever tessellated on the CPU.
// CPU-side hatch fill data. The kernel triangulates the boundary and the GPU
// evaluates the pattern inside that mesh.
use std::sync::Arc;
@ -207,6 +204,40 @@ pub struct HatchModel {
}
impl HatchModel {
/// Indexed local-space fill mesh with even-odd loop containment.
pub(crate) fn fill_mesh(&self) -> (Vec<[f32; 2]>, Vec<u32>) {
let mut rings = Vec::new();
let mut ring = Vec::new();
for &[x, y] in self.boundary.iter() {
if x.is_finite() && y.is_finite() {
ring.push([x as f64, y as f64]);
} else if ring.len() >= 3 {
rings.push(std::mem::take(&mut ring));
} else {
ring.clear();
}
}
if ring.len() >= 3 {
rings.push(ring);
}
let (points, triangles) = cadkernel::geom2d::triangulate_rings(&rings);
let vertices = points
.into_iter()
.map(|[x, y]| [x as f32, y as f32])
.collect();
let mut indices = Vec::with_capacity(triangles.len() * 3);
for triangle in triangles {
for index in triangle {
let Ok(index) = u32::try_from(index) else {
return (Vec::new(), Vec::new());
};
indices.push(index);
}
}
(vertices, indices)
}
/// CPU-side rasteriser for `HatchPattern::Pattern` — produces the line
/// segments inside the boundary so non-GPU consumers (PDF export,
/// `paper_canvas`, print preview) can draw the actual pattern instead

View file

@ -16,9 +16,8 @@ impl DeviceCapabilities {
/// Indexed wires read one per-wire constants storage buffer.
const WIRE_STORAGE_BINDINGS: u32 = 1;
/// Batched hatch uses five storage bindings in one shader stage:
/// instances, boundaries, families, dashes, and visibility.
const HATCH_STORAGE_BINDINGS: u32 = 5;
/// Batched hatch stores instances, families, dashes, and visibility.
const HATCH_STORAGE_BINDINGS: u32 = 4;
pub fn detect(device: &wgpu::Device) -> Self {
Self::from_limits(&device.limits())

View file

@ -34,8 +34,8 @@ impl HatchBackendKind {
enum HatchBackend {
Storage {
resident: Option<StorageHatchBatch>,
preview: Option<StorageHatchBatch>,
resident: Vec<StorageHatchBatch>,
preview: Vec<StorageHatchBatch>,
},
Texture {
resident: Vec<TextureHatch>,
@ -137,8 +137,8 @@ impl HatchGpu {
});
let backend = match backend_kind {
HatchBackendKind::Storage => HatchBackend::Storage {
resident: None,
preview: None,
resident: Vec::new(),
preview: Vec::new(),
},
HatchBackendKind::Texture => HatchBackend::Texture {
resident: Vec::new(),
@ -223,28 +223,29 @@ impl HatchGpu {
queue: &wgpu::Queue,
mut is_visible: impl FnMut([f32; 4]) -> bool,
) -> usize {
let HatchBackend::Storage {
resident: Some(batch),
..
} = &mut self.backend
let HatchBackend::Storage { resident, .. } = &mut self.backend
else {
return 0;
};
for index in 0..batch.unique_source_count {
batch.source_visibility[index] = u32::from(is_visible(batch.source_aabbs[index]));
let mut updated = 0;
for batch in resident {
for index in 0..batch.unique_source_count {
batch.source_visibility[index] = u32::from(is_visible(batch.source_aabbs[index]));
}
for index in batch.unique_source_count..batch.source_visibility.len() {
batch.source_visibility[index] = 1;
}
for (index, aabb) in batch.instance_aabbs.iter().copied().enumerate() {
batch.placements[index].visible = if index == 0 && batch.unique_source_count > 0 {
1
} else {
u32::from(is_visible(aabb))
};
}
batch.upload_visibility(queue);
updated += batch.instance_aabbs.len() + batch.unique_source_count;
}
for index in batch.unique_source_count..batch.source_visibility.len() {
batch.source_visibility[index] = 1;
}
for (index, aabb) in batch.instance_aabbs.iter().copied().enumerate() {
batch.placements[index].visible = if index == 0 && batch.unique_source_count > 0 {
1
} else {
u32::from(is_visible(aabb))
};
}
batch.upload_visibility(queue);
batch.instance_aabbs.len() + batch.unique_source_count
updated
}
pub fn draw<'pass>(
@ -258,12 +259,13 @@ impl HatchGpu {
pass.set_stencil_reference(stencil_reference);
match &self.backend {
HatchBackend::Storage { resident, preview } => {
for batch in [resident.as_ref(), preview.as_ref()].into_iter().flatten() {
for batch in resident.iter().chain(preview) {
pass.set_bind_group(1, &batch.bind_group, &[]);
pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
pass.set_vertex_buffer(1, batch.placement_buffer.slice(..));
for (vertices, instances) in &batch.draws {
pass.draw(vertices.clone(), instances.clone());
pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
for (indices, instances) in &batch.draws {
pass.draw_indexed(indices.clone(), 0, instances.clone());
}
}
}
@ -272,7 +274,11 @@ impl HatchGpu {
pass.set_bind_group(1, &hatch.bind_group, &[]);
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
pass.set_vertex_buffer(1, hatch.placement_buffer.slice(..));
pass.draw(0..6, 0..hatch.instance_count);
pass.set_index_buffer(
hatch.index_buffer.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.draw_indexed(0..hatch.index_count, 0, 0..hatch.instance_count);
}
}
}
@ -294,11 +300,11 @@ mod tests {
#[test]
fn selects_backend_from_device_limits() {
assert_eq!(
HatchBackendKind::select(capabilities(5), false),
HatchBackendKind::select(capabilities(4), false),
HatchBackendKind::Storage
);
assert_eq!(
HatchBackendKind::select(capabilities(4), false),
HatchBackendKind::select(capabilities(3), false),
HatchBackendKind::Texture
);
}

View file

@ -1,48 +1,5 @@
// 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 dozenshundreds 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.
// Batched hatch rendering. The kernel triangulates each boundary once; the
// fragment shader only evaluates the fill pattern inside that mesh.
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
use iced::wgpu;
@ -70,23 +27,16 @@ pub struct HatchInstance {
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
pub family_offset: u32, // 96
pub family_count: u32, // 100
/// 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
pub draw_depth: f32, // 104
/// Gradient shape (`GradientKind::shader_kind`), bit 4 = inverted stops.
pub grad_kind: u32, // 120
pub _pad2: u32, // 124
pub grad_kind: u32, // 108
}
const _: () = assert!(std::mem::size_of::<HatchInstance>() == 128);
const _: () = assert!(std::mem::size_of::<HatchInstance>() == 112);
/// 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.
@ -120,10 +70,7 @@ pub struct LineFamilyGpu {
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`).
/// Kernel mesh vertex in local space with its source instance.
#[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct HatchVertex {
@ -184,6 +131,7 @@ impl HatchPlacement {
/// hatch render pass entirely).
pub(super) struct StorageHatchBatch {
pub vertex_buffer: wgpu::Buffer,
pub index_buffer: wgpu::Buffer,
pub placement_buffer: wgpu::Buffer,
pub draws: Vec<(std::ops::Range<u32>, std::ops::Range<u32>)>,
// The four storage buffers below are referenced via `bind_group` —
@ -191,7 +139,6 @@ pub(super) struct StorageHatchBatch {
// 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
@ -205,8 +152,7 @@ pub(super) struct StorageHatchBatch {
#[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.
/// per pan tick. Far cheaper than touching the instance data.
pub placements: Vec<HatchPlacement>,
pub source_visibility: Vec<u32>,
pub source_aabbs: Vec<[f32; 4]>,
@ -218,24 +164,76 @@ pub(super) struct StorageHatchBatch {
pub instance_aabbs: Vec<[f32; 4]>,
}
fn hatch_buffer_cost(hatch: &HatchModel) -> [u64; 7] {
let boundary = hatch.boundary.len() as u64;
let (families, dashes) = match &hatch.pattern {
HatchPattern::Pattern(families) => (
families.len() as u64,
families.iter().map(|family| family.dashes.len() as u64).sum(),
),
_ => (0, 0),
};
[
boundary.saturating_mul(std::mem::size_of::<HatchVertex>() as u64),
boundary.saturating_mul(24),
std::mem::size_of::<HatchInstance>() as u64,
families.saturating_mul(std::mem::size_of::<LineFamilyGpu>() as u64),
dashes.saturating_mul(std::mem::size_of::<f32>() as u64),
std::mem::size_of::<u32>() as u64,
std::mem::size_of::<HatchPlacement>() as u64,
]
}
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`].
/// Builds whole-hatch chunks within the device's buffer limits.
pub(super) fn build(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel],
) -> Option<Self> {
) -> Vec<Self> {
if hatches.is_empty() {
return None;
return Vec::new();
}
let limits = device.limits();
let chunk_limit = limits
.max_buffer_size
.min(limits.max_storage_buffer_binding_size as u64);
let mut ranges = Vec::new();
let mut start = 0;
let mut used = [0u64; 7];
for (index, hatch) in hatches.iter().enumerate() {
let cost = hatch_buffer_cost(hatch);
let overflow = used
.iter()
.zip(cost)
.any(|(used, cost)| used.saturating_add(cost) > chunk_limit);
if overflow && index > start {
ranges.push(start..index);
start = index;
used = [0; 7];
}
for (used, cost) in used.iter_mut().zip(cost) {
*used = used.saturating_add(cost);
}
}
ranges.push(start..hatches.len());
ranges
.into_iter()
.filter_map(|range| Self::build_chunk(device, bgl, &hatches[range]))
.collect()
}
fn build_chunk(
device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel],
) -> Option<Self> {
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();
let mut meshes = Vec::with_capacity(hatches.len());
let mut slots = rustc_hash::FxHashMap::default();
let mut groups: Vec<Vec<&HatchModel>> = Vec::new();
for (index, hatch) in hatches.iter().enumerate() {
@ -255,22 +253,9 @@ impl StorageHatchBatch {
for group in &groups {
let h = group[0];
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 mesh = h.fill_mesh();
let has_mesh = !mesh.0.is_empty() && !mesh.1.is_empty();
meshes.push(mesh);
let family_offset = families.len() as u32;
let mut family_count = 0u32;
@ -301,11 +286,8 @@ impl StorageHatchBatch {
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.
// PAT local frame: perpendicular spacing and
// along-line phase.
let perp_step = fam.dy;
let along_step = fam.dx;
// Screen-space derivative drives 1-px line width
@ -366,39 +348,19 @@ impl StorageHatchBatch {
grad_range,
mode,
visible: 0,
boundary_offset,
boundary_count,
family_offset,
family_count,
draw_depth: if group.len() == 1 { h.draw_depth } else { 0.0 },
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],
aabb: [min_x, min_y, max_x, max_y],
world_origin: wo_hi,
world_origin_low: wo_lo,
angle_offset: h.angle_offset,
@ -408,26 +370,14 @@ impl StorageHatchBatch {
grad_min,
grad_range,
mode,
visible: 1,
boundary_offset,
boundary_count,
visible: u32::from(has_mesh),
family_offset,
family_count,
draw_depth: if group.len() == 1 { h.draw_depth } else { 0.0 },
// 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());
}
@ -435,21 +385,33 @@ impl StorageHatchBatch {
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 mut verts = Vec::new();
let mut indices = Vec::new();
let mut mesh_ranges = Vec::with_capacity(meshes.len());
for (instance_index, (points, mesh_indices)) in meshes.into_iter().enumerate() {
let Ok(base) = u32::try_from(verts.len()) else {
return None;
};
let Ok(start) = u32::try_from(indices.len()) else {
return None;
};
verts.extend(points.into_iter().map(|local_xy| HatchVertex {
local_xy,
instance_index: instance_index as u32,
}));
for index in mesh_indices {
let Some(index) = base.checked_add(index) else {
return None;
};
indices.push(index);
}
let Ok(end) = u32::try_from(indices.len()) else {
return None;
};
mesh_ranges.push(start..end);
}
if indices.is_empty() {
return None;
}
let mut placements = Vec::new();
let mut draws = Vec::with_capacity(groups.len());
@ -461,7 +423,10 @@ impl StorageHatchBatch {
draw_depth: 0.0,
visible: 1,
});
draws.push((0..unique_source_count as u32 * 6, 0..1));
let end = mesh_ranges[unique_source_count - 1].end;
if end > 0 {
draws.push((0..end, 0..1));
}
let mut union = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY];
for inst in instances.iter().take(unique_source_count) {
union[0] = union[0].min(inst.aabb[0] + inst.world_origin[0]);
@ -500,10 +465,32 @@ impl StorageHatchBatch {
inst.aabb[3] + inst.world_origin[1] + high[1],
]);
}
draws.push((
source_index as u32 * 6..source_index as u32 * 6 + 6,
placement_start..placements.len() as u32,
));
if !mesh_ranges[source_index].is_empty() {
draws.push((
mesh_ranges[source_index].clone(),
placement_start..placements.len() as u32,
));
}
}
let visibility: Vec<u32> = instances.iter().map(|instance| instance.visible).collect();
let limits = device.limits();
let buffer_fits = |count: usize, stride: usize| {
(count as u64).saturating_mul(stride as u64) <= limits.max_buffer_size
};
let storage_fits = |count: usize, stride: usize| {
(count as u64).saturating_mul(stride as u64)
<= limits.max_storage_buffer_binding_size as u64
};
if !buffer_fits(verts.len(), std::mem::size_of::<HatchVertex>())
|| !buffer_fits(indices.len(), std::mem::size_of::<u32>())
|| !buffer_fits(placements.len(), std::mem::size_of::<HatchPlacement>())
|| !storage_fits(instances.len(), std::mem::size_of::<HatchInstance>())
|| !storage_fits(families.len(), std::mem::size_of::<LineFamilyGpu>())
|| !storage_fits(dashes.len(), std::mem::size_of::<f32>())
|| !storage_fits(visibility.len(), std::mem::size_of::<u32>())
{
return None;
}
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@ -511,6 +498,11 @@ impl StorageHatchBatch {
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.index"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let placement_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.placements"),
contents: bytemuck::cast_slice(&placements),
@ -521,11 +513,6 @@ impl StorageHatchBatch {
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),
@ -537,7 +524,6 @@ impl StorageHatchBatch {
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),
@ -554,18 +540,14 @@ impl StorageHatchBatch {
},
wgpu::BindGroupEntry {
binding: 1,
resource: boundary_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: family_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
binding: 2,
resource: dash_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
binding: 3,
resource: visibility_buffer.as_entire_binding(),
},
],
@ -582,10 +564,10 @@ impl StorageHatchBatch {
Some(Self {
vertex_buffer,
index_buffer,
placement_buffer,
draws,
instance_buffer,
boundary_buffer,
family_buffer,
dash_buffer,
visibility_buffer,
@ -614,10 +596,7 @@ impl StorageHatchBatch {
);
}
/// 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).
/// Shared layout for instance, family, dash, and visibility storage.
#[allow(dead_code)]
pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
@ -632,7 +611,7 @@ impl StorageHatchBatch {
};
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hatch.bgl"),
entries: &[entry(0), entry(1), entry(2), entry(3), entry(4)],
entries: &[entry(0), entry(1), entry(2), entry(3)],
})
}
}

View file

@ -1,14 +1,5 @@
// Storage-free hatch renderer — texture-backed, UNCAPPED.
//
// Devices without storage buffers use this per-hatch renderer. It reuses the
// storage-free hatch algorithm (see wipeout.wgsl / hatch_texture.wgsl) but packs the
// variable-length boundary / family / dash arrays into ONE RGBA32F data texture
// read via textureLoad — removing the MAX_FAMILIES / MAX_HATCH_BOUNDARY_VERTS /
// MAX_DASHES caps of the uniform (WipeoutGpu) path. Every hatch type — solid,
// gradient, and arbitrarily complex line patterns — renders in compat mode.
//
// Storage-capable devices use the sibling storage backend; wipeout masks use
// wipeout_gpu.rs.
// Texture-backed hatch renderer for devices without storage buffers. Boundary
// geometry comes from the same kernel mesh as the storage backend.
use crate::scene::model::hatch_model::{HatchModel, HatchPattern};
use iced::wgpu;
@ -71,7 +62,7 @@ struct TextureHatchUniform {
color: [f32; 4], // 0
color2: [f32; 4], // 16
mode: u32, // 32
vcount: u32, // 36
_reserved: u32, // 36
angle_offset: f32, // 40
scale: f32, // 44
grad_cos: f32, // 48
@ -90,7 +81,9 @@ struct TextureHatchUniform {
pub(super) struct TextureHatch {
pub(super) vertex_buffer: wgpu::Buffer,
pub(super) index_buffer: wgpu::Buffer,
pub(super) placement_buffer: wgpu::Buffer,
pub(super) index_count: u32,
pub(super) instance_count: u32,
pub(super) bind_group: wgpu::BindGroup,
/// Reserved for per-frame AABB LOD (mirrors `WipeoutGpu`); not yet wired
@ -125,7 +118,7 @@ impl TextureHatch {
}
groups
.into_iter()
.map(|group| Self::new(device, queue, &group, bgl1))
.filter_map(|group| Self::new(device, queue, &group, bgl1))
.collect()
}
@ -164,7 +157,7 @@ impl TextureHatch {
queue: &wgpu::Queue,
models: &[&HatchModel],
bgl1: &wgpu::BindGroupLayout,
) -> Self {
) -> Option<Self> {
let model = models[0];
// ── Decode pattern mode (mirrors WipeoutGpu::new) ─────────────────
// The gradient shape (kind + invert bit) rides in the mode's high
@ -197,41 +190,26 @@ impl TextureHatch {
max_y = max_y.max(y);
}
let max_spacing = match &model.pattern {
HatchPattern::Pattern(families) => {
families.iter().map(|f| f.dy.abs()).fold(0.0f32, f32::max)
}
_ => 5.0,
};
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
let pad = (diag * 0.8 + max_spacing * 2.0 * model.scale).max(1.0);
// Anchor pattern phase at `world_origin` with the boundary stored raw,
// matching the storage backend — NOT WipeoutGpu,
// whose f64 origin grid-snap is dead code (wipeouts are always solid)
// and would phase-shift every line pattern relative to desktop. No drift.
let origin = model.world_origin;
let drift = [0.0f32, 0.0f32];
let (x0, x1, y0, y1) = (
min_x + drift[0] - pad,
max_x + drift[0] + pad,
min_y + drift[1] - pad,
max_y + drift[1] + pad,
);
let quad = [
HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 },
HatchVertex { pos: [x1, y0, 0.0], _pad: 0.0 },
HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 },
HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 },
HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 },
HatchVertex { pos: [x0, y1, 0.0], _pad: 0.0 },
];
let (mesh_points, indices) = model.fill_mesh();
if mesh_points.is_empty() || indices.is_empty() {
return None;
}
let vertices: Vec<HatchVertex> = mesh_points
.into_iter()
.map(|[x, y]| HatchVertex { pos: [x, y, 0.0], _pad: 0.0 })
.collect();
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.vbuf"),
contents: bytemuck::cast_slice(&quad),
contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX,
});
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.ibuf"),
contents: bytemuck::cast_slice(&indices),
usage: wgpu::BufferUsages::INDEX,
});
let base = model
.render_instance
.map_or([0.0; 3], |instance| instance.translation);
@ -290,21 +268,8 @@ impl TextureHatch {
(0.0, 1.0)
};
// ── Pack the data texture: boundary | families | dashes ───────────
// ── Pack the data texture: families | dashes ─────────────────────
let mut texels: Vec<[f32; 4]> = Vec::new();
// Boundary section (texels 0..vcount). NaN separators become the
// finite GPU sentinel — the shader NaN self-compare is folded to
// `true` on some drivers (#386, #416).
for &[x, y] in model.boundary.iter() {
if x.is_finite() && y.is_finite() {
texels.push([x + drift[0], y + drift[1], 0.0, 0.0]);
} else {
let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP;
texels.push([s, s, 0.0, 0.0]);
}
}
let vcount = texels.len() as u32;
// Family section (3 texels each) + a flat dash pool.
let fam_off = texels.len() as u32;
let mut n_families = 0u32;
@ -320,7 +285,7 @@ impl TextureHatch {
0.0
};
let angle_r = fam.angle_deg.to_radians();
// QCAD PAT convention: perp_step = dy, along_step = dx.
// PAT local frame: perpendicular spacing and along-line phase.
texels.push([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]);
texels.push([fam.dx, fam.dy, fam.dy, fam.dx]);
// Counts as exact f32 (small integers → no denormal/bitcast risk).
@ -385,7 +350,7 @@ impl TextureHatch {
color: model.color,
color2,
mode,
vcount,
_reserved: 0,
angle_offset: model.angle_offset,
// Clamp like the desktop renderer so scale==0 can't make perp_step 0
// → round(perp/0)=NaN → an invisible hatch.
@ -433,14 +398,16 @@ impl TextureHatch {
[min_x, min_y, max_x, max_y]
};
Self {
Some(Self {
vertex_buffer,
index_buffer,
placement_buffer,
index_count: indices.len() as u32,
instance_count: placements.len() as u32,
bind_group,
world_aabb,
_uniform_buf,
_data_tex: data_tex,
}
})
}
}

View file

@ -1,23 +1,10 @@
// Phase 4-B batched hatch shader. All hatches in one draw call;
// per-instance data fetched from storage buffers indexed by the
// `instance_index` vertex attribute (passed from the per-vertex
// (local_xy, instance_index) stream of tessellated boundary-triangle
// positions so we don't depend on @builtin(instance_index) edge cases
// across backends).
// Batched hatch shader. Kernel mesh vertices carry their source instance.
//
// Layout matches `hatch_gpu/storage.rs`:
// group 1 binding 0 InstanceBuffer HatchInstance[] (128 B / inst)
// group 1 binding 1 BoundaryBuffer vec4<f32>[] (xy in .xy)
// group 1 binding 2 FamilyBuffer LineFamilyGpu[] (48 B / fam)
// group 1 binding 3 DashBuffer f32[]
//
// The vertex shader emits tessellated boundary triangles in local space
// (an instance whose boundary failed to tessellate falls back to an
// AABB quad with `poly_test == 1`, so the fragment shader still runs
// `in_polygon` to clip it to the real shape). A `visible == 0` instance
// gets an out-of-NDC clip position so the fragment shader never runs
// for it that's the GPU-side cull (Phase 4-B equivalent of
// `compute_hatch_lod` writing `hatch_skip_flags`).
// group 1 binding 0 InstanceBuffer HatchInstance[]
// group 1 binding 1 FamilyBuffer LineFamilyGpu[]
// group 1 binding 2 DashBuffer f32[]
// group 1 binding 3 Visibility u32[]
// Group 0: shared frame uniforms (matches hatch.wgsl)
@ -53,14 +40,10 @@ struct HatchInstance {
grad_range: f32,
mode: u32, // 0=pattern, 1=solid, 2=gradient
visible: u32, // 0 = skip (CPU writes via compute_hatch_lod)
boundary_offset: u32,
boundary_count: u32,
family_offset: u32,
family_count: u32,
draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral
poly_test: u32, // 1 = run in_polygon (fallback), 0 = skip
grad_kind: u32, // shape (0=linear,1=cyl,2=sph,3=hemi,4=curved), bit4=invert
_pad2: u32,
}
// Draw-order depth bias (see wire.wgsl). Higher draw_depth smaller z
@ -83,14 +66,13 @@ struct LineFamily {
}
@group(1) @binding(0) var<storage, read> instances: array<HatchInstance>;
@group(1) @binding(1) var<storage, read> boundary: array<vec4<f32>>;
@group(1) @binding(2) var<storage, read> families: array<LineFamily>;
@group(1) @binding(3) var<storage, read> dashes: array<f32>;
@group(1) @binding(1) var<storage, read> families: array<LineFamily>;
@group(1) @binding(2) var<storage, read> dashes: array<f32>;
// Per-instance visibility (Phase 4-B sub-pixel + frustum skip).
// CPU writes `1` to draw / `0` to skip every frame; vertex shader
// emits an out-of-NDC clip position for 0-instances so the GPU
// rasterizer culls the primitive before any fragment runs.
@group(1) @binding(4) var<storage, read> visibility: array<u32>;
@group(1) @binding(3) var<storage, read> visibility: array<u32>;
// Vertex shader
@ -144,58 +126,6 @@ struct VOut {
return o;
}
// Point-in-polygon (ray casting) over a sub-range of BoundaryBuffer
fn valid_vertex(p: vec2<f32>) -> bool {
// Sub-loop separators are a huge finite sentinel (1e30, see
// GPU_BOUNDARY_SEP), not NaN: `x == x` NaN detection gets folded to
// `true` by some drivers (Intel Mesa fast math), which made separators
// read as real vertices and bleed fills past their boundary (#386, #416).
return abs(p.x) < 1.0e29 && abs(p.y) < 1.0e29;
}
fn edge_crosses(p: vec2<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
if (a.y > p.y) != (c.y > p.y) {
let x_int = (c.x - a.x) * (p.y - a.y) / (c.y - a.y) + a.x;
return p.x < x_int;
}
return false;
}
fn in_polygon(p: vec2<f32>, offset: u32, count: u32) -> bool {
var inside = false;
var prev = vec2<f32>(0.0, 0.0);
var first = vec2<f32>(0.0, 0.0);
var have_prev = false;
for (var i = 0u; i < count; i++) {
let vi = boundary[offset + i].xy;
if !valid_vertex(vi) {
// Close the sub-loop that just ended (last first edge). An
// unclosed boundary e.g. a SOLID's 4 corners, which are not
// repeated otherwise miscounts crossings and the fill bleeds
// outside the shape. (#140)
if have_prev && edge_crosses(p, prev, first) {
inside = !inside;
}
have_prev = false;
continue;
}
if have_prev {
if edge_crosses(p, prev, vi) {
inside = !inside;
}
} else {
first = vi;
}
prev = vi;
have_prev = true;
}
if have_prev && edge_crosses(p, prev, first) {
inside = !inside;
}
return inside;
}
// Per-family hatch test (same math as hatch.wgsl, dashes from
// global DashBuffer instead of per-hatch FamilyBatch)
@ -282,15 +212,7 @@ fn check_family(
let inst = instances[v.instance_index];
// 1. Boundary test only on the fallback path (poly_test==1). On the
// tessellated fast path the triangles already bound the fill.
if inst.poly_test == 1u {
if !in_polygon(v.xz, inst.boundary_offset, inst.boundary_count) {
discard;
}
}
// 2. Mode dispatch.
// Mode dispatch.
if inst.mode == 1u {
return inst.color;
} else if inst.mode == 2u {
@ -319,12 +241,11 @@ fn check_family(
t = 1.0 - t;
}
// Radial stops run OUTSIDE-IN: colour 1 at the rim, colour 2 at the
// centre (AutoCAD's SPHERICAL/HEMISPHERICAL convention; the INV bit
// above swaps them back).
// centre; the INV bit above swaps them back.
return mix(inst.color2, inst.color, t);
}
// 3. Pattern LOD. Keep every family visible until all family spacings
// Pattern LOD. Keep every family visible until all family spacings
// project below 2 px, then substitute one solid fill. A single dense
// family must neither hide itself nor turn a complex hatch solid.
var all_families_subpixel = inst.family_count > 0u && u.world_per_pixel > 0.0;
@ -343,7 +264,7 @@ fn check_family(
return inst.color;
}
// 4. Pattern evaluation.
// Pattern evaluation.
let cos_off = cos(inst.angle_offset);
let sin_off = sin(inst.angle_offset);
for (var i = 0u; i < inst.family_count; i++) {

View file

@ -1,15 +1,7 @@
// Hatch texture shader storage-free, UNCAPPED variant of wipeout.wgsl.
//
// Devices without vertex/fragment storage buffers cannot use the batched
// storage-buffer renderer (hatch.wgsl). This shader keeps the exact
// wipeout.wgsl hatch algorithm (in_polygon + per-family line/dash/dot + solid +
// gradient) but reads the variable-length boundary / family / dash arrays from a
// single RGBA32F data texture via textureLoad instead of fixed-size uniforms
// removing the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS (1024) / MAX_DASHES
// (128) caps of the uniform path.
// Texture-backed hatch shader. The kernel mesh bounds the fill; this shader
// evaluates only the pattern, solid, or gradient colour.
//
// Data texture layout (row-major, width = h.tex_width, one vec4 per texel):
// [ 0 .. vcount ) boundary verts, .xy = local XY, NaN = break
// [ fam_off .. fam_off+3*n_fam ) families, 3 texels each (see load_family)
// [ dash_off .. ) dash values, 4 per texel (RGBA)
@ -36,7 +28,7 @@ struct HatchUniforms {
color: vec4<f32>, // 0
color2: vec4<f32>, // 16
mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
vcount: u32, // 36: boundary vertex count
_reserved: u32, // 36
angle_offset: f32, // 40
scale: f32, // 44
grad_cos: f32, // 48
@ -119,56 +111,6 @@ struct VOut {
return o;
}
// Point-in-polygon (ray casting)
fn valid_vertex(p: vec2<f32>) -> bool {
// Sub-loop separators are a huge finite sentinel (1e30, see
// GPU_BOUNDARY_SEP), not NaN: `x == x` NaN detection gets folded to
// `true` by some drivers (Intel Mesa fast math), which made separators
// read as real vertices and bleed fills past their boundary (#386, #416).
return abs(p.x) < 1.0e29 && abs(p.y) < 1.0e29;
}
fn edge_crosses(p: vec2<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
if (a.y > p.y) != (c.y > p.y) {
let x_int = (c.x - a.x) * (p.y - a.y) / (c.y - a.y) + a.x;
return p.x < x_int;
}
return false;
}
fn in_polygon(p: vec2<f32>) -> bool {
var inside = false;
let n = h.vcount;
var prev = vec2<f32>(0.0, 0.0);
var first = vec2<f32>(0.0, 0.0);
var have_prev = false;
for (var i = 0u; i < n; i++) {
let vi = texel(i).xy;
if !valid_vertex(vi) {
// NaN sentinel closes the current sub-loop (last first edge). (#140)
if have_prev && edge_crosses(p, prev, first) {
inside = !inside;
}
have_prev = false;
continue;
}
if have_prev {
if edge_crosses(p, prev, vi) {
inside = !inside;
}
} else {
first = vi;
}
prev = vi;
have_prev = true;
}
if have_prev && edge_crosses(p, prev, first) {
inside = !inside;
}
return inside;
}
// Per-family hatch test (identical math to wipeout.wgsl)
// `ddx_xz`/`ddy_xz` are screen-space derivatives of `xz`, taken once in
@ -244,8 +186,6 @@ fn check_family(
let ddx_xz = dpdx(v.xz);
let ddy_xz = dpdy(v.xz);
if !in_polygon(v.xz) { discard; }
let base_mode = h.mode & 0xFFu;
let gk = (h.mode >> 8u) & 15u;
let ginv = ((h.mode >> 8u) & 16u) != 0u;