From 5b9fffdfba7a2f6b4b0c8e7cafde442e0fe67e66 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 13 Aug 2026 00:28:50 +0300 Subject: [PATCH] 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 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/scene/model/hatch_model.rs | 41 ++- src/scene/pipeline/device_capabilities.rs | 5 +- src/scene/pipeline/hatch_gpu/mod.rs | 62 +++-- src/scene/pipeline/hatch_gpu/storage.rs | 305 ++++++++++------------ src/scene/pipeline/hatch_gpu/texture.rs | 89 ++----- src/shaders/hatch.wgsl | 103 +------- src/shaders/hatch_texture.wgsl | 66 +---- 9 files changed, 259 insertions(+), 416 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 736d70c4f..cd87836de 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 38101daaa..42a3ec4c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index 641708f22..492ce02cd 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -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) { + 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 diff --git a/src/scene/pipeline/device_capabilities.rs b/src/scene/pipeline/device_capabilities.rs index 7650bfba0..c87cc6113 100644 --- a/src/scene/pipeline/device_capabilities.rs +++ b/src/scene/pipeline/device_capabilities.rs @@ -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()) diff --git a/src/scene/pipeline/hatch_gpu/mod.rs b/src/scene/pipeline/hatch_gpu/mod.rs index 86503453d..1585d043e 100644 --- a/src/scene/pipeline/hatch_gpu/mod.rs +++ b/src/scene/pipeline/hatch_gpu/mod.rs @@ -34,8 +34,8 @@ impl HatchBackendKind { enum HatchBackend { Storage { - resident: Option, - preview: Option, + resident: Vec, + preview: Vec, }, Texture { resident: Vec, @@ -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 ); } diff --git a/src/scene/pipeline/hatch_gpu/storage.rs b/src/scene/pipeline/hatch_gpu/storage.rs index 01e9ede65..4ab94131e 100644 --- a/src/scene/pipeline/hatch_gpu/storage.rs +++ b/src/scene/pipeline/hatch_gpu/storage.rs @@ -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 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[] (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::() == 128); +const _: () = assert!(std::mem::size_of::() == 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::() == 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, std::ops::Range)>, // 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, pub source_visibility: Vec, 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::() as u64), + boundary.saturating_mul(24), + std::mem::size_of::() as u64, + families.saturating_mul(std::mem::size_of::() as u64), + dashes.saturating_mul(std::mem::size_of::() as u64), + std::mem::size_of::() as u64, + std::mem::size_of::() 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 { + ) -> Vec { 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 { + let mut instances: Vec = Vec::with_capacity(hatches.len()); - let mut boundary: Vec<[f32; 4]> = Vec::new(); let mut families: Vec = Vec::new(); let mut dashes: Vec = Vec::new(); + let mut meshes = Vec::with_capacity(hatches.len()); let mut slots = rustc_hash::FxHashMap::default(); let mut groups: Vec> = 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 = 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 = 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::()) + || !buffer_fits(indices.len(), std::mem::size_of::()) + || !buffer_fits(placements.len(), std::mem::size_of::()) + || !storage_fits(instances.len(), std::mem::size_of::()) + || !storage_fits(families.len(), std::mem::size_of::()) + || !storage_fits(dashes.len(), std::mem::size_of::()) + || !storage_fits(visibility.len(), std::mem::size_of::()) + { + 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 = 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)], }) } } diff --git a/src/scene/pipeline/hatch_gpu/texture.rs b/src/scene/pipeline/hatch_gpu/texture.rs index d2037a922..83fe314b8 100644 --- a/src/scene/pipeline/hatch_gpu/texture.rs +++ b/src/scene/pipeline/hatch_gpu/texture.rs @@ -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 { 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 = 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, - } + }) } } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index cef1f51dc..f288faee5 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -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[] (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 instances: array; -@group(1) @binding(1) var boundary: array>; -@group(1) @binding(2) var families: array; -@group(1) @binding(3) var dashes: array; +@group(1) @binding(1) var families: array; +@group(1) @binding(2) var dashes: array; // 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 visibility: array; +@group(1) @binding(3) var visibility: array; // ── 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) -> 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, a: vec2, c: vec2) -> 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, offset: u32, count: u32) -> bool { - var inside = false; - var prev = vec2(0.0, 0.0); - var first = vec2(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++) { diff --git a/src/shaders/hatch_texture.wgsl b/src/shaders/hatch_texture.wgsl index 3a7775382..c67df0b8d 100644 --- a/src/shaders/hatch_texture.wgsl +++ b/src/shaders/hatch_texture.wgsl @@ -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, // 0 color2: vec4, // 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) -> 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, a: vec2, c: vec2) -> 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) -> bool { - var inside = false; - let n = h.vcount; - var prev = vec2(0.0, 0.0); - var first = vec2(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;