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]] [[package]]
name = "cadkernel" name = "cadkernel"
version = "0.1.0" 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 = [ dependencies = [
"acadrust", "acadrust",
"cavalier_contours", "cavalier_contours",

View file

@ -32,7 +32,7 @@ rfd = "0.17"
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
env_logger = "0.11" env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4736846", features = ["serde"] } 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 } acadifc = { git = "https://github.com/OpenAEC-Foundation/acadifc.git", rev = "47b5603", optional = true }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1" flate2 = "1"

View file

@ -1,8 +1,5 @@
// HatchModel — CPU-side hatch fill data; rendered entirely on the GPU. // CPU-side hatch fill data. The kernel triangulates the boundary and the GPU
// // evaluates the pattern inside that mesh.
// 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.
use std::sync::Arc; use std::sync::Arc;
@ -207,6 +204,40 @@ pub struct HatchModel {
} }
impl 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 /// CPU-side rasteriser for `HatchPattern::Pattern` — produces the line
/// segments inside the boundary so non-GPU consumers (PDF export, /// segments inside the boundary so non-GPU consumers (PDF export,
/// `paper_canvas`, print preview) can draw the actual pattern instead /// `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. /// Indexed wires read one per-wire constants storage buffer.
const WIRE_STORAGE_BINDINGS: u32 = 1; const WIRE_STORAGE_BINDINGS: u32 = 1;
/// Batched hatch uses five storage bindings in one shader stage: /// Batched hatch stores instances, families, dashes, and visibility.
/// instances, boundaries, families, dashes, and visibility. const HATCH_STORAGE_BINDINGS: u32 = 4;
const HATCH_STORAGE_BINDINGS: u32 = 5;
pub fn detect(device: &wgpu::Device) -> Self { pub fn detect(device: &wgpu::Device) -> Self {
Self::from_limits(&device.limits()) Self::from_limits(&device.limits())

View file

@ -34,8 +34,8 @@ impl HatchBackendKind {
enum HatchBackend { enum HatchBackend {
Storage { Storage {
resident: Option<StorageHatchBatch>, resident: Vec<StorageHatchBatch>,
preview: Option<StorageHatchBatch>, preview: Vec<StorageHatchBatch>,
}, },
Texture { Texture {
resident: Vec<TextureHatch>, resident: Vec<TextureHatch>,
@ -137,8 +137,8 @@ impl HatchGpu {
}); });
let backend = match backend_kind { let backend = match backend_kind {
HatchBackendKind::Storage => HatchBackend::Storage { HatchBackendKind::Storage => HatchBackend::Storage {
resident: None, resident: Vec::new(),
preview: None, preview: Vec::new(),
}, },
HatchBackendKind::Texture => HatchBackend::Texture { HatchBackendKind::Texture => HatchBackend::Texture {
resident: Vec::new(), resident: Vec::new(),
@ -223,28 +223,29 @@ impl HatchGpu {
queue: &wgpu::Queue, queue: &wgpu::Queue,
mut is_visible: impl FnMut([f32; 4]) -> bool, mut is_visible: impl FnMut([f32; 4]) -> bool,
) -> usize { ) -> usize {
let HatchBackend::Storage { let HatchBackend::Storage { resident, .. } = &mut self.backend
resident: Some(batch),
..
} = &mut self.backend
else { else {
return 0; return 0;
}; };
for index in 0..batch.unique_source_count { let mut updated = 0;
batch.source_visibility[index] = u32::from(is_visible(batch.source_aabbs[index])); 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() { updated
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
} }
pub fn draw<'pass>( pub fn draw<'pass>(
@ -258,12 +259,13 @@ impl HatchGpu {
pass.set_stencil_reference(stencil_reference); pass.set_stencil_reference(stencil_reference);
match &self.backend { match &self.backend {
HatchBackend::Storage { resident, preview } => { 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_bind_group(1, &batch.bind_group, &[]);
pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..)); pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
pass.set_vertex_buffer(1, batch.placement_buffer.slice(..)); pass.set_vertex_buffer(1, batch.placement_buffer.slice(..));
for (vertices, instances) in &batch.draws { pass.set_index_buffer(batch.index_buffer.slice(..), wgpu::IndexFormat::Uint32);
pass.draw(vertices.clone(), instances.clone()); 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_bind_group(1, &hatch.bind_group, &[]);
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
pass.set_vertex_buffer(1, hatch.placement_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] #[test]
fn selects_backend_from_device_limits() { fn selects_backend_from_device_limits() {
assert_eq!( assert_eq!(
HatchBackendKind::select(capabilities(5), false), HatchBackendKind::select(capabilities(4), false),
HatchBackendKind::Storage HatchBackendKind::Storage
); );
assert_eq!( assert_eq!(
HatchBackendKind::select(capabilities(4), false), HatchBackendKind::select(capabilities(3), false),
HatchBackendKind::Texture HatchBackendKind::Texture
); );
} }

View file

@ -1,48 +1,5 @@
// Hatch rendering — the single, canonical GPU hatch renderer. One draw // Batched hatch rendering. The kernel triangulates each boundary once; the
// call for all hatches, per-instance data fetched from storage buffers in // fragment shader only evaluates the fill pattern inside that mesh.
// 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 crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
use iced::wgpu; use iced::wgpu;
@ -70,23 +27,16 @@ pub struct HatchInstance {
pub grad_range: f32, // 84 pub grad_range: f32, // 84
pub mode: u32, // 88 (0=pattern, 1=solid, 2=gradient) pub mode: u32, // 88 (0=pattern, 1=solid, 2=gradient)
pub visible: u32, // 92 (CPU sets to 0 to skip) pub visible: u32, // 92 (CPU sets to 0 to skip)
pub boundary_offset: u32, // 96 (first boundary vert index) pub family_offset: u32, // 96
pub boundary_count: u32, // 100 pub family_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 /// 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. /// bias in the vertex shader so this fill orders against other types.
pub draw_depth: f32, // 112 pub draw_depth: f32, // 104
/// 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. /// Gradient shape (`GradientKind::shader_kind`), bit 4 = inverted stops.
pub grad_kind: u32, // 120 pub grad_kind: u32, // 108
pub _pad2: u32, // 124
} }
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 /// 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. /// 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); const _: () = assert!(std::mem::size_of::<LineFamilyGpu>() == 48);
/// Per-vertex data — tessellated boundary triangles in local (world_origin- /// Kernel mesh vertex in local space with its source instance.
/// 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)] #[repr(C)]
#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub(super) struct HatchVertex { pub(super) struct HatchVertex {
@ -184,6 +131,7 @@ impl HatchPlacement {
/// hatch render pass entirely). /// hatch render pass entirely).
pub(super) struct StorageHatchBatch { pub(super) struct StorageHatchBatch {
pub vertex_buffer: wgpu::Buffer, pub vertex_buffer: wgpu::Buffer,
pub index_buffer: wgpu::Buffer,
pub placement_buffer: wgpu::Buffer, pub placement_buffer: wgpu::Buffer,
pub draws: Vec<(std::ops::Range<u32>, std::ops::Range<u32>)>, pub draws: Vec<(std::ops::Range<u32>, std::ops::Range<u32>)>,
// The four storage buffers below are referenced via `bind_group` — // 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 // only direct consumer. Keep them as fields to keep ownership in
// one place; `#[allow(dead_code)]` silences the read-never warning. // one place; `#[allow(dead_code)]` silences the read-never warning.
#[allow(dead_code)] pub instance_buffer: wgpu::Buffer, #[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 family_buffer: wgpu::Buffer,
#[allow(dead_code)] pub dash_buffer: wgpu::Buffer, #[allow(dead_code)] pub dash_buffer: wgpu::Buffer,
/// Per-instance visibility flag (1=draw, 0=skip). Stored in its /// 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, #[allow(dead_code)] pub instance_count: u32,
/// CPU mirror — `update_visibility` re-uploads this whole slice /// CPU mirror — `update_visibility` re-uploads this whole slice
/// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches /// 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 /// per pan tick. Far cheaper than touching the instance data.
/// data.
pub placements: Vec<HatchPlacement>, pub placements: Vec<HatchPlacement>,
pub source_visibility: Vec<u32>, pub source_visibility: Vec<u32>,
pub source_aabbs: Vec<[f32; 4]>, pub source_aabbs: Vec<[f32; 4]>,
@ -218,24 +164,76 @@ pub(super) struct StorageHatchBatch {
pub instance_aabbs: Vec<[f32; 4]>, 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 { impl StorageHatchBatch {
/// One-time build from the full hatch list. Re-uploaded only when /// Builds whole-hatch chunks within the device's buffer limits.
/// `geometry_epoch` advances (mirrors the existing per-hatch
/// upload trigger). Per-frame visibility flips go through
/// [`upload_visibility`].
pub(super) fn build( pub(super) fn build(
device: &wgpu::Device, device: &wgpu::Device,
bgl: &wgpu::BindGroupLayout, bgl: &wgpu::BindGroupLayout,
hatches: &[HatchModel], hatches: &[HatchModel],
) -> Option<Self> { ) -> Vec<Self> {
if hatches.is_empty() { 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 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 families: Vec<LineFamilyGpu> = Vec::new();
let mut dashes: Vec<f32> = 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 slots = rustc_hash::FxHashMap::default();
let mut groups: Vec<Vec<&HatchModel>> = Vec::new(); let mut groups: Vec<Vec<&HatchModel>> = Vec::new();
for (index, hatch) in hatches.iter().enumerate() { for (index, hatch) in hatches.iter().enumerate() {
@ -255,22 +253,9 @@ impl StorageHatchBatch {
for group in &groups { for group in &groups {
let h = group[0]; let h = group[0];
let boundary_offset = boundary.len() as u32; let mesh = h.fill_mesh();
for &[x, y] in h.boundary.iter() { let has_mesh = !mesh.0.is_empty() && !mesh.1.is_empty();
// NaN sub-loop separators become the finite sentinel on the meshes.push(mesh);
// 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 family_offset = families.len() as u32;
let mut family_count = 0u32; let mut family_count = 0u32;
@ -301,11 +286,8 @@ impl StorageHatchBatch {
dashes.push(d); dashes.push(d);
} }
let n_dashes = (dashes.len() as u32 - dash_offset).min(u32::MAX); let n_dashes = (dashes.len() as u32 - dash_offset).min(u32::MAX);
// QCAD PAT local-frame convention (mirrors // PAT local frame: perpendicular spacing and
// the storage batch convention: `dy` is // along-line phase.
// 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 perp_step = fam.dy;
let along_step = fam.dx; let along_step = fam.dx;
// Screen-space derivative drives 1-px line width // Screen-space derivative drives 1-px line width
@ -366,39 +348,19 @@ impl StorageHatchBatch {
grad_range, grad_range,
mode, mode,
visible: 0, visible: 0,
boundary_offset,
boundary_count,
family_offset, family_offset,
family_count, family_count,
draw_depth: if group.len() == 1 { h.draw_depth } else { 0.0 }, draw_depth: if group.len() == 1 { h.draw_depth } else { 0.0 },
poly_test: 1,
grad_kind, grad_kind,
_pad2: 0,
}); });
continue; 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); let (wo_hi, wo_lo) = split_origin_ds(h.world_origin);
instances.push(HatchInstance { instances.push(HatchInstance {
color: h.color, color: h.color,
color2, 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: wo_hi,
world_origin_low: wo_lo, world_origin_low: wo_lo,
angle_offset: h.angle_offset, angle_offset: h.angle_offset,
@ -408,26 +370,14 @@ impl StorageHatchBatch {
grad_min, grad_min,
grad_range, grad_range,
mode, mode,
visible: 1, visible: u32::from(has_mesh),
boundary_offset,
boundary_count,
family_offset, family_offset,
family_count, family_count,
draw_depth: if group.len() == 1 { h.draw_depth } else { 0.0 }, 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, grad_kind,
_pad2: 0,
}); });
} }
// Empty fallbacks — storage buffers can't be zero-sized. // Empty fallbacks — storage buffers can't be zero-sized.
if boundary.is_empty() {
boundary.push([0.0; 4]);
}
if families.is_empty() { if families.is_empty() {
families.push(LineFamilyGpu::default_filler()); families.push(LineFamilyGpu::default_filler());
} }
@ -435,21 +385,33 @@ impl StorageHatchBatch {
dashes.push(0.0); dashes.push(0.0);
} }
// Vertex buffer — one AABB quad (two triangles, BL,BR,TL, BR,TR,TL) per let mut verts = Vec::new();
// instance; the GPU shader clips each fragment to the boundary via the let mut indices = Vec::new();
// in_polygon test (poly_test == 1). 6 verts/hatch, independent of let mut mesh_ranges = Vec::with_capacity(meshes.len());
// boundary complexity, so the buffer can never overflow the device for (instance_index, (points, mesh_indices)) in meshes.into_iter().enumerate() {
// limit no matter how dense the drawing. let Ok(base) = u32::try_from(verts.len()) else {
let mut verts: Vec<HatchVertex> = Vec::with_capacity(instances.len() * 6); return None;
for (i, inst) in instances.iter().enumerate() { };
let [xmin, ymin, xmax, ymax] = inst.aabb; let Ok(start) = u32::try_from(indices.len()) else {
let quad = [ return None;
[xmin, ymin], [xmax, ymin], [xmin, ymax], };
[xmax, ymin], [xmax, ymax], [xmin, ymax], verts.extend(points.into_iter().map(|local_xy| HatchVertex {
]; local_xy,
for c in quad { instance_index: instance_index as u32,
verts.push(HatchVertex { local_xy: c, instance_index: i 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 placements = Vec::new();
let mut draws = Vec::with_capacity(groups.len()); let mut draws = Vec::with_capacity(groups.len());
@ -461,7 +423,10 @@ impl StorageHatchBatch {
draw_depth: 0.0, draw_depth: 0.0,
visible: 1, 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]; let mut union = [f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY];
for inst in instances.iter().take(unique_source_count) { for inst in instances.iter().take(unique_source_count) {
union[0] = union[0].min(inst.aabb[0] + inst.world_origin[0]); 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], inst.aabb[3] + inst.world_origin[1] + high[1],
]); ]);
} }
draws.push(( if !mesh_ranges[source_index].is_empty() {
source_index as u32 * 6..source_index as u32 * 6 + 6, draws.push((
placement_start..placements.len() as u32, 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 { let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@ -511,6 +498,11 @@ impl StorageHatchBatch {
contents: bytemuck::cast_slice(&verts), contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX, 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 { let placement_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.placements"), label: Some("hatch.placements"),
contents: bytemuck::cast_slice(&placements), contents: bytemuck::cast_slice(&placements),
@ -521,11 +513,6 @@ impl StorageHatchBatch {
contents: bytemuck::cast_slice(&instances), contents: bytemuck::cast_slice(&instances),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, 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 { let family_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.families"), label: Some("hatch.families"),
contents: bytemuck::cast_slice(&families), contents: bytemuck::cast_slice(&families),
@ -537,7 +524,6 @@ impl StorageHatchBatch {
usage: wgpu::BufferUsages::STORAGE, 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 { let visibility_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.visibility"), label: Some("hatch.visibility"),
contents: bytemuck::cast_slice(&visibility), contents: bytemuck::cast_slice(&visibility),
@ -554,18 +540,14 @@ impl StorageHatchBatch {
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 1, binding: 1,
resource: boundary_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: family_buffer.as_entire_binding(), resource: family_buffer.as_entire_binding(),
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 3, binding: 2,
resource: dash_buffer.as_entire_binding(), resource: dash_buffer.as_entire_binding(),
}, },
wgpu::BindGroupEntry { wgpu::BindGroupEntry {
binding: 4, binding: 3,
resource: visibility_buffer.as_entire_binding(), resource: visibility_buffer.as_entire_binding(),
}, },
], ],
@ -582,10 +564,10 @@ impl StorageHatchBatch {
Some(Self { Some(Self {
vertex_buffer, vertex_buffer,
index_buffer,
placement_buffer, placement_buffer,
draws, draws,
instance_buffer, instance_buffer,
boundary_buffer,
family_buffer, family_buffer,
dash_buffer, dash_buffer,
visibility_buffer, visibility_buffer,
@ -614,10 +596,7 @@ impl StorageHatchBatch {
); );
} }
/// Group-1 bind group layout — shared by the pipeline so it can be /// Shared layout for instance, family, dash, and visibility storage.
/// 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)] #[allow(dead_code)]
pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
let entry = |binding: u32| wgpu::BindGroupLayoutEntry { let entry = |binding: u32| wgpu::BindGroupLayoutEntry {
@ -632,7 +611,7 @@ impl StorageHatchBatch {
}; };
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("hatch.bgl"), 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. // Texture-backed hatch renderer for devices without storage buffers. Boundary
// // geometry comes from the same kernel mesh as the storage backend.
// 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.
use crate::scene::model::hatch_model::{HatchModel, HatchPattern}; use crate::scene::model::hatch_model::{HatchModel, HatchPattern};
use iced::wgpu; use iced::wgpu;
@ -71,7 +62,7 @@ struct TextureHatchUniform {
color: [f32; 4], // 0 color: [f32; 4], // 0
color2: [f32; 4], // 16 color2: [f32; 4], // 16
mode: u32, // 32 mode: u32, // 32
vcount: u32, // 36 _reserved: u32, // 36
angle_offset: f32, // 40 angle_offset: f32, // 40
scale: f32, // 44 scale: f32, // 44
grad_cos: f32, // 48 grad_cos: f32, // 48
@ -90,7 +81,9 @@ struct TextureHatchUniform {
pub(super) struct TextureHatch { pub(super) struct TextureHatch {
pub(super) vertex_buffer: wgpu::Buffer, pub(super) vertex_buffer: wgpu::Buffer,
pub(super) index_buffer: wgpu::Buffer,
pub(super) placement_buffer: wgpu::Buffer, pub(super) placement_buffer: wgpu::Buffer,
pub(super) index_count: u32,
pub(super) instance_count: u32, pub(super) instance_count: u32,
pub(super) bind_group: wgpu::BindGroup, pub(super) bind_group: wgpu::BindGroup,
/// Reserved for per-frame AABB LOD (mirrors `WipeoutGpu`); not yet wired /// Reserved for per-frame AABB LOD (mirrors `WipeoutGpu`); not yet wired
@ -125,7 +118,7 @@ impl TextureHatch {
} }
groups groups
.into_iter() .into_iter()
.map(|group| Self::new(device, queue, &group, bgl1)) .filter_map(|group| Self::new(device, queue, &group, bgl1))
.collect() .collect()
} }
@ -164,7 +157,7 @@ impl TextureHatch {
queue: &wgpu::Queue, queue: &wgpu::Queue,
models: &[&HatchModel], models: &[&HatchModel],
bgl1: &wgpu::BindGroupLayout, bgl1: &wgpu::BindGroupLayout,
) -> Self { ) -> Option<Self> {
let model = models[0]; let model = models[0];
// ── Decode pattern mode (mirrors WipeoutGpu::new) ───────────────── // ── Decode pattern mode (mirrors WipeoutGpu::new) ─────────────────
// The gradient shape (kind + invert bit) rides in the mode's high // The gradient shape (kind + invert bit) rides in the mode's high
@ -197,41 +190,26 @@ impl TextureHatch {
max_y = max_y.max(y); 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 origin = model.world_origin;
let drift = [0.0f32, 0.0f32]; let drift = [0.0f32, 0.0f32];
let (x0, x1, y0, y1) = ( let (mesh_points, indices) = model.fill_mesh();
min_x + drift[0] - pad, if mesh_points.is_empty() || indices.is_empty() {
max_x + drift[0] + pad, return None;
min_y + drift[1] - pad, }
max_y + drift[1] + pad, let vertices: Vec<HatchVertex> = mesh_points
); .into_iter()
.map(|[x, y]| HatchVertex { pos: [x, y, 0.0], _pad: 0.0 })
let quad = [ .collect();
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 vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("hatch.texture.vbuf"), label: Some("hatch.texture.vbuf"),
contents: bytemuck::cast_slice(&quad), contents: bytemuck::cast_slice(&vertices),
usage: wgpu::BufferUsages::VERTEX, 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 let base = model
.render_instance .render_instance
.map_or([0.0; 3], |instance| instance.translation); .map_or([0.0; 3], |instance| instance.translation);
@ -290,21 +268,8 @@ impl TextureHatch {
(0.0, 1.0) (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(); 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. // Family section (3 texels each) + a flat dash pool.
let fam_off = texels.len() as u32; let fam_off = texels.len() as u32;
let mut n_families = 0u32; let mut n_families = 0u32;
@ -320,7 +285,7 @@ impl TextureHatch {
0.0 0.0
}; };
let angle_r = fam.angle_deg.to_radians(); 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([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]);
texels.push([fam.dx, fam.dy, fam.dy, fam.dx]); texels.push([fam.dx, fam.dy, fam.dy, fam.dx]);
// Counts as exact f32 (small integers → no denormal/bitcast risk). // Counts as exact f32 (small integers → no denormal/bitcast risk).
@ -385,7 +350,7 @@ impl TextureHatch {
color: model.color, color: model.color,
color2, color2,
mode, mode,
vcount, _reserved: 0,
angle_offset: model.angle_offset, angle_offset: model.angle_offset,
// Clamp like the desktop renderer so scale==0 can't make perp_step 0 // Clamp like the desktop renderer so scale==0 can't make perp_step 0
// → round(perp/0)=NaN → an invisible hatch. // → round(perp/0)=NaN → an invisible hatch.
@ -433,14 +398,16 @@ impl TextureHatch {
[min_x, min_y, max_x, max_y] [min_x, min_y, max_x, max_y]
}; };
Self { Some(Self {
vertex_buffer, vertex_buffer,
index_buffer,
placement_buffer, placement_buffer,
index_count: indices.len() as u32,
instance_count: placements.len() as u32, instance_count: placements.len() as u32,
bind_group, bind_group,
world_aabb, world_aabb,
_uniform_buf, _uniform_buf,
_data_tex: data_tex, _data_tex: data_tex,
} })
} }
} }

View file

@ -1,23 +1,10 @@
// Phase 4-B — batched hatch shader. All hatches in one draw call; // Batched hatch shader. Kernel mesh vertices carry their source instance.
// 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).
// //
// Layout — matches `hatch_gpu/storage.rs`: // Layout — matches `hatch_gpu/storage.rs`:
// group 1 binding 0 InstanceBuffer HatchInstance[] (128 B / inst) // group 1 binding 0 InstanceBuffer HatchInstance[]
// group 1 binding 1 BoundaryBuffer vec4<f32>[] (xy in .xy) // group 1 binding 1 FamilyBuffer LineFamilyGpu[]
// group 1 binding 2 FamilyBuffer LineFamilyGpu[] (48 B / fam) // group 1 binding 2 DashBuffer f32[]
// group 1 binding 3 DashBuffer f32[] // group 1 binding 3 Visibility u32[]
//
// 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 0: shared frame uniforms (matches hatch.wgsl) ────────────────── // ── Group 0: shared frame uniforms (matches hatch.wgsl) ──────────────────
@ -53,14 +40,10 @@ struct HatchInstance {
grad_range: f32, grad_range: f32,
mode: u32, // 0=pattern, 1=solid, 2=gradient mode: u32, // 0=pattern, 1=solid, 2=gradient
visible: u32, // 0 = skip (CPU writes via compute_hatch_lod) visible: u32, // 0 = skip (CPU writes via compute_hatch_lod)
boundary_offset: u32,
boundary_count: u32,
family_offset: u32, family_offset: u32,
family_count: u32, family_count: u32,
draw_depth: f32, // signed (-1,1) draw-order bias; 0 = neutral 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 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 → // 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(0) var<storage, read> instances: array<HatchInstance>;
@group(1) @binding(1) var<storage, read> boundary: array<vec4<f32>>; @group(1) @binding(1) var<storage, read> families: array<LineFamily>;
@group(1) @binding(2) var<storage, read> families: array<LineFamily>; @group(1) @binding(2) var<storage, read> dashes: array<f32>;
@group(1) @binding(3) var<storage, read> dashes: array<f32>;
// Per-instance visibility (Phase 4-B sub-pixel + frustum skip). // Per-instance visibility (Phase 4-B sub-pixel + frustum skip).
// CPU writes `1` to draw / `0` to skip every frame; vertex shader // 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 // emits an out-of-NDC clip position for 0-instances so the GPU
// rasterizer culls the primitive before any fragment runs. // 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 ──────────────────────────────────────────────────────── // ── Vertex shader ────────────────────────────────────────────────────────
@ -144,58 +126,6 @@ struct VOut {
return o; 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 // ── Per-family hatch test (same math as hatch.wgsl, dashes from
// global DashBuffer instead of per-hatch FamilyBatch) ──────────────────── // global DashBuffer instead of per-hatch FamilyBatch) ────────────────────
@ -282,15 +212,7 @@ fn check_family(
let inst = instances[v.instance_index]; let inst = instances[v.instance_index];
// 1. Boundary test — only on the fallback path (poly_test==1). On the // Mode dispatch.
// 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.
if inst.mode == 1u { if inst.mode == 1u {
return inst.color; return inst.color;
} else if inst.mode == 2u { } else if inst.mode == 2u {
@ -319,12 +241,11 @@ fn check_family(
t = 1.0 - t; t = 1.0 - t;
} }
// Radial stops run OUTSIDE-IN: colour 1 at the rim, colour 2 at the // Radial stops run OUTSIDE-IN: colour 1 at the rim, colour 2 at the
// centre (AutoCAD's SPHERICAL/HEMISPHERICAL convention; the INV bit // centre; the INV bit above swaps them back.
// above swaps them back).
return mix(inst.color2, inst.color, t); 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 // project below 2 px, then substitute one solid fill. A single dense
// family must neither hide itself nor turn a complex hatch solid. // 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; var all_families_subpixel = inst.family_count > 0u && u.world_per_pixel > 0.0;
@ -343,7 +264,7 @@ fn check_family(
return inst.color; return inst.color;
} }
// 4. Pattern evaluation. // Pattern evaluation.
let cos_off = cos(inst.angle_offset); let cos_off = cos(inst.angle_offset);
let sin_off = sin(inst.angle_offset); let sin_off = sin(inst.angle_offset);
for (var i = 0u; i < inst.family_count; i++) { 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. // Texture-backed hatch shader. The kernel mesh bounds the fill; this shader
// // evaluates only the pattern, solid, or gradient colour.
// 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.
// //
// Data texture layout (row-major, width = h.tex_width, one vec4 per texel): // 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) // [ fam_off .. fam_off+3*n_fam ) families, 3 texels each (see load_family)
// [ dash_off .. ) dash values, 4 per texel (RGBA) // [ dash_off .. ) dash values, 4 per texel (RGBA)
@ -36,7 +28,7 @@ struct HatchUniforms {
color: vec4<f32>, // 0 color: vec4<f32>, // 0
color2: vec4<f32>, // 16 color2: vec4<f32>, // 16
mode: u32, // 32: 0=pattern, 1=solid, 2=gradient mode: u32, // 32: 0=pattern, 1=solid, 2=gradient
vcount: u32, // 36: boundary vertex count _reserved: u32, // 36
angle_offset: f32, // 40 angle_offset: f32, // 40
scale: f32, // 44 scale: f32, // 44
grad_cos: f32, // 48 grad_cos: f32, // 48
@ -119,56 +111,6 @@ struct VOut {
return o; 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) ───────────────── // ── Per-family hatch test (identical math to wipeout.wgsl) ─────────────────
// `ddx_xz`/`ddy_xz` are screen-space derivatives of `xz`, taken once in // `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 ddx_xz = dpdx(v.xz);
let ddy_xz = dpdy(v.xz); let ddy_xz = dpdy(v.xz);
if !in_polygon(v.xz) { discard; }
let base_mode = h.mode & 0xFFu; let base_mode = h.mode & 0xFFu;
let gk = (h.mode >> 8u) & 15u; let gk = (h.mode >> 8u) & 15u;
let ginv = ((h.mode >> 8u) & 16u) != 0u; let ginv = ((h.mode >> 8u) & 16u) != 0u;