diff --git a/src/scene/pipeline/hatch_batched_gpu.rs b/src/scene/pipeline/hatch_batched_gpu.rs deleted file mode 100644 index ee2fb719c..000000000 --- a/src/scene/pipeline/hatch_batched_gpu.rs +++ /dev/null @@ -1,559 +0,0 @@ -// Phase 4-B — batched hatch rendering. Single draw call for all -// hatches, per-instance data fetched from storage buffers in the -// vertex shader. Replaces the per-hatch bind group + draw of -// `hatch_gpu.rs` once the new path is wired in. -// -// 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. - -use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily}; -use iced::wgpu; -use iced::wgpu::util::DeviceExt; - -// ── GPU structs ──────────────────────────────────────────────────────────── -// -// Layout matches the WGSL `HatchInstance` exactly. `repr(C)` + manual -// padding keeps WGSL's 16-byte alignment rules satisfied for arrays of -// this struct. - -#[repr(C)] -#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] -pub struct HatchInstance { - pub color: [f32; 4], // 0 - pub color2: [f32; 4], // 16 (gradient end) - pub aabb: [f32; 4], // 32 (local-space xmin,ymin,xmax,ymax) - pub world_origin: [f32; 2], // 48 (anchor high half, added back in VS) - pub world_origin_low: [f32; 2], // 56 (anchor low residual — double-single) - pub angle_offset: f32, // 64 - pub scale: f32, // 68 - pub grad_cos: f32, // 72 - pub grad_sin: f32, // 76 - pub grad_min: f32, // 80 - pub grad_range: f32, // 84 - pub mode: u32, // 88 (0=pattern, 1=solid, 2=gradient) - pub visible: u32, // 92 (CPU sets to 0 to skip) - pub boundary_offset: u32, // 96 (first boundary vert index) - pub boundary_count: u32, // 100 - pub family_offset: u32, // 104 - pub family_count: u32, // 108 - /// Signed draw-order depth (-1,1); 0.0 = neutral. Applied as a clip-z - /// bias in the vertex shader so this fill orders against other types. - pub draw_depth: f32, // 112 - /// 1 = run the per-fragment `in_polygon` boundary test (fallback path for - /// an instance whose boundary failed to tessellate); 0 = the rasterized - /// triangles already bound the fill, skip the test. - pub poly_test: u32, // 116 - pub _pad1: u32, // 120 - pub _pad2: u32, // 124 -} - -const _: () = assert!(std::mem::size_of::() == 128); - -/// Split a hatch's f64 world-origin anchor into double-single (high, low) f32 -/// pairs so the GPU keeps sub-unit precision at UTM-scale coordinates. -#[inline] -fn split_origin_ds(o: [f64; 2]) -> ([f32; 2], [f32; 2]) { - let hx = o[0] as f32; - let hy = o[1] as f32; - ([hx, hy], [(o[0] - hx as f64) as f32, (o[1] - hy as f64) as f32]) -} - -/// Mirrors the per-family struct used by the existing per-hatch shader, -/// but the dash slice lives in a separate concatenated DashBuffer (the -/// old shader had it embedded). `dash_offset` / `dash_count` index into -/// that flat array. -#[repr(C)] -#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] -pub struct LineFamilyGpu { - pub cos_a: f32, // 0 - pub sin_a: f32, // 4 - pub x0: f32, // 8 - pub y0: f32, // 12 - pub dx: f32, // 16 - pub dy: f32, // 20 - pub perp_step: f32, // 24 - pub along_step: f32, // 28 - pub line_width: f32, // 32 - pub period: f32, // 36 - pub n_dashes: u32, // 40 - pub dash_offset: u32, // 44 -} - -const _: () = assert!(std::mem::size_of::() == 48); - -/// Per-vertex data — tessellated boundary triangles in local (world_origin- -/// relative) space, each carrying its instance index (avoids relying on -/// `@builtin(instance_index)` across backends). Instances whose boundary -/// failed to tessellate emit an AABB quad here instead (see `build`). -#[repr(C)] -#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] -pub struct HatchBatchedVertex { - pub local_xy: [f32; 2], // 0 — local-space position - pub instance_index: u32, // 8 — index into InstanceBuffer -} - -impl HatchBatchedVertex { - pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { - wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::() as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[ - wgpu::VertexAttribute { - offset: 0, - shader_location: 0, - format: wgpu::VertexFormat::Float32x2, - }, - wgpu::VertexAttribute { - offset: 8, - shader_location: 1, - format: wgpu::VertexFormat::Uint32, - }, - ], - } - } -} - -// ── Batch builder ────────────────────────────────────────────────────────── - -/// Pack a list of `HatchModel`s into the four concatenated storage -/// buffers + the per-vertex buffer needed by `hatch_batched.wgsl`. -/// Returns `None` when the input slice is empty (caller skips the -/// hatch render pass entirely). -pub struct HatchBatchedGpu { - pub vertex_buffer: wgpu::Buffer, - pub vertex_count: u32, - // The four storage buffers below are referenced via `bind_group` — - // dropping them would invalidate it, but the bind group is the - // only direct consumer. Keep them as fields to keep ownership in - // one place; `#[allow(dead_code)]` silences the read-never warning. - #[allow(dead_code)] pub instance_buffer: wgpu::Buffer, - #[allow(dead_code)] pub boundary_buffer: wgpu::Buffer, - #[allow(dead_code)] pub family_buffer: wgpu::Buffer, - #[allow(dead_code)] pub dash_buffer: wgpu::Buffer, - /// Per-instance visibility flag (1=draw, 0=skip). Stored in its - /// own small storage buffer so per-frame updates don't have to - /// touch the large `instance_buffer`. Vertex shader reads - /// `visibility[instance_index]` — when 0 it emits an out-of-NDC - /// clip position so the GPU clips the primitive before the - /// fragment stage runs. - pub visibility_buffer: wgpu::Buffer, - pub bind_group: wgpu::BindGroup, - #[allow(dead_code)] pub instance_count: u32, - /// CPU mirror — `update_visibility` re-uploads this whole slice - /// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches - /// per pan tick. Far cheaper than touching the 128 B-per-instance - /// data. - pub visibility: Vec, - /// CPU-side mirror of each instance's local-space AABB (world- - /// offset-subtracted, world_origin already added back). Used by - /// `compute_hatch_lod` to evaluate the sub-pixel + frustum cull - /// without reading back from the GPU. - pub instance_aabbs: Vec<[f32; 4]>, -} - -impl HatchBatchedGpu { - /// One-time build from the full hatch list. Re-uploaded only when - /// `geometry_epoch` advances (mirrors the existing per-hatch - /// upload trigger). Per-frame visibility flips go through - /// [`upload_visibility`]. - pub fn build( - device: &wgpu::Device, - bgl: &wgpu::BindGroupLayout, - hatches: &[HatchModel], - ) -> Option { - if hatches.is_empty() { - return None; - } - - 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(); - - for h in hatches { - let boundary_offset = boundary.len() as u32; - for &[x, y] in h.boundary.iter() { - boundary.push([x, y, 0.0, 0.0]); - } - let boundary_count = boundary.len() as u32 - boundary_offset; - - - let family_offset = families.len() as u32; - let mut family_count = 0u32; - - let (mode, color2, grad_cos, grad_sin, grad_min, grad_range) = match &h.pattern { - HatchPattern::Solid => (1u32, [0.0; 4], 0.0, 0.0, 0.0, 1.0), - HatchPattern::Gradient { angle_deg, color2 } => { - let r = angle_deg.to_radians(); - // Gradient projection range (proj_min / proj_range) — - // computed at upload time, identical to per-hatch path. - let (gmin, gmax) = boundary_projection_range(&h.boundary, r); - let grange = (gmax - gmin).max(1.0); - (2u32, *color2, r.cos(), r.sin(), gmin, grange) - } - HatchPattern::Pattern(fams) => { - for fam in fams { - let dash_offset = dashes.len() as u32; - for &d in &fam.dashes { - dashes.push(d); - } - let n_dashes = (dashes.len() as u32 - dash_offset).min(u32::MAX); - // QCAD PAT local-frame convention (mirrors - // `build_family_batch` in hatch_gpu.rs): `dy` is - // the perpendicular spacing, `dx` is the along-line - // phase shift — both in family-local coords. The - // shader applies cos_off/sin_off to rotate them. - let perp_step = fam.dy; - let along_step = fam.dx; - // Screen-space derivative drives 1-px line width - // in the shader; this stored field is unused. - let line_width = 0.0_f32; - let period: f32 = fam.dashes.iter().map(|d| d.abs()).sum(); - families.push(LineFamilyGpu { - cos_a: fam.angle_deg.to_radians().cos(), - sin_a: fam.angle_deg.to_radians().sin(), - x0: fam.x0, - y0: fam.y0, - dx: fam.dx, - dy: fam.dy, - perp_step, - along_step, - line_width, - period: if n_dashes > 0 { period } else { 0.0 }, - n_dashes, - dash_offset, - }); - family_count += 1; - } - (0u32, [0.0; 4], 0.0, 0.0, 0.0, 1.0) - } - }; - - // Boundary AABB in local space (matches the corner quad - // emitted by the vertex shader). The verts are already in - // `world_origin`-relative coords (see scene/mod.rs hatch - // packing), so this AABB lives in that frame. - let mut min_x = f32::INFINITY; - let mut min_y = f32::INFINITY; - let mut max_x = f32::NEG_INFINITY; - let mut max_y = f32::NEG_INFINITY; - for &[x, y] in h.boundary.iter() { - if x.is_finite() && y.is_finite() { - if x < min_x { min_x = x; } - if y < min_y { min_y = y; } - if x > max_x { max_x = x; } - if y > max_y { max_y = y; } - } - } - if !min_x.is_finite() { - // Empty / all-NaN — skip but keep the slot so indices - // stay in lockstep with the input list (visibility=0). - let (wo_hi, wo_lo) = split_origin_ds(h.world_origin); - instances.push(HatchInstance { - color: h.color, - color2, - aabb: [0.0, 0.0, 0.0, 0.0], - world_origin: wo_hi, - world_origin_low: wo_lo, - angle_offset: h.angle_offset, - scale: h.scale.max(1e-6), - grad_cos, - grad_sin, - grad_min, - grad_range, - mode, - visible: 0, - boundary_offset, - boundary_count, - family_offset, - family_count, - draw_depth: h.draw_depth, - poly_test: 1, - _pad1: 0, - _pad2: 0, - }); - continue; - } - - // Pad the AABB so the quad covers any pattern halo + the - // family origin. Mirrors the per-hatch shader's quad sizing - // logic — `diag * 0.8 + max_spacing * 2 * scale`. - let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt(); - // `perp_step.abs()` per family — uses the same QCAD local- - // frame convention as `LineFamilyGpu.perp_step` above so - // the quad padding matches what the shader will sample. - let max_spacing = match &h.pattern { - HatchPattern::Pattern(fs) => fs - .iter() - .map(|f| f.dy.abs()) - .fold(0.0f32, f32::max), - _ => 5.0, - }; - let pad = (diag * 0.8 + max_spacing * 2.0 * h.scale).max(1.0); - - let (wo_hi, wo_lo) = split_origin_ds(h.world_origin); - instances.push(HatchInstance { - color: h.color, - color2, - aabb: [min_x - pad, min_y - pad, max_x + pad, max_y + pad], - world_origin: wo_hi, - world_origin_low: wo_lo, - angle_offset: h.angle_offset, - scale: h.scale.max(1e-6), - grad_cos, - grad_sin, - grad_min, - grad_range, - mode, - visible: 1, - boundary_offset, - boundary_count, - family_offset, - family_count, - draw_depth: h.draw_depth, - // Always resolve inside/outside per fragment on the GPU (the - // boundary is uploaded below). Dropping CPU pre-triangulation - // keeps the vertex buffer at 6 verts/hatch regardless of - // boundary complexity, so a hatch-dense drawing can't overflow - // the device buffer limit. Each instance draws its AABB quad. - poly_test: 1, - _pad1: 0, - _pad2: 0, - }); - } - // Empty fallbacks — storage buffers can't be zero-sized. - if boundary.is_empty() { - boundary.push([0.0; 4]); - } - if families.is_empty() { - families.push(LineFamilyGpu::default_filler()); - } - if dashes.is_empty() { - dashes.push(0.0); - } - - // Vertex buffer — one AABB quad (two triangles, BL,BR,TL, BR,TR,TL) per - // instance; the GPU shader clips each fragment to the boundary via the - // in_polygon test (poly_test == 1). 6 verts/hatch, independent of - // boundary complexity, so the buffer can never overflow the device - // limit no matter how dense the drawing. - let mut verts: Vec = 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(HatchBatchedVertex { local_xy: c, instance_index: i as u32 }); - } - } - - let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch_batched.vertex"), - contents: bytemuck::cast_slice(&verts), - usage: wgpu::BufferUsages::VERTEX, - }); - let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch_batched.instances"), - contents: bytemuck::cast_slice(&instances), - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - }); - let boundary_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch_batched.boundary"), - contents: bytemuck::cast_slice(&boundary), - usage: wgpu::BufferUsages::STORAGE, - }); - let family_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch_batched.families"), - contents: bytemuck::cast_slice(&families), - usage: wgpu::BufferUsages::STORAGE, - }); - let dash_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch_batched.dashes"), - contents: bytemuck::cast_slice(&dashes), - 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_batched.visibility"), - contents: bytemuck::cast_slice(&visibility), - usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, - }); - - let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("hatch_batched.bg"), - layout: bgl, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: instance_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: boundary_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: family_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 3, - resource: dash_buffer.as_entire_binding(), - }, - wgpu::BindGroupEntry { - binding: 4, - resource: visibility_buffer.as_entire_binding(), - }, - ], - }); - - // CPU AABB mirror — local-space rect with world_origin added - // back, ready for `aabb_offscreen` / `aabb_below_pixel` to - // compare against the camera view_proj. - let instance_aabbs: Vec<[f32; 4]> = instances - .iter() - .map(|i| { - let ox = i.world_origin[0]; - let oy = i.world_origin[1]; - [ - i.aabb[0] + ox, - i.aabb[1] + oy, - i.aabb[2] + ox, - i.aabb[3] + oy, - ] - }) - .collect(); - - Some(Self { - vertex_buffer, - vertex_count: verts.len() as u32, - instance_buffer, - boundary_buffer, - family_buffer, - dash_buffer, - visibility_buffer, - bind_group, - instance_count: instances.len() as u32, - visibility, - instance_aabbs, - }) - } - - /// Push the CPU `visibility` slice to GPU. Call when any - /// element changes (typically per-frame from compute_hatch_lod). - pub fn upload_visibility(&self, queue: &wgpu::Queue) { - queue.write_buffer( - &self.visibility_buffer, - 0, - bytemuck::cast_slice(&self.visibility), - ); - } - - /// Group-1 bind group layout — shared by the pipeline so it can be - /// constructed once at startup. All four bindings are read-only - /// storage and visible to both VS (AABB+visibility lookup) and FS - /// (boundary / family / dash sampling). - #[allow(dead_code)] - pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { - let entry = |binding: u32| wgpu::BindGroupLayoutEntry { - binding, - visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, - ty: wgpu::BindingType::Buffer { - ty: wgpu::BufferBindingType::Storage { read_only: true }, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }; - device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("hatch_batched.bgl"), - entries: &[entry(0), entry(1), entry(2), entry(3), entry(4)], - }) - } -} - -impl LineFamilyGpu { - fn default_filler() -> Self { - Self { - cos_a: 1.0, - sin_a: 0.0, - x0: 0.0, - y0: 0.0, - dx: 1.0, - dy: 0.0, - perp_step: 1.0, - along_step: 1.0, - line_width: 0.0, - period: 0.0, - n_dashes: 0, - dash_offset: 0, - } - } -} - -// ── Helpers ─────────────────────────────────────────────────────────────── - -/// Project every boundary vertex onto the gradient direction -/// `(cos θ, sin θ)` and return the (min, max) projection. Used to set -/// up the gradient's normalized parameter range. Same math as the -/// per-hatch path; duplicated here to keep this module self-contained. -fn boundary_projection_range(boundary: &[[f32; 2]], theta: f32) -> (f32, f32) { - let (cs, sn) = (theta.cos(), theta.sin()); - let mut lo = f32::INFINITY; - let mut hi = f32::NEG_INFINITY; - for &[x, y] in boundary { - if !x.is_finite() || !y.is_finite() { - continue; - } - let p = x * cs + y * sn; - if p < lo { lo = p; } - if p > hi { hi = p; } - } - if !lo.is_finite() { - return (0.0, 1.0); - } - (lo, hi) -} - -// PatFamily is re-exported by hatch_model so we don't need to import -// it explicitly anywhere else — but rust needs the type referenced to -// confirm the layout assumption above. -#[allow(dead_code)] -fn _assert_patfamily_fields(f: &PatFamily) -> (f32, f32, f32) { - (f.angle_deg, f.x0, f.y0) -} diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index 4a2265eb0..5644b786d 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -1,26 +1,133 @@ -// Hatch GPU buffers — renders hatch fills as shader-driven bounding-box quads. +// 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. `None` on WebGL2 (wasm), which lacks +// vertex-stage storage buffers. The old per-hatch renderer with the +// 16-family cap now lives in `wipeout_gpu.rs` and serves only wipeouts. // -// Group 1 bindings per hatch: -// binding 0 — HatchUniformData (64 bytes) mode, color, angle_offset, scale, gradient params -// binding 1 — BoundaryData (16384 bytes) boundary polygon vertices -// binding 2 — FamilyBatchData (1296 bytes) up to 16 line families + 128 dash values +// 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. -use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily, MAX_HATCH_BOUNDARY_VERTS}; +use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily}; use iced::wgpu; use iced::wgpu::util::DeviceExt; -// ── Constants ────────────────────────────────────────────────────────────── - -pub const MAX_FAMILIES: usize = 16; -pub const MAX_DASHES: usize = 128; - -// ── Vertex ──────────────────────────────────────────────────────────────── +// ── GPU structs ──────────────────────────────────────────────────────────── +// +// Layout matches the WGSL `HatchInstance` exactly. `repr(C)` + manual +// padding keeps WGSL's 16-byte alignment rules satisfied for arrays of +// this struct. #[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct HatchInstance { + pub color: [f32; 4], // 0 + pub color2: [f32; 4], // 16 (gradient end) + pub aabb: [f32; 4], // 32 (local-space xmin,ymin,xmax,ymax) + pub world_origin: [f32; 2], // 48 (anchor high half, added back in VS) + pub world_origin_low: [f32; 2], // 56 (anchor low residual — double-single) + pub angle_offset: f32, // 64 + pub scale: f32, // 68 + pub grad_cos: f32, // 72 + pub grad_sin: f32, // 76 + pub grad_min: f32, // 80 + pub grad_range: f32, // 84 + pub mode: u32, // 88 (0=pattern, 1=solid, 2=gradient) + pub visible: u32, // 92 (CPU sets to 0 to skip) + pub boundary_offset: u32, // 96 (first boundary vert index) + pub boundary_count: u32, // 100 + pub family_offset: u32, // 104 + pub family_count: u32, // 108 + /// Signed draw-order depth (-1,1); 0.0 = neutral. Applied as a clip-z + /// bias in the vertex shader so this fill orders against other types. + pub draw_depth: f32, // 112 + /// 1 = run the per-fragment `in_polygon` boundary test (fallback path for + /// an instance whose boundary failed to tessellate); 0 = the rasterized + /// triangles already bound the fill, skip the test. + pub poly_test: u32, // 116 + pub _pad1: u32, // 120 + pub _pad2: u32, // 124 +} + +const _: () = assert!(std::mem::size_of::() == 128); + +/// Split a hatch's f64 world-origin anchor into double-single (high, low) f32 +/// pairs so the GPU keeps sub-unit precision at UTM-scale coordinates. +#[inline] +fn split_origin_ds(o: [f64; 2]) -> ([f32; 2], [f32; 2]) { + let hx = o[0] as f32; + let hy = o[1] as f32; + ([hx, hy], [(o[0] - hx as f64) as f32, (o[1] - hy as f64) as f32]) +} + +/// Mirrors the per-family struct used by the existing per-hatch shader, +/// but the dash slice lives in a separate concatenated DashBuffer (the +/// old shader had it embedded). `dash_offset` / `dash_count` index into +/// that flat array. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct LineFamilyGpu { + pub cos_a: f32, // 0 + pub sin_a: f32, // 4 + pub x0: f32, // 8 + pub y0: f32, // 12 + pub dx: f32, // 16 + pub dy: f32, // 20 + pub perp_step: f32, // 24 + pub along_step: f32, // 28 + pub line_width: f32, // 32 + pub period: f32, // 36 + pub n_dashes: u32, // 40 + pub dash_offset: u32, // 44 +} + +const _: () = assert!(std::mem::size_of::() == 48); + +/// Per-vertex data — tessellated boundary triangles in local (world_origin- +/// relative) space, each carrying its instance index (avoids relying on +/// `@builtin(instance_index)` across backends). Instances whose boundary +/// failed to tessellate emit an AABB quad here instead (see `build`). +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] pub struct HatchVertex { - pub pos: [f32; 3], - pub _pad: f32, + pub local_xy: [f32; 2], // 0 — local-space position + pub instance_index: u32, // 8 — index into InstanceBuffer } impl HatchVertex { @@ -28,437 +135,430 @@ impl HatchVertex { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - offset: 0, - shader_location: 0, - format: wgpu::VertexFormat::Float32x3, - }], + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x2, + }, + wgpu::VertexAttribute { + offset: 8, + shader_location: 1, + format: wgpu::VertexFormat::Uint32, + }, + ], } } } -// ── Uniform structs ─────────────────────────────────────────────────────── - -/// Per-hatch parameters (binding 0). Must be 80 bytes (16-byte aligned). -/// -/// `mode` encoding: -/// 0 → Pattern (families in FamilyBatchData) -/// 1 → Solid -/// 2 → Gradient (grad_cos/sin/min/range used) -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct HatchUniformData { - pub color: [f32; 4], // 0: primary RGBA / gradient start - pub color2: [f32; 4], // 16: gradient end color - pub mode: u32, // 32: 0=pattern, 1=solid, 2=gradient - pub vertex_count: u32, // 36: boundary vertex count - pub angle_offset: f32, // 40: pattern rotation (radians) - pub scale: f32, // 44: pattern scale multiplier - pub grad_cos: f32, // 48: gradient direction cos - pub grad_sin: f32, // 52: gradient direction sin - pub grad_min: f32, // 56: gradient proj_min - pub grad_range: f32, // 60: gradient proj_range - pub origin: [f32; 2], // 64: hatch-local origin (boundary AABB centre), - // // high half of the double-single anchor. - // // The pattern fragment shader subtracts this - // // from xz before the perp/perp_step quotient - // // so f32 doesn't catastrophically cancel when - // // world coords are large and pattern spacing - // // is small (e.g. UTM drawing + sub-mm hatch). - pub origin_low: [f32; 2], // 72: anchor low residual (double-single) so the - // // vertex clip position stays precise at UTM scale. -} // total 80 bytes - -/// Boundary polygon (binding 1). Matches WGSL `Boundary`. -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct BoundaryData { - pub verts: [[f32; 4]; MAX_HATCH_BOUNDARY_VERTS], // world XY in .xy, .zw unused -} - -/// One line family packed for the shader (48 bytes). -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct LineFamilyGpu { - pub cos_a: f32, // cos(angle_rad) - pub sin_a: f32, // sin(angle_rad) - pub x0: f32, // family origin x - pub y0: f32, // family origin y - pub dx: f32, // step vector x - pub dy: f32, // step vector y - pub perp_step: f32, // -dx*sin_a + dy*cos_a (perpendicular spacing) - pub along_step: f32, // dx*cos_a + dy*sin_a (along-line phase shift per step) - pub line_width: f32, // |perp_step| * 0.08 - pub period: f32, // sum of |dash values| (0 = solid) - pub n_dashes: u32, // number of dash entries (0 = solid) - pub dash_off: u32, // index into FamilyBatchData::dash_values -} - -/// All line families + dash values for one hatch (binding 2). -/// Total size: 16×48 + 32×16 + 4×4 = 768 + 512 + 16 = 1296 bytes. -/// dash_values is [f32; 4]×32 so each element is vec4 (16-byte stride, valid in uniform). -#[repr(C)] -#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct FamilyBatchData { - pub families: [LineFamilyGpu; MAX_FAMILIES], // 768 bytes - pub dash_values: [[f32; 4]; 32], // 512 bytes (128 f32s as 32×vec4) - pub n_families: u32, // 4 bytes - pub _pad: [u32; 3], // 12 bytes -} // 1296 bytes total - -// ── Per-hatch GPU handle ─────────────────────────────────────────────────── +// ── Batch builder ────────────────────────────────────────────────────────── +/// Pack a list of `HatchModel`s into the four concatenated storage +/// buffers + the per-vertex buffer needed by `hatch.wgsl`. +/// Returns `None` when the input slice is empty (caller skips the +/// hatch render pass entirely). pub struct HatchGpu { pub vertex_buffer: wgpu::Buffer, + pub vertex_count: u32, + // The four storage buffers below are referenced via `bind_group` — + // dropping them would invalidate it, but the bind group is the + // only direct consumer. Keep them as fields to keep ownership in + // one place; `#[allow(dead_code)]` silences the read-never warning. + #[allow(dead_code)] pub instance_buffer: wgpu::Buffer, + #[allow(dead_code)] pub boundary_buffer: wgpu::Buffer, + #[allow(dead_code)] pub family_buffer: wgpu::Buffer, + #[allow(dead_code)] pub dash_buffer: wgpu::Buffer, + /// Per-instance visibility flag (1=draw, 0=skip). Stored in its + /// own small storage buffer so per-frame updates don't have to + /// touch the large `instance_buffer`. Vertex shader reads + /// `visibility[instance_index]` — when 0 it emits an out-of-NDC + /// clip position so the GPU clips the primitive before the + /// fragment stage runs. + pub visibility_buffer: wgpu::Buffer, pub bind_group: wgpu::BindGroup, - /// Mirrors `HatchModel.vp_scissor`. Forwarded to the pipeline so each - /// frame's `compute_hatch_scissors` can translate the world rect into - /// a pixel scissor. - pub vp_scissor: Option<[f32; 4]>, - /// World-space XY bounding rect [min_x, min_y, max_x, max_y] of the - /// boundary polygon. Used by the per-frame LOD pass to skip hatches - /// whose entire footprint projects to less than ~2 px (Phase 3.3). - pub world_aabb: [f32; 4], - _uniform_buf: wgpu::Buffer, - _boundary_buf: wgpu::Buffer, - _family_buf: wgpu::Buffer, + #[allow(dead_code)] pub instance_count: u32, + /// CPU mirror — `update_visibility` re-uploads this whole slice + /// when any flag changes. ~4 B per hatch, so 40 KB / 10 k hatches + /// per pan tick. Far cheaper than touching the 128 B-per-instance + /// data. + pub visibility: Vec, + /// CPU-side mirror of each instance's local-space AABB (world- + /// offset-subtracted, world_origin already added back). Used by + /// `compute_hatch_lod` to evaluate the sub-pixel + frustum cull + /// without reading back from the GPU. + pub instance_aabbs: Vec<[f32; 4]>, } impl HatchGpu { - pub fn new(device: &wgpu::Device, model: &HatchModel, bgl1: &wgpu::BindGroupLayout) -> Self { - // ── Decode pattern mode ────────────────────────────────────────── - let (mode, color2, grad_cos, grad_sin) = match &model.pattern { - HatchPattern::Solid => (1u32, [0.0f32; 4], 0.0f32, 0.0f32), - HatchPattern::Pattern(_) => (0u32, [0.0f32; 4], 0.0f32, 0.0f32), - HatchPattern::Gradient { angle_deg, color2 } => { - let r = angle_deg.to_radians(); - (2u32, *color2, r.cos(), r.sin()) - } - }; + /// One-time build from the full hatch list. Re-uploaded only when + /// `geometry_epoch` advances (mirrors the existing per-hatch + /// upload trigger). Per-frame visibility flips go through + /// [`upload_visibility`]. + pub fn build( + device: &wgpu::Device, + bgl: &wgpu::BindGroupLayout, + hatches: &[HatchModel], + ) -> Option { + if hatches.is_empty() { + return None; + } - // ── Bounding box ───────────────────────────────────────────────── - let (mut min_x, mut max_x, mut min_y, mut max_y) = ( - f32::INFINITY, - f32::NEG_INFINITY, - f32::INFINITY, - f32::NEG_INFINITY, - ); - for &[x, y] in model.boundary.iter() { - if !x.is_finite() || !y.is_finite() { + 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(); + + for h in hatches { + let boundary_offset = boundary.len() as u32; + for &[x, y] in h.boundary.iter() { + boundary.push([x, y, 0.0, 0.0]); + } + let boundary_count = boundary.len() as u32 - boundary_offset; + + + let family_offset = families.len() as u32; + let mut family_count = 0u32; + + let (mode, color2, grad_cos, grad_sin, grad_min, grad_range) = match &h.pattern { + HatchPattern::Solid => (1u32, [0.0; 4], 0.0, 0.0, 0.0, 1.0), + HatchPattern::Gradient { angle_deg, color2 } => { + let r = angle_deg.to_radians(); + // Gradient projection range (proj_min / proj_range) — + // computed at upload time, identical to per-hatch path. + let (gmin, gmax) = boundary_projection_range(&h.boundary, r); + let grange = (gmax - gmin).max(1.0); + (2u32, *color2, r.cos(), r.sin(), gmin, grange) + } + HatchPattern::Pattern(fams) => { + for fam in fams { + let dash_offset = dashes.len() as u32; + for &d in &fam.dashes { + dashes.push(d); + } + let n_dashes = (dashes.len() as u32 - dash_offset).min(u32::MAX); + // QCAD PAT local-frame convention (mirrors + // `build_family_batch` in hatch_gpu.rs): `dy` is + // the perpendicular spacing, `dx` is the along-line + // phase shift — both in family-local coords. The + // shader applies cos_off/sin_off to rotate them. + let perp_step = fam.dy; + let along_step = fam.dx; + // Screen-space derivative drives 1-px line width + // in the shader; this stored field is unused. + let line_width = 0.0_f32; + let period: f32 = fam.dashes.iter().map(|d| d.abs()).sum(); + families.push(LineFamilyGpu { + cos_a: fam.angle_deg.to_radians().cos(), + sin_a: fam.angle_deg.to_radians().sin(), + x0: fam.x0, + y0: fam.y0, + dx: fam.dx, + dy: fam.dy, + perp_step, + along_step, + line_width, + period: if n_dashes > 0 { period } else { 0.0 }, + n_dashes, + dash_offset, + }); + family_count += 1; + } + (0u32, [0.0; 4], 0.0, 0.0, 0.0, 1.0) + } + }; + + // Boundary AABB in local space (matches the corner quad + // emitted by the vertex shader). The verts are already in + // `world_origin`-relative coords (see scene/mod.rs hatch + // packing), so this AABB lives in that frame. + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for &[x, y] in h.boundary.iter() { + if x.is_finite() && y.is_finite() { + if x < min_x { min_x = x; } + if y < min_y { min_y = y; } + if x > max_x { max_x = x; } + if y > max_y { max_y = y; } + } + } + if !min_x.is_finite() { + // Empty / all-NaN — skip but keep the slot so indices + // stay in lockstep with the input list (visibility=0). + let (wo_hi, wo_lo) = split_origin_ds(h.world_origin); + instances.push(HatchInstance { + color: h.color, + color2, + aabb: [0.0, 0.0, 0.0, 0.0], + world_origin: wo_hi, + world_origin_low: wo_lo, + angle_offset: h.angle_offset, + scale: h.scale.max(1e-6), + grad_cos, + grad_sin, + grad_min, + grad_range, + mode, + visible: 0, + boundary_offset, + boundary_count, + family_offset, + family_count, + draw_depth: h.draw_depth, + poly_test: 1, + _pad1: 0, + _pad2: 0, + }); continue; } - min_x = min_x.min(x); - max_x = max_x.max(x); - min_y = min_y.min(y); - max_y = max_y.max(y); + + // Pad the AABB so the quad covers any pattern halo + the + // family origin. Mirrors the per-hatch shader's quad sizing + // logic — `diag * 0.8 + max_spacing * 2 * scale`. + let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt(); + // `perp_step.abs()` per family — uses the same QCAD local- + // frame convention as `LineFamilyGpu.perp_step` above so + // the quad padding matches what the shader will sample. + let max_spacing = match &h.pattern { + HatchPattern::Pattern(fs) => fs + .iter() + .map(|f| f.dy.abs()) + .fold(0.0f32, f32::max), + _ => 5.0, + }; + let pad = (diag * 0.8 + max_spacing * 2.0 * h.scale).max(1.0); + + let (wo_hi, wo_lo) = split_origin_ds(h.world_origin); + instances.push(HatchInstance { + color: h.color, + color2, + aabb: [min_x - pad, min_y - pad, max_x + pad, max_y + pad], + world_origin: wo_hi, + world_origin_low: wo_lo, + angle_offset: h.angle_offset, + scale: h.scale.max(1e-6), + grad_cos, + grad_sin, + grad_min, + grad_range, + mode, + visible: 1, + boundary_offset, + boundary_count, + family_offset, + family_count, + draw_depth: h.draw_depth, + // Always resolve inside/outside per fragment on the GPU (the + // boundary is uploaded below). Dropping CPU pre-triangulation + // keeps the vertex buffer at 6 verts/hatch regardless of + // boundary complexity, so a hatch-dense drawing can't overflow + // the device buffer limit. Each instance draws its AABB quad. + poly_test: 1, + _pad1: 0, + _pad2: 0, + }); + } + // Empty fallbacks — storage buffers can't be zero-sized. + if boundary.is_empty() { + boundary.push([0.0; 4]); + } + if families.is_empty() { + families.push(LineFamilyGpu::default_filler()); + } + if dashes.is_empty() { + dashes.push(0.0); } - let max_spacing = match &model.pattern { - HatchPattern::Pattern(families) => families - .iter() - .map(|f| perp_spacing(f).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); - // ── Pattern phase anchor. - // - // Boundary verts are already stored as small f32 offsets from - // `model.world_origin` (an f64 anchor near the AABB centre), so - // the in_polygon ray-cast and the pattern math both run at full - // f32 precision in the fragment shader. The remaining question - // is *which* offset-rel point we hand to the vertex shader as - // `h.origin` (so the quad lands at the correct WCS position - // after `view_proj * (v.pos + h.origin)`). - // - // For pattern alignment across adjacent hatches with the same - // family, snap `h.origin` to the family's perp/along grid in - // f64. Without the snap, each hatch's pattern phase is per-hatch - // and stripes don't continue across hatch boundaries (you see - // pattern misalignment along a road etc). - let mut origin = model.world_origin; - if let HatchPattern::Pattern(families) = &model.pattern { - if let Some(fam) = families.first() { - // Must mirror the shader's pattern math exactly. QCAD PAT - // convention (matching build_family_batch): `dy` is the - // perpendicular spacing and `dx` is the along-line phase - // shift, both in family-local space. Multiplied by - // `model.scale` in the fragment shader. Combined angle - // (family + pattern_angle) rotates the perp/along axes. - let angle = (fam.angle_deg.to_radians() + model.angle_offset) as f64; - let cos_a = angle.cos(); - let sin_a = angle.sin(); - let scale = model.scale as f64; - let perp_step = fam.dy as f64 * scale; - let along_step = fam.dx as f64 * scale; - let o_perp = -origin[0] * sin_a + origin[1] * cos_a; - let o_along = origin[0] * cos_a + origin[1] * sin_a; - let snapped_perp = if perp_step.abs() > 1e-12 { - (o_perp / perp_step).round() * perp_step - } else { - o_perp - }; - let snapped_along = if along_step.abs() > 1e-12 { - (o_along / along_step).round() * along_step - } else { - o_along - }; - origin = [ - -snapped_perp * sin_a + snapped_along * cos_a, - snapped_perp * cos_a + snapped_along * sin_a, - ]; + // 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 }); } } - // Boundary buffer + quad now live in "snap-shifted" hatch-local - // coords: subtract any drift between model.world_origin and the - // snapped origin so the in_polygon ray-cast in fragment shader - // (which reads the boundary verts straight) lines up with v.xz. - let drift = [ - (model.world_origin[0] - origin[0]) as f32, - (model.world_origin[1] - origin[1]) as f32, - ]; - 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 vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch.vbuf"), - contents: bytemuck::cast_slice(&quad), + label: Some("hatch.vertex"), + contents: bytemuck::cast_slice(&verts), usage: wgpu::BufferUsages::VERTEX, }); - - // ── Gradient: projection range (in snapped-local space) ────────── - let (grad_min, grad_range) = if mode == 2 { - let projs: Vec = model - .boundary - .iter() - .filter(|v| v[0].is_finite() && v[1].is_finite()) - .map(|&[x, y]| (x + drift[0]) * grad_cos + (y + drift[1]) * grad_sin) - .collect(); - if projs.is_empty() { - (0.0, 1.0) - } else { - let proj_min = projs.iter().cloned().fold(f32::INFINITY, f32::min); - let proj_max = projs.iter().cloned().fold(f32::NEG_INFINITY, f32::max); - (proj_min, (proj_max - proj_min).max(1e-4)) - } - } else { - (0.0, 1.0) - }; - - // ── HatchUniformData ───────────────────────────────────────────── - let n = model.boundary.len().min(MAX_HATCH_BOUNDARY_VERTS); - let uniform_data = HatchUniformData { - color: model.color, - color2, - mode, - vertex_count: n as u32, - angle_offset: model.angle_offset, - scale: model.scale, - grad_cos, - grad_sin, - grad_min, - grad_range, - origin: [origin[0] as f32, origin[1] as f32], - origin_low: [ - (origin[0] - origin[0] as f32 as f64) as f32, - (origin[1] - origin[1] as f32 as f64) as f32, - ], - }; - - // ── BoundaryData (in snapped-local space) ──────────────────────── - let mut boundary_data = BoundaryData { - verts: [[0.0; 4]; MAX_HATCH_BOUNDARY_VERTS], - }; - for (i, &[x, y]) in model - .boundary - .iter() - .take(MAX_HATCH_BOUNDARY_VERTS) - .enumerate() - { - // Preserve NaN separators — they signal path breaks to the - // in_polygon ray-cast, must not be shifted into finite numbers. - if x.is_finite() && y.is_finite() { - boundary_data.verts[i] = [x + drift[0], y + drift[1], 0.0, 0.0]; - } else { - boundary_data.verts[i] = [f32::NAN, f32::NAN, 0.0, 0.0]; - } - } - - // ── FamilyBatchData ─────────────────────────────────────────────── - let family_batch = build_family_batch(&model.pattern); - - // ── GPU buffers ─────────────────────────────────────────────────── - let _uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch.uniforms"), - contents: bytemuck::bytes_of(&uniform_data), - usage: wgpu::BufferUsages::UNIFORM, + let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch.instances"), + contents: bytemuck::cast_slice(&instances), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, }); - let _boundary_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + let boundary_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("hatch.boundary"), - contents: bytemuck::bytes_of(&boundary_data), - usage: wgpu::BufferUsages::UNIFORM, + contents: bytemuck::cast_slice(&boundary), + usage: wgpu::BufferUsages::STORAGE, }); - let _family_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + let family_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("hatch.families"), - contents: bytemuck::bytes_of(&family_batch), - usage: wgpu::BufferUsages::UNIFORM, + contents: bytemuck::cast_slice(&families), + usage: wgpu::BufferUsages::STORAGE, + }); + let dash_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch.dashes"), + contents: bytemuck::cast_slice(&dashes), + usage: wgpu::BufferUsages::STORAGE, + }); + + let visibility: Vec = instances.iter().map(|i| i.visible).collect(); + let visibility_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch.visibility"), + contents: bytemuck::cast_slice(&visibility), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, }); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("hatch.bind_group1"), - layout: bgl1, + label: Some("hatch.bg"), + layout: bgl, entries: &[ wgpu::BindGroupEntry { binding: 0, - resource: _uniform_buf.as_entire_binding(), + resource: instance_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 1, - resource: _boundary_buf.as_entire_binding(), + resource: boundary_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 2, - resource: _family_buf.as_entire_binding(), + resource: family_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 3, + resource: dash_buffer.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 4, + resource: visibility_buffer.as_entire_binding(), }, ], }); - // Boundary vertices are stored as f32 offsets from - // `model.world_origin` (so f32 keeps precision at UTM-scale - // WCS). The pipeline-level frustum cull (Phase 2.3 - // `aabb_offscreen`) projects through `view_proj`, which expects - // local-space (world_offset-subtracted) coordinates — the same - // space as `world_origin`. Add the origin back so `world_aabb` - // is in that absolute local space rather than relative to the - // anchor. (Sub-pixel LOD via `aabb_below_pixel` is unaffected: - // it measures a projected diagonal, invariant under translation.) - let ox = model.world_origin[0] as f32; - let oy = model.world_origin[1] as f32; - let world_aabb = if min_x.is_finite() && min_y.is_finite() { - [min_x + ox, min_y + oy, max_x + ox, max_y + oy] - } else { - [min_x, min_y, max_x, max_y] - }; + // CPU AABB mirror — local-space rect with world_origin added + // back, ready for `aabb_offscreen` / `aabb_below_pixel` to + // compare against the camera view_proj. + let instance_aabbs: Vec<[f32; 4]> = instances + .iter() + .map(|i| { + let ox = i.world_origin[0]; + let oy = i.world_origin[1]; + [ + i.aabb[0] + ox, + i.aabb[1] + oy, + i.aabb[2] + ox, + i.aabb[3] + oy, + ] + }) + .collect(); - Self { + Some(Self { vertex_buffer, + vertex_count: verts.len() as u32, + instance_buffer, + boundary_buffer, + family_buffer, + dash_buffer, + visibility_buffer, bind_group, - vp_scissor: model.vp_scissor, - world_aabb, - _uniform_buf, - _boundary_buf, - _family_buf, + instance_count: instances.len() as u32, + visibility, + instance_aabbs, + }) + } + + /// Push the CPU `visibility` slice to GPU. Call when any + /// element changes (typically per-frame from compute_hatch_lod). + pub fn upload_visibility(&self, queue: &wgpu::Queue) { + queue.write_buffer( + &self.visibility_buffer, + 0, + bytemuck::cast_slice(&self.visibility), + ); + } + + /// Group-1 bind group layout — shared by the pipeline so it can be + /// constructed once at startup. All four bindings are read-only + /// storage and visible to both VS (AABB+visibility lookup) and FS + /// (boundary / family / dash sampling). + #[allow(dead_code)] + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + let entry = |binding: u32| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }; + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("hatch.bgl"), + entries: &[entry(0), entry(1), entry(2), entry(3), entry(4)], + }) + } +} + +impl LineFamilyGpu { + fn default_filler() -> Self { + Self { + cos_a: 1.0, + sin_a: 0.0, + x0: 0.0, + y0: 0.0, + dx: 1.0, + dy: 0.0, + perp_step: 1.0, + along_step: 1.0, + line_width: 0.0, + period: 0.0, + n_dashes: 0, + dash_offset: 0, } } } // ── Helpers ─────────────────────────────────────────────────────────────── -/// Perpendicular spacing between adjacent parallel lines. -/// QCAD PAT format stores (dx, dy) in line-local coordinates: dy = perpendicular, dx = along-line. -fn perp_spacing(f: &PatFamily) -> f32 { - f.dy -} - -/// Pack pattern families into the GPU batch struct. -fn build_family_batch(pattern: &HatchPattern) -> FamilyBatchData { - let mut batch = FamilyBatchData { - families: [LineFamilyGpu { - cos_a: 0.0, - sin_a: 0.0, - x0: 0.0, - y0: 0.0, - dx: 0.0, - dy: 0.0, - perp_step: 0.0, - along_step: 0.0, - line_width: 0.0, - period: 0.0, - n_dashes: 0, - dash_off: 0, - }; MAX_FAMILIES], - dash_values: [[0.0; 4]; 32], - n_families: 0, - _pad: [0; 3], - }; - - let HatchPattern::Pattern(families) = pattern else { - return batch; - }; - - let mut dash_cursor: usize = 0; - - for (fi, family) in families.iter().take(MAX_FAMILIES).enumerate() { - let angle_r = family.angle_deg.to_radians(); - let cos_a = angle_r.cos(); - let sin_a = angle_r.sin(); - // QCAD PAT local-frame convention: dy = perpendicular spacing, dx = along-line phase shift. - let perp_step = family.dy; - let along_step = family.dx; - let line_width = 0.0_f32; // unused: shader uses screen-space derivative for 1px lines - - // Dash pattern: collect up to available space. - let n_avail = MAX_DASHES.saturating_sub(dash_cursor); - let n_dashes = family.dashes.len().min(n_avail); - let period: f32 = family.dashes[..n_dashes].iter().map(|d| d.abs()).sum(); - - let dash_off = dash_cursor as u32; - for &d in &family.dashes[..n_dashes] { - batch.dash_values[dash_cursor / 4][dash_cursor % 4] = d; - dash_cursor += 1; - } - - batch.families[fi] = LineFamilyGpu { - cos_a, - sin_a, - x0: family.x0, - y0: family.y0, - dx: family.dx, - dy: family.dy, - perp_step, - along_step, - line_width, - period: if n_dashes > 0 { period } else { 0.0 }, - n_dashes: n_dashes as u32, - dash_off, - }; - batch.n_families += 1; - - if dash_cursor >= MAX_DASHES { - break; +/// Project every boundary vertex onto the gradient direction +/// `(cos θ, sin θ)` and return the (min, max) projection. Used to set +/// up the gradient's normalized parameter range. Same math as the +/// per-hatch path; duplicated here to keep this module self-contained. +fn boundary_projection_range(boundary: &[[f32; 2]], theta: f32) -> (f32, f32) { + let (cs, sn) = (theta.cos(), theta.sin()); + let mut lo = f32::INFINITY; + let mut hi = f32::NEG_INFINITY; + for &[x, y] in boundary { + if !x.is_finite() || !y.is_finite() { + continue; } + let p = x * cs + y * sn; + if p < lo { lo = p; } + if p > hi { hi = p; } } + if !lo.is_finite() { + return (0.0, 1.0); + } + (lo, hi) +} - batch +// PatFamily is re-exported by hatch_model so we don't need to import +// it explicitly anywhere else — but rust needs the type referenced to +// confirm the layout assumption above. +#[allow(dead_code)] +fn _assert_patfamily_fields(f: &PatFamily) -> (f32, f32, f32) { + (f.angle_deg, f.x0, f.y0) } diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 916189b2c..c7f49ae0e 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,6 +1,6 @@ pub mod face3d_gpu; -pub mod hatch_batched_gpu; pub mod hatch_gpu; +pub mod wipeout_gpu; pub mod image_gpu; pub mod mesh_gpu; pub mod text_gpu; @@ -13,7 +13,7 @@ use iced::wgpu::util::DeviceExt; use iced::{Rectangle, Size}; pub use face3d_gpu::Face3DGpu; -pub use hatch_gpu::HatchGpu; +pub use wipeout_gpu::WipeoutGpu; pub use image_gpu::ImageGpu; pub use mesh_gpu::MeshLodGpu; pub use uniforms::Uniforms; @@ -41,12 +41,12 @@ pub struct Pipeline { /// storage). Passed to `WireGpu::from_run` / `from_batch` so they can build /// each batch's per-wire bind group. wire_const_bgl: Option, - hatch_pipeline: wgpu::RenderPipeline, + wipeout_pipeline: wgpu::RenderPipeline, /// Phase 4-B — single-draw batched hatch pipeline. Per-instance /// data lives in storage buffers; one draw call covers every /// hatch in the frame. `None` on WebGL2 (wasm), which lacks /// vertex-stage storage buffers. - hatch_batched_pipeline: Option, + hatch_pipeline: Option, image_pipeline: wgpu::RenderPipeline, /// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the /// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts). @@ -72,10 +72,10 @@ pub struct Pipeline { face3d_depth_pipeline: wgpu::RenderPipeline, uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, - hatch_bgl1: wgpu::BindGroupLayout, + wipeout_bgl1: wgpu::BindGroupLayout, /// Group-1 layout for the batched hatch pipeline (storage buffers /// for instances / boundary / families / dashes). `None` on WebGL2. - hatch_batched_bgl1: Option, + hatch_bgl1: Option, image_bgl1: wgpu::BindGroupLayout, /// Group-1 layout for the text pipeline (atlas texture + sampler). text_atlas_bgl: wgpu::BindGroupLayout, @@ -125,14 +125,13 @@ pub struct Pipeline { /// frame they are present (small), drawn on top of the base wire pass — so /// a live drag never re-uploads the resident base buffer. gpu_preview_wires: Vec, - /// Phase 4-B — single batched-hatch GPU resource. Drawn in one - /// indexed call with per-instance visibility masking the rest. - /// Legacy per-hatch `Vec` + scissor / skip-flag plumbing - /// removed in step 5; the `hatch_pipeline` itself stays around to - /// serve the wipeout path which still uses `HatchGpu`. - gpu_hatch_batched: Option, - /// Wipeout fills — rendered after wires in a separate pass. - gpu_wipeouts: Vec, + /// The canonical hatch fills — a single batched GPU resource drawn in one + /// call with per-instance visibility masking the rest. All pattern line + /// families are uploaded (no cap). `None` on WebGL2. + gpu_hatch: Option, + /// Wipeout masks — solid fills rendered after wires in a separate pass via + /// the legacy per-primitive `WipeoutGpu` renderer. + gpu_wipeouts: Vec, /// Per-wipeout draw-time skip flag (Phase 2.3 frustum cull). `true` /// when the wipeout's projected AABB sits entirely outside the /// viewport rect. Recomputed by `compute_wipeout_lod`. @@ -431,8 +430,8 @@ impl Pipeline { }; let frag = wgpu::ShaderStages::FRAGMENT; let vert_frag = wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT; - let hatch_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("hatch.bgl1"), + let wipeout_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("wipeout.bgl1"), entries: &[ hatch_entry(0, vert_frag), hatch_entry(1, frag), @@ -440,19 +439,86 @@ impl Pipeline { ], }); + let wipeout_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("wipeout.pipeline_layout"), + bind_group_layouts: &[&frame_bgl, &wipeout_bgl1], + push_constant_ranges: &[], + }); + + let wipeout_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("wipeout.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/wipeout.wgsl" + ))), + }); + + let wipeout_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("wipeout.pipeline"), + layout: Some(&wipeout_layout), + vertex: wgpu::VertexState { + module: &wipeout_shader, + entry_point: Some("vs_main"), + buffers: &[wipeout_gpu::HatchVertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState { + constant: 1, + slope_scale: 1.0, + clamp: 0.0, + }, + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &wipeout_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + + // ── Hatch batched pipeline (Phase 4-B) ───────────────────────────── + // WebGL2 (the wasm fallback backend) has no vertex-stage storage + // buffers, which this pipeline's bind group requires. Skip it on wasm + // and run without the batched-hatch optimization. + #[cfg(target_arch = "wasm32")] + let (hatch_bgl1, hatch_pipeline): ( + Option, + Option, + ) = (None, None); + #[cfg(not(target_arch = "wasm32"))] + let (hatch_bgl1, hatch_pipeline) = { + let hatch_bgl1 = hatch_gpu::HatchGpu::bind_group_layout(device); let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("hatch.pipeline_layout"), bind_group_layouts: &[&frame_bgl, &hatch_bgl1], push_constant_ranges: &[], }); - let hatch_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("hatch.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( "../../shaders/hatch.wgsl" ))), }); - let hatch_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("hatch.pipeline"), layout: Some(&hatch_layout), @@ -496,74 +562,7 @@ impl Pipeline { multiview: None, cache: None, }); - - // ── Hatch batched pipeline (Phase 4-B) ───────────────────────────── - // WebGL2 (the wasm fallback backend) has no vertex-stage storage - // buffers, which this pipeline's bind group requires. Skip it on wasm - // and run without the batched-hatch optimization. - #[cfg(target_arch = "wasm32")] - let (hatch_batched_bgl1, hatch_batched_pipeline): ( - Option, - Option, - ) = (None, None); - #[cfg(not(target_arch = "wasm32"))] - let (hatch_batched_bgl1, hatch_batched_pipeline) = { - let hatch_batched_bgl1 = hatch_batched_gpu::HatchBatchedGpu::bind_group_layout(device); - let hatch_batched_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("hatch_batched.pipeline_layout"), - bind_group_layouts: &[&frame_bgl, &hatch_batched_bgl1], - push_constant_ranges: &[], - }); - let hatch_batched_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("hatch_batched.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/hatch_batched.wgsl" - ))), - }); - let hatch_batched_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("hatch_batched.pipeline"), - layout: Some(&hatch_batched_layout), - vertex: wgpu::VertexState { - module: &hatch_batched_shader, - entry_point: Some("vs_main"), - buffers: &[hatch_batched_gpu::HatchBatchedVertex::layout()], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }, - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - cull_mode: None, - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth32Float, - depth_write_enabled: true, - depth_compare: wgpu::CompareFunction::LessEqual, - stencil: wgpu::StencilState::default(), - bias: wgpu::DepthBiasState { - constant: 1, - slope_scale: 1.0, - clamp: 0.0, - }, - }), - multisample: wgpu::MultisampleState { - count: MSAA_SAMPLES, - mask: !0, - alpha_to_coverage_enabled: false, - }, - fragment: Some(wgpu::FragmentState { - module: &hatch_batched_shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }), - multiview: None, - cache: None, - }); - (Some(hatch_batched_bgl1), Some(hatch_batched_pipeline)) + (Some(hatch_bgl1), Some(hatch_pipeline)) }; // ── Mesh pipeline ────────────────────────────────────────────────── @@ -1152,8 +1151,8 @@ impl Pipeline { wire_const_bgl: Some(wire_const_bgl), #[cfg(target_arch = "wasm32")] wire_const_bgl: None, + wipeout_pipeline, hatch_pipeline, - hatch_batched_pipeline, image_pipeline, text_pipeline, text_atlas_bgl, @@ -1172,8 +1171,8 @@ impl Pipeline { face3d_depth_pipeline, uniform_buffer, uniform_bind_group, + wipeout_bgl1, hatch_bgl1, - hatch_batched_bgl1, image_bgl1, depth_texture_size: Size::new(1, 1), // (0, 0) forces the first `ensure_depth_texture` to allocate at the @@ -1192,7 +1191,7 @@ impl Pipeline { wire_pixel_scissors: vec![], gpu_selected_wires: vec![], gpu_preview_wires: vec![], - gpu_hatch_batched: None, + gpu_hatch: None, gpu_wipeouts: vec![], wipeout_skip_flags: vec![], wipeout_pixel_scissors: vec![], @@ -1408,7 +1407,7 @@ impl Pipeline { clip_w: u32, clip_h: u32, ) { - let Some(batch) = &mut self.gpu_hatch_batched else { + let Some(batch) = &mut self.gpu_hatch else { return; }; for (i, aabb) in batch.instance_aabbs.iter().enumerate() { @@ -1581,21 +1580,21 @@ impl Pipeline { pub fn upload_hatches(&mut self, device: &wgpu::Device, hatches: &[HatchModel]) { // No batched pipeline (WebGL2) → nothing to upload. - let Some(bgl1) = &self.hatch_batched_bgl1 else { - self.gpu_hatch_batched = None; + let Some(bgl1) = &self.hatch_bgl1 else { + self.gpu_hatch = None; return; }; let renderable: Vec = hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); - self.gpu_hatch_batched = - hatch_batched_gpu::HatchBatchedGpu::build(device, bgl1, &renderable); + self.gpu_hatch = + hatch_gpu::HatchGpu::build(device, bgl1, &renderable); } pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) { self.gpu_wipeouts = wipeouts .iter() .filter(|h| h.boundary.len() >= 3) - .map(|h| HatchGpu::new(device, h, &self.hatch_bgl1)) + .map(|h| WipeoutGpu::new(device, h, &self.wipeout_bgl1)) .collect(); } @@ -1733,7 +1732,7 @@ impl Pipeline { // (paper-space MSPACE) isn't ported to the batched path // yet — follow-up if it shows up as a visual issue. if let (Some(batch), Some(pipeline)) = - (&self.gpu_hatch_batched, &self.hatch_batched_pipeline) + (&self.gpu_hatch, &self.hatch_pipeline) { // Skipped while navigating (interaction LOD) — the per-pixel // hatch pass dominates the GPU frame on hatch-heavy drawings. @@ -2211,7 +2210,7 @@ impl Pipeline { occlusion_query_set: None, }); pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); - pass.set_pipeline(&self.hatch_pipeline); + pass.set_pipeline(&self.wipeout_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); let mut scissor_active = false; for (i, wipeout) in self.gpu_wipeouts.iter().enumerate() { @@ -2457,7 +2456,7 @@ fn aabb_diagonal_pixels( /// does, so the extra cost is negligible. /// /// IMPORTANT: the AABB must be in the same local space (world_offset -/// subtracted) that `view_proj` expects. `HatchGpu.world_aabb` rebuilds +/// subtracted) that `view_proj` expects. `WipeoutGpu.world_aabb` rebuilds /// the absolute local-space rect from `model.world_origin + boundary /// extents` for this reason; meshes already store an absolute rect. fn aabb_offscreen( diff --git a/src/scene/pipeline/wipeout_gpu.rs b/src/scene/pipeline/wipeout_gpu.rs new file mode 100644 index 000000000..b163fec1f --- /dev/null +++ b/src/scene/pipeline/wipeout_gpu.rs @@ -0,0 +1,472 @@ +// Wipeout GPU buffers — the legacy per-primitive fill renderer, now used ONLY +// for wipeout masks (solid fills drawn after wires to hide them). Real hatch +// fills go through the canonical `hatch_gpu.rs` batched renderer. +// +// It retains a general pattern/gradient capability (mode 0/2 below) and the +// `MAX_FAMILIES = 16` cap, but wipeouts are always solid (mode 1, zero pattern +// families), so the cap never truncates anything in practice. The family code +// is kept only as a latent non-batched fallback; do NOT read `MAX_FAMILIES` as +// a limit on visible hatch fills. +// +// Group 1 bindings per fill: +// binding 0 — HatchUniformData (64 bytes) mode, color, angle_offset, scale, gradient params +// binding 1 — BoundaryData (16384 bytes) boundary polygon vertices +// binding 2 — FamilyBatchData (1296 bytes) up to 16 line families + 128 dash values (unused for wipeouts) + +use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily, MAX_HATCH_BOUNDARY_VERTS}; +use iced::wgpu; +use iced::wgpu::util::DeviceExt; + +// ── Constants ────────────────────────────────────────────────────────────── + +pub const MAX_FAMILIES: usize = 16; +pub const MAX_DASHES: usize = 128; + +// ── Vertex ──────────────────────────────────────────────────────────────── + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct HatchVertex { + pub pos: [f32; 3], + pub _pad: f32, +} + +impl HatchVertex { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }], + } + } +} + +// ── Uniform structs ─────────────────────────────────────────────────────── + +/// Per-hatch parameters (binding 0). Must be 80 bytes (16-byte aligned). +/// +/// `mode` encoding: +/// 0 → Pattern (families in FamilyBatchData) +/// 1 → Solid +/// 2 → Gradient (grad_cos/sin/min/range used) +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct HatchUniformData { + pub color: [f32; 4], // 0: primary RGBA / gradient start + pub color2: [f32; 4], // 16: gradient end color + pub mode: u32, // 32: 0=pattern, 1=solid, 2=gradient + pub vertex_count: u32, // 36: boundary vertex count + pub angle_offset: f32, // 40: pattern rotation (radians) + pub scale: f32, // 44: pattern scale multiplier + pub grad_cos: f32, // 48: gradient direction cos + pub grad_sin: f32, // 52: gradient direction sin + pub grad_min: f32, // 56: gradient proj_min + pub grad_range: f32, // 60: gradient proj_range + pub origin: [f32; 2], // 64: hatch-local origin (boundary AABB centre), + // // high half of the double-single anchor. + // // The pattern fragment shader subtracts this + // // from xz before the perp/perp_step quotient + // // so f32 doesn't catastrophically cancel when + // // world coords are large and pattern spacing + // // is small (e.g. UTM drawing + sub-mm hatch). + pub origin_low: [f32; 2], // 72: anchor low residual (double-single) so the + // // vertex clip position stays precise at UTM scale. +} // total 80 bytes + +/// Boundary polygon (binding 1). Matches WGSL `Boundary`. +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct BoundaryData { + pub verts: [[f32; 4]; MAX_HATCH_BOUNDARY_VERTS], // world XY in .xy, .zw unused +} + +/// One line family packed for the shader (48 bytes). +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct LineFamilyGpu { + pub cos_a: f32, // cos(angle_rad) + pub sin_a: f32, // sin(angle_rad) + pub x0: f32, // family origin x + pub y0: f32, // family origin y + pub dx: f32, // step vector x + pub dy: f32, // step vector y + pub perp_step: f32, // -dx*sin_a + dy*cos_a (perpendicular spacing) + pub along_step: f32, // dx*cos_a + dy*sin_a (along-line phase shift per step) + pub line_width: f32, // |perp_step| * 0.08 + pub period: f32, // sum of |dash values| (0 = solid) + pub n_dashes: u32, // number of dash entries (0 = solid) + pub dash_off: u32, // index into FamilyBatchData::dash_values +} + +/// All line families + dash values for one hatch (binding 2). +/// Total size: 16×48 + 32×16 + 4×4 = 768 + 512 + 16 = 1296 bytes. +/// dash_values is [f32; 4]×32 so each element is vec4 (16-byte stride, valid in uniform). +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct FamilyBatchData { + pub families: [LineFamilyGpu; MAX_FAMILIES], // 768 bytes + pub dash_values: [[f32; 4]; 32], // 512 bytes (128 f32s as 32×vec4) + pub n_families: u32, // 4 bytes + pub _pad: [u32; 3], // 12 bytes +} // 1296 bytes total + +// ── Per-hatch GPU handle ─────────────────────────────────────────────────── + +pub struct WipeoutGpu { + pub vertex_buffer: wgpu::Buffer, + pub bind_group: wgpu::BindGroup, + /// Mirrors `HatchModel.vp_scissor`. Forwarded to the pipeline so each + /// frame's `compute_hatch_scissors` can translate the world rect into + /// a pixel scissor. + pub vp_scissor: Option<[f32; 4]>, + /// World-space XY bounding rect [min_x, min_y, max_x, max_y] of the + /// boundary polygon. Used by the per-frame LOD pass to skip hatches + /// whose entire footprint projects to less than ~2 px (Phase 3.3). + pub world_aabb: [f32; 4], + _uniform_buf: wgpu::Buffer, + _boundary_buf: wgpu::Buffer, + _family_buf: wgpu::Buffer, +} + +impl WipeoutGpu { + pub fn new(device: &wgpu::Device, model: &HatchModel, bgl1: &wgpu::BindGroupLayout) -> Self { + // ── Decode pattern mode ────────────────────────────────────────── + let (mode, color2, grad_cos, grad_sin) = match &model.pattern { + HatchPattern::Solid => (1u32, [0.0f32; 4], 0.0f32, 0.0f32), + HatchPattern::Pattern(_) => (0u32, [0.0f32; 4], 0.0f32, 0.0f32), + HatchPattern::Gradient { angle_deg, color2 } => { + let r = angle_deg.to_radians(); + (2u32, *color2, r.cos(), r.sin()) + } + }; + + // ── Bounding box ───────────────────────────────────────────────── + let (mut min_x, mut max_x, mut min_y, mut max_y) = ( + f32::INFINITY, + f32::NEG_INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + ); + for &[x, y] in model.boundary.iter() { + if !x.is_finite() || !y.is_finite() { + continue; + } + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + + let max_spacing = match &model.pattern { + HatchPattern::Pattern(families) => families + .iter() + .map(|f| perp_spacing(f).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); + // ── Pattern phase anchor. + // + // Boundary verts are already stored as small f32 offsets from + // `model.world_origin` (an f64 anchor near the AABB centre), so + // the in_polygon ray-cast and the pattern math both run at full + // f32 precision in the fragment shader. The remaining question + // is *which* offset-rel point we hand to the vertex shader as + // `h.origin` (so the quad lands at the correct WCS position + // after `view_proj * (v.pos + h.origin)`). + // + // For pattern alignment across adjacent hatches with the same + // family, snap `h.origin` to the family's perp/along grid in + // f64. Without the snap, each hatch's pattern phase is per-hatch + // and stripes don't continue across hatch boundaries (you see + // pattern misalignment along a road etc). + let mut origin = model.world_origin; + if let HatchPattern::Pattern(families) = &model.pattern { + if let Some(fam) = families.first() { + // Must mirror the shader's pattern math exactly. QCAD PAT + // convention (matching build_family_batch): `dy` is the + // perpendicular spacing and `dx` is the along-line phase + // shift, both in family-local space. Multiplied by + // `model.scale` in the fragment shader. Combined angle + // (family + pattern_angle) rotates the perp/along axes. + let angle = (fam.angle_deg.to_radians() + model.angle_offset) as f64; + let cos_a = angle.cos(); + let sin_a = angle.sin(); + let scale = model.scale as f64; + let perp_step = fam.dy as f64 * scale; + let along_step = fam.dx as f64 * scale; + let o_perp = -origin[0] * sin_a + origin[1] * cos_a; + let o_along = origin[0] * cos_a + origin[1] * sin_a; + let snapped_perp = if perp_step.abs() > 1e-12 { + (o_perp / perp_step).round() * perp_step + } else { + o_perp + }; + let snapped_along = if along_step.abs() > 1e-12 { + (o_along / along_step).round() * along_step + } else { + o_along + }; + origin = [ + -snapped_perp * sin_a + snapped_along * cos_a, + snapped_perp * cos_a + snapped_along * sin_a, + ]; + } + } + // Boundary buffer + quad now live in "snap-shifted" hatch-local + // coords: subtract any drift between model.world_origin and the + // snapped origin so the in_polygon ray-cast in fragment shader + // (which reads the boundary verts straight) lines up with v.xz. + let drift = [ + (model.world_origin[0] - origin[0]) as f32, + (model.world_origin[1] - origin[1]) as f32, + ]; + 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 vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch.vbuf"), + contents: bytemuck::cast_slice(&quad), + usage: wgpu::BufferUsages::VERTEX, + }); + + // ── Gradient: projection range (in snapped-local space) ────────── + let (grad_min, grad_range) = if mode == 2 { + let projs: Vec = model + .boundary + .iter() + .filter(|v| v[0].is_finite() && v[1].is_finite()) + .map(|&[x, y]| (x + drift[0]) * grad_cos + (y + drift[1]) * grad_sin) + .collect(); + if projs.is_empty() { + (0.0, 1.0) + } else { + let proj_min = projs.iter().cloned().fold(f32::INFINITY, f32::min); + let proj_max = projs.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + (proj_min, (proj_max - proj_min).max(1e-4)) + } + } else { + (0.0, 1.0) + }; + + // ── HatchUniformData ───────────────────────────────────────────── + let n = model.boundary.len().min(MAX_HATCH_BOUNDARY_VERTS); + let uniform_data = HatchUniformData { + color: model.color, + color2, + mode, + vertex_count: n as u32, + angle_offset: model.angle_offset, + scale: model.scale, + grad_cos, + grad_sin, + grad_min, + grad_range, + origin: [origin[0] as f32, origin[1] as f32], + origin_low: [ + (origin[0] - origin[0] as f32 as f64) as f32, + (origin[1] - origin[1] as f32 as f64) as f32, + ], + }; + + // ── BoundaryData (in snapped-local space) ──────────────────────── + let mut boundary_data = BoundaryData { + verts: [[0.0; 4]; MAX_HATCH_BOUNDARY_VERTS], + }; + for (i, &[x, y]) in model + .boundary + .iter() + .take(MAX_HATCH_BOUNDARY_VERTS) + .enumerate() + { + // Preserve NaN separators — they signal path breaks to the + // in_polygon ray-cast, must not be shifted into finite numbers. + if x.is_finite() && y.is_finite() { + boundary_data.verts[i] = [x + drift[0], y + drift[1], 0.0, 0.0]; + } else { + boundary_data.verts[i] = [f32::NAN, f32::NAN, 0.0, 0.0]; + } + } + + // ── FamilyBatchData ─────────────────────────────────────────────── + let family_batch = build_family_batch(&model.pattern); + + // ── GPU buffers ─────────────────────────────────────────────────── + let _uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch.uniforms"), + contents: bytemuck::bytes_of(&uniform_data), + usage: wgpu::BufferUsages::UNIFORM, + }); + let _boundary_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch.boundary"), + contents: bytemuck::bytes_of(&boundary_data), + usage: wgpu::BufferUsages::UNIFORM, + }); + let _family_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch.families"), + contents: bytemuck::bytes_of(&family_batch), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("hatch.bind_group1"), + layout: bgl1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: _uniform_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: _boundary_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: _family_buf.as_entire_binding(), + }, + ], + }); + + // Boundary vertices are stored as f32 offsets from + // `model.world_origin` (so f32 keeps precision at UTM-scale + // WCS). The pipeline-level frustum cull (Phase 2.3 + // `aabb_offscreen`) projects through `view_proj`, which expects + // local-space (world_offset-subtracted) coordinates — the same + // space as `world_origin`. Add the origin back so `world_aabb` + // is in that absolute local space rather than relative to the + // anchor. (Sub-pixel LOD via `aabb_below_pixel` is unaffected: + // it measures a projected diagonal, invariant under translation.) + let ox = model.world_origin[0] as f32; + let oy = model.world_origin[1] as f32; + let world_aabb = if min_x.is_finite() && min_y.is_finite() { + [min_x + ox, min_y + oy, max_x + ox, max_y + oy] + } else { + [min_x, min_y, max_x, max_y] + }; + + Self { + vertex_buffer, + bind_group, + vp_scissor: model.vp_scissor, + world_aabb, + _uniform_buf, + _boundary_buf, + _family_buf, + } + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +/// Perpendicular spacing between adjacent parallel lines. +/// QCAD PAT format stores (dx, dy) in line-local coordinates: dy = perpendicular, dx = along-line. +fn perp_spacing(f: &PatFamily) -> f32 { + f.dy +} + +/// Pack pattern families into the GPU batch struct. +fn build_family_batch(pattern: &HatchPattern) -> FamilyBatchData { + let mut batch = FamilyBatchData { + families: [LineFamilyGpu { + cos_a: 0.0, + sin_a: 0.0, + x0: 0.0, + y0: 0.0, + dx: 0.0, + dy: 0.0, + perp_step: 0.0, + along_step: 0.0, + line_width: 0.0, + period: 0.0, + n_dashes: 0, + dash_off: 0, + }; MAX_FAMILIES], + dash_values: [[0.0; 4]; 32], + n_families: 0, + _pad: [0; 3], + }; + + let HatchPattern::Pattern(families) = pattern else { + return batch; + }; + + let mut dash_cursor: usize = 0; + + for (fi, family) in families.iter().take(MAX_FAMILIES).enumerate() { + let angle_r = family.angle_deg.to_radians(); + let cos_a = angle_r.cos(); + let sin_a = angle_r.sin(); + // QCAD PAT local-frame convention: dy = perpendicular spacing, dx = along-line phase shift. + let perp_step = family.dy; + let along_step = family.dx; + let line_width = 0.0_f32; // unused: shader uses screen-space derivative for 1px lines + + // Dash pattern: collect up to available space. + let n_avail = MAX_DASHES.saturating_sub(dash_cursor); + let n_dashes = family.dashes.len().min(n_avail); + let period: f32 = family.dashes[..n_dashes].iter().map(|d| d.abs()).sum(); + + let dash_off = dash_cursor as u32; + for &d in &family.dashes[..n_dashes] { + batch.dash_values[dash_cursor / 4][dash_cursor % 4] = d; + dash_cursor += 1; + } + + batch.families[fi] = LineFamilyGpu { + cos_a, + sin_a, + x0: family.x0, + y0: family.y0, + dx: family.dx, + dy: family.dy, + perp_step, + along_step, + line_width, + period: if n_dashes > 0 { period } else { 0.0 }, + n_dashes: n_dashes as u32, + dash_off, + }; + batch.n_families += 1; + + if dash_cursor >= MAX_DASHES { + break; + } + } + + batch +} diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index 73c2a27db..b7e3c0383 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -1,16 +1,25 @@ -// Hatch shader — GPU-driven hatch fill over an arbitrary polygon boundary. +// 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). // -// Vertex shader : projects a bounding-box quad over the hatch region. -// Fragment shader: three stages — -// 1. Point-in-polygon (ray casting against boundary vertices in world XY). -// 2. Pattern test: -// mode 0 — N line families (PAT format), each with optional dash pattern. -// mode 1 — solid fill. -// mode 2 — linear gradient. +// Layout — matches `hatch_gpu.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[] // -// GPU limits: MAX_FAMILIES = 16, MAX_DASHES = 128. +// 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: frame uniforms (shared) ────────────────────────────────────── +// ── Group 0: shared frame uniforms (matches hatch.wgsl) ────────────────── struct Uniforms { viewport_size: vec2, @@ -28,94 +37,109 @@ struct Uniforms { } @group(0) @binding(0) var u: Uniforms; -// ── Group 1: per-hatch data ──────────────────────────────────────────────── -// -// mode encoding: -// 0 → Pattern (evaluate FamilyBatch) -// 1 → Solid (return h.color immediately) -// 2 → Gradient (mix h.color → h.color2 along grad_cos/grad_sin) +// ── Group 1: batched hatch storage ─────────────────────────────────────── -struct HatchUniforms { - color: vec4, // 0: primary RGBA - color2: vec4, // 16: gradient end color - mode: u32, // 32: 0=pattern, 1=solid, 2=gradient - vcount: u32, // 36: boundary vertex count - angle_offset: f32, // 40: pattern rotation (radians, added to each family) - scale: f32, // 44: pattern scale multiplier - grad_cos: f32, // 48: gradient direction cos - grad_sin: f32, // 52: gradient direction sin - grad_min: f32, // 56: gradient proj_min - grad_range: f32, // 60: gradient proj_range - origin: vec2, // 64: hatch-local pattern origin (boundary - // // centre). Pattern math runs against - // // `xz - origin` so the f32 modulo doesn't - // // catastrophically cancel when world coords - // // are large and pattern spacing is small. - origin_low: vec2, // 72: anchor low residual (double-single) +struct HatchInstance { + color: vec4, + color2: vec4, + aabb: vec4, // (xmin, ymin, xmax, ymax) — local space + world_origin: vec2, // anchor high half + world_origin_low: vec2, // anchor low residual (double-single) + angle_offset: f32, + scale: f32, + grad_cos: f32, + grad_sin: f32, + grad_min: f32, + 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 + _pad1: u32, + _pad2: u32, } -@group(1) @binding(0) var h: HatchUniforms; -struct Boundary { - verts: array, 1024>, -} -@group(1) @binding(1) var b: Boundary; +// Draw-order depth bias (see wire.wgsl). Higher draw_depth → smaller z → +// drawn on top, ordering this fill against other entity types. +const DRAW_ORDER_BIAS: f32 = 0.001; -// One line family (48 bytes, matches LineFamilyGpu in hatch_gpu.rs). struct LineFamily { - cos_a: f32, // base cos(angle) - sin_a: f32, // base sin(angle) - x0: f32, // family origin x - y0: f32, // family origin y - dx: f32, // step vector x - dy: f32, // step vector y - perp_step: f32, // -dx*sin_a + dy*cos_a (perpendicular spacing) - along_step: f32, // dx*cos_a + dy*sin_a (phase shift per step) - line_width: f32, // half-width of each line (|perp_step| × 0.08) - period: f32, // sum(|dashes|) — 0 means solid - n_dashes: u32, // number of dash entries - dash_off: u32, // start index in dash_values + cos_a: f32, + sin_a: f32, + x0: f32, + y0: f32, + dx: f32, + dy: f32, + perp_step: f32, + along_step: f32, + line_width: f32, + period: f32, + n_dashes: u32, + dash_offset: u32, } -struct FamilyBatch { - families: array, - dash_values: array, 32>, // 128 f32s packed as 32×vec4 (uniform stride=16) - n_families: u32, - _pad0: u32, _pad1: u32, _pad2: u32, +@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; +// 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; + +// ── Vertex shader ──────────────────────────────────────────────────────── + +struct VIn { + @location(0) local_xy: vec2, + @location(1) instance_index: u32, } -@group(1) @binding(2) var f: FamilyBatch; -// ── Vertex shader ────────────────────────────────────────────────────────── - -struct VIn { @location(0) pos: vec3 } struct VOut { - @builtin(position) clip: vec4, - @location(0) xz: vec2, + @builtin(position) clip: vec4, + @location(0) xz: vec2, + @location(1) @interpolate(flat) instance_index: u32, } @vertex fn vs_main(v: VIn) -> VOut { var o: VOut; - // v.pos comes in pre-shifted to hatch-local space (CPU subtracted - // h.origin from the quad). Add origin back inside the view_proj - // multiply to land at the correct world-space clip position. The - // f32+f32 addition can lose sub-mm precision at very far hatches - // but the result feeds view_proj for screen NDC where sub-pixel - // jitter is invisible. The local-space `xz` we export to the - // fragment shader stays at full f32 precision so the in_polygon / - // pattern math doesn't catastrophically cancel. - // Double-single relative-to-eye: anchor high cancels eye_high (Sterbenz); - // boundary-local v.pos + anchor low + (−eye_low) carry the residual. - let hi = vec3(h.origin.x - u.eye_high.x, - h.origin.y - u.eye_high.y, + let inst = instances[v.instance_index]; + + // Per-frame visibility (CPU-driven sub-pixel + frustum skip). + // 0 → emit a clip position whose x/y exceed |w| so the GPU + // frustum-culls the primitive and no fragment runs. (WGSL + // forbids literal NaN so this out-of-NDC trick replaces the + // usual NaN-degenerate-triangle.) + if visibility[v.instance_index] == 0u { + o.clip = vec4(2.0, 2.0, 2.0, 1.0); + o.xz = vec2(0.0, 0.0); + o.instance_index = v.instance_index; + return o; + } + + let local = v.local_xy; + // Double-single relative-to-eye: the anchor high half cancels exactly + // against eye_high (Sterbenz); local + anchor low + (−eye_low) carry the + // residual. `local` is small (boundary-relative), so adding it in the low + // term keeps full precision at UTM-scale anchors. + let hi = vec3(inst.world_origin.x - u.eye_high.x, + inst.world_origin.y - u.eye_high.y, -u.eye_high.z); - let lo = vec3(v.pos.x + h.origin_low.x - u.eye_low.x, - v.pos.y + h.origin_low.y - u.eye_low.y, - v.pos.z - u.eye_low.z); + let lo = vec3(local.x + inst.world_origin_low.x - u.eye_low.x, + local.y + inst.world_origin_low.y - u.eye_low.y, + -u.eye_low.z); o.clip = u.view_rot * vec4(hi + lo, 1.0); - o.xz = vec2(v.pos.x, v.pos.y); + o.clip.z = o.clip.z - inst.draw_depth * DRAW_ORDER_BIAS * o.clip.w; + o.xz = local; + o.instance_index = v.instance_index; return o; } -// ── Point-in-polygon (ray casting) ──────────────────────────────────────── +// ── Point-in-polygon (ray casting) over a sub-range of BoundaryBuffer ──── fn valid_vertex(p: vec2) -> bool { return p.x == p.x && p.y == p.y; @@ -129,14 +153,13 @@ fn edge_crosses(p: vec2, a: vec2, c: vec2) -> bool { return false; } -fn in_polygon(p: vec2) -> bool { +fn in_polygon(p: vec2, offset: u32, count: u32) -> 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 = b.verts[i].xy; + 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 @@ -164,11 +187,8 @@ fn in_polygon(p: vec2) -> bool { return inside; } -// ── Per-family hatch test ───────────────────────────────────────────────── -// -// Returns true if world point `xz` falls on a hatch line of `fam`. -// `cos_off` / `sin_off` are cos/sin of `h.angle_offset` (precomputed once). -// `scale` is `h.scale`. +// ── Per-family hatch test (same math as hatch.wgsl, dashes from +// global DashBuffer instead of per-hatch FamilyBatch) ──────────────────── fn check_family( xz: vec2, @@ -177,18 +197,15 @@ fn check_family( sin_off: f32, scale: f32, ) -> bool { - // Rotate family direction by angle_offset. let cos_a = fam.cos_a * cos_off - fam.sin_a * sin_off; let sin_a = fam.sin_a * cos_off + fam.cos_a * sin_off; - // Rotate and scale the family origin. let ox = (fam.x0 * cos_off - fam.y0 * sin_off) * scale; let oz = (fam.x0 * sin_off + fam.y0 * cos_off) * scale; let px = xz.x - ox; let pz = xz.y - oz; - // Perpendicular distance from the nearest parallel line. let perp_step = fam.perp_step * scale; let line_w = abs(fam.line_width * scale); @@ -199,18 +216,16 @@ fn check_family( let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; // World units per screen pixel on each axis — used to light exactly the - // one pixel that contains a dot's centre (pixel-snapped, so a dot stays a - // steady single pixel at any pattern angle instead of flickering). + // one pixel that contains a dot's centre (pixel-snapped, so the dot stays + // a steady single pixel at any pattern angle instead of flickering). let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); - // A fragment within ~1px of a line may be a dot; further out is empty fill. + // A fragment within ~1px of a line may be a dot; everything further out is + // empty fill. (A dot's pixel sits on a line, so its perp offset is < 1px.) if d > half_px * 2.0 { return false; } - - // Solid line family — no dash check needed. if fam.n_dashes == 0u { return d <= half_px; } - // Dash pattern test: position along line k. let along_step = fam.along_step * scale; let period = fam.period * scale; let along = px * cos_a + pz * sin_a; @@ -219,17 +234,17 @@ fn check_family( var pos = 0.0; for (var j = 0u; j < fam.n_dashes; j++) { - let idx = fam.dash_off + j; - let sv = f.dash_values[idx / 4u][idx % 4u] * scale; // scale dash lengths + let sv = dashes[fam.dash_offset + j] * scale; if sv > 0.0 { if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; } else if sv < 0.0 { pos = pos - sv; } else { - // Dot: signed distance to its lattice centre, rotated back to world - // and snapped to the pixel grid — the grid rotates with the - // pattern but the lit pixel stays a steady single pixel. + // Dot: signed distance to its lattice centre (along the line and + // across lines), rotated back to world, then snapped to the pixel + // grid. The dot grid rotates with the pattern; the lit pixel does + // not, so it never thins/flickers. let dtv = (t - pos) - round((t - pos) / period) * period; let owx = -dtv * cos_a + dperp * sin_a; let owy = -dtv * sin_a - dperp * cos_a; @@ -239,51 +254,50 @@ fn check_family( return false; } -// ── Fragment shader ──────────────────────────────────────────────────────── +// ── Fragment shader ────────────────────────────────────────────────────── @fragment fn fs_main(v: VOut) -> @location(0) vec4 { - // 1. Boundary test. - if !in_polygon(v.xz) { discard; } + let inst = instances[v.instance_index]; - // 2. Mode dispatch. - if h.mode == 1u { - // Solid fill. - return h.color; - } else if h.mode == 2u { - // Linear gradient. - let proj = v.xz.x * h.grad_cos + v.xz.y * h.grad_sin; - let t = clamp((proj - h.grad_min) / h.grad_range, 0.0, 1.0); - return mix(h.color, h.color2, t); + // 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; + } } - // 3. Pattern: LOD substitution — when the densest family's spacing - // projects to less than 2 px, individual lines blur into a solid - // fill and the per-family loop just wastes ALU. Return solid color - // instead. (Phase 3.3 hatch LOD.) - if u.world_per_pixel > 0.0 && f.n_families > 0u { + // 2. Mode dispatch. + if inst.mode == 1u { + return inst.color; + } else if inst.mode == 2u { + let proj = v.xz.x * inst.grad_cos + v.xz.y * inst.grad_sin; + let t = clamp((proj - inst.grad_min) / inst.grad_range, 0.0, 1.0); + return mix(inst.color, inst.color2, t); + } + + // 3. Pattern LOD: when the densest family's spacing projects below + // 2 px, lines blur into a solid fill — return color instead of + // iterating every family (mirrors Phase 3.3 LOD in hatch.wgsl). + if u.world_per_pixel > 0.0 && inst.family_count > 0u { var min_spacing_world: f32 = 1.0e30; - for (var i = 0u; i < f.n_families; i++) { - let s = abs(f.families[i].perp_step) * h.scale; + for (var i = 0u; i < inst.family_count; i++) { + let s = abs(families[inst.family_offset + i].perp_step) * inst.scale; if s > 0.0 && s < min_spacing_world { min_spacing_world = s; } } if min_spacing_world / u.world_per_pixel < 2.0 { - return h.color; + return inst.color; } } - // 4. Pattern: v.xz is already in hatch-local space (CPU pre-shifted - // quad + BoundaryData by h.origin). The PAT family origin - // (typically (0,0)) is treated as relative to h.origin, so - // pattern phase is per-hatch rather than world-aligned — that - // trade-off is the price of f32 precision at large drawing - // extents. - let cos_off = cos(h.angle_offset); - let sin_off = sin(h.angle_offset); - for (var i = 0u; i < f.n_families; i++) { - if check_family(v.xz, f.families[i], cos_off, sin_off, h.scale) { - return h.color; + // 4. 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++) { + if check_family(v.xz, families[inst.family_offset + i], cos_off, sin_off, inst.scale) { + return inst.color; } } discard; diff --git a/src/shaders/hatch_batched.wgsl b/src/shaders/hatch_batched.wgsl deleted file mode 100644 index 4c02d124b..000000000 --- a/src/shaders/hatch_batched.wgsl +++ /dev/null @@ -1,308 +0,0 @@ -// 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). -// -// Layout — matches `hatch_batched_gpu.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 0: shared frame uniforms (matches hatch.wgsl) ────────────────── - -struct Uniforms { - viewport_size: vec2, - world_per_pixel: f32, - lwdisplay_enable: f32, - flat_shade: f32, - transparency_enable: f32, - _pad: vec2, - // Relative-to-eye (double-single): see wire.wgsl. - view_rot: mat4x4, - eye_high: vec3, - _pad_eh: f32, - eye_low: vec3, - _pad_el: f32, -} -@group(0) @binding(0) var u: Uniforms; - -// ── Group 1: batched hatch storage ─────────────────────────────────────── - -struct HatchInstance { - color: vec4, - color2: vec4, - aabb: vec4, // (xmin, ymin, xmax, ymax) — local space - world_origin: vec2, // anchor high half - world_origin_low: vec2, // anchor low residual (double-single) - angle_offset: f32, - scale: f32, - grad_cos: f32, - grad_sin: f32, - grad_min: f32, - 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 - _pad1: u32, - _pad2: u32, -} - -// Draw-order depth bias (see wire.wgsl). Higher draw_depth → smaller z → -// drawn on top, ordering this fill against other entity types. -const DRAW_ORDER_BIAS: f32 = 0.001; - -struct LineFamily { - cos_a: f32, - sin_a: f32, - x0: f32, - y0: f32, - dx: f32, - dy: f32, - perp_step: f32, - along_step: f32, - line_width: f32, - period: f32, - n_dashes: u32, - dash_offset: u32, -} - -@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; -// 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; - -// ── Vertex shader ──────────────────────────────────────────────────────── - -struct VIn { - @location(0) local_xy: vec2, - @location(1) instance_index: u32, -} - -struct VOut { - @builtin(position) clip: vec4, - @location(0) xz: vec2, - @location(1) @interpolate(flat) instance_index: u32, -} - -@vertex fn vs_main(v: VIn) -> VOut { - var o: VOut; - let inst = instances[v.instance_index]; - - // Per-frame visibility (CPU-driven sub-pixel + frustum skip). - // 0 → emit a clip position whose x/y exceed |w| so the GPU - // frustum-culls the primitive and no fragment runs. (WGSL - // forbids literal NaN so this out-of-NDC trick replaces the - // usual NaN-degenerate-triangle.) - if visibility[v.instance_index] == 0u { - o.clip = vec4(2.0, 2.0, 2.0, 1.0); - o.xz = vec2(0.0, 0.0); - o.instance_index = v.instance_index; - return o; - } - - let local = v.local_xy; - // Double-single relative-to-eye: the anchor high half cancels exactly - // against eye_high (Sterbenz); local + anchor low + (−eye_low) carry the - // residual. `local` is small (boundary-relative), so adding it in the low - // term keeps full precision at UTM-scale anchors. - let hi = vec3(inst.world_origin.x - u.eye_high.x, - inst.world_origin.y - u.eye_high.y, - -u.eye_high.z); - let lo = vec3(local.x + inst.world_origin_low.x - u.eye_low.x, - local.y + inst.world_origin_low.y - u.eye_low.y, - -u.eye_low.z); - o.clip = u.view_rot * vec4(hi + lo, 1.0); - o.clip.z = o.clip.z - inst.draw_depth * DRAW_ORDER_BIAS * o.clip.w; - o.xz = local; - o.instance_index = v.instance_index; - return o; -} - -// ── Point-in-polygon (ray casting) over a sub-range of BoundaryBuffer ──── - -fn valid_vertex(p: vec2) -> bool { - return p.x == p.x && p.y == p.y; -} - -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) ──────────────────── - -fn check_family( - xz: vec2, - fam: LineFamily, - cos_off: f32, - sin_off: f32, - scale: f32, -) -> bool { - let cos_a = fam.cos_a * cos_off - fam.sin_a * sin_off; - let sin_a = fam.sin_a * cos_off + fam.cos_a * sin_off; - - let ox = (fam.x0 * cos_off - fam.y0 * sin_off) * scale; - let oz = (fam.x0 * sin_off + fam.y0 * cos_off) * scale; - - let px = xz.x - ox; - let pz = xz.y - oz; - - let perp_step = fam.perp_step * scale; - let line_w = abs(fam.line_width * scale); - - let perp = -px * sin_a + pz * cos_a; - let k = round(perp / perp_step); - let dperp = perp - k * perp_step; - let d = abs(dperp); - let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; - - // World units per screen pixel on each axis — used to light exactly the - // one pixel that contains a dot's centre (pixel-snapped, so the dot stays - // a steady single pixel at any pattern angle instead of flickering). - let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); - let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); - - // A fragment within ~1px of a line may be a dot; everything further out is - // empty fill. (A dot's pixel sits on a line, so its perp offset is < 1px.) - if d > half_px * 2.0 { return false; } - if fam.n_dashes == 0u { return d <= half_px; } - - let along_step = fam.along_step * scale; - let period = fam.period * scale; - let along = px * cos_a + pz * sin_a; - let t = along - k * along_step; - let t_mod = ((t % period) + period) % period; - - var pos = 0.0; - for (var j = 0u; j < fam.n_dashes; j++) { - let sv = dashes[fam.dash_offset + j] * scale; - if sv > 0.0 { - if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } - pos = pos + sv; - } else if sv < 0.0 { - pos = pos - sv; - } else { - // Dot: signed distance to its lattice centre (along the line and - // across lines), rotated back to world, then snapped to the pixel - // grid. The dot grid rotates with the pattern; the lit pixel does - // not, so it never thins/flickers. - let dtv = (t - pos) - round((t - pos) / period) * period; - let owx = -dtv * cos_a + dperp * sin_a; - let owy = -dtv * sin_a - dperp * cos_a; - if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } - } - } - return false; -} - -// ── Fragment shader ────────────────────────────────────────────────────── - -@fragment fn fs_main(v: VOut) -> @location(0) vec4 { - 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. - if inst.mode == 1u { - return inst.color; - } else if inst.mode == 2u { - let proj = v.xz.x * inst.grad_cos + v.xz.y * inst.grad_sin; - let t = clamp((proj - inst.grad_min) / inst.grad_range, 0.0, 1.0); - return mix(inst.color, inst.color2, t); - } - - // 3. Pattern LOD: when the densest family's spacing projects below - // 2 px, lines blur into a solid fill — return color instead of - // iterating every family (mirrors Phase 3.3 LOD in hatch.wgsl). - if u.world_per_pixel > 0.0 && inst.family_count > 0u { - var min_spacing_world: f32 = 1.0e30; - for (var i = 0u; i < inst.family_count; i++) { - let s = abs(families[inst.family_offset + i].perp_step) * inst.scale; - if s > 0.0 && s < min_spacing_world { - min_spacing_world = s; - } - } - if min_spacing_world / u.world_per_pixel < 2.0 { - return inst.color; - } - } - - // 4. 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++) { - if check_family(v.xz, families[inst.family_offset + i], cos_off, sin_off, inst.scale) { - return inst.color; - } - } - discard; - // Unreachable: `discard` kills the fragment before this runs, but - // DX12/FXC reports E_FAIL X3507 ("not all control paths return a - // value") without an explicit return after every terminal discard. - return vec4(0.0); -} diff --git a/src/shaders/wipeout.wgsl b/src/shaders/wipeout.wgsl new file mode 100644 index 000000000..8e689efd7 --- /dev/null +++ b/src/shaders/wipeout.wgsl @@ -0,0 +1,294 @@ +// Hatch shader — GPU-driven hatch fill over an arbitrary polygon boundary. +// +// Vertex shader : projects a bounding-box quad over the hatch region. +// Fragment shader: three stages — +// 1. Point-in-polygon (ray casting against boundary vertices in world XY). +// 2. Pattern test: +// mode 0 — N line families (PAT format), each with optional dash pattern. +// mode 1 — solid fill. +// mode 2 — linear gradient. +// +// GPU limits: MAX_FAMILIES = 16, MAX_DASHES = 128. + +// ── Group 0: frame uniforms (shared) ────────────────────────────────────── + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + _pad: vec2, + // Relative-to-eye (double-single): see wire.wgsl. + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +// ── Group 1: per-hatch data ──────────────────────────────────────────────── +// +// mode encoding: +// 0 → Pattern (evaluate FamilyBatch) +// 1 → Solid (return h.color immediately) +// 2 → Gradient (mix h.color → h.color2 along grad_cos/grad_sin) + +struct HatchUniforms { + color: vec4, // 0: primary RGBA + color2: vec4, // 16: gradient end color + mode: u32, // 32: 0=pattern, 1=solid, 2=gradient + vcount: u32, // 36: boundary vertex count + angle_offset: f32, // 40: pattern rotation (radians, added to each family) + scale: f32, // 44: pattern scale multiplier + grad_cos: f32, // 48: gradient direction cos + grad_sin: f32, // 52: gradient direction sin + grad_min: f32, // 56: gradient proj_min + grad_range: f32, // 60: gradient proj_range + origin: vec2, // 64: hatch-local pattern origin (boundary + // // centre). Pattern math runs against + // // `xz - origin` so the f32 modulo doesn't + // // catastrophically cancel when world coords + // // are large and pattern spacing is small. + origin_low: vec2, // 72: anchor low residual (double-single) +} +@group(1) @binding(0) var h: HatchUniforms; + +struct Boundary { + verts: array, 1024>, +} +@group(1) @binding(1) var b: Boundary; + +// One line family (48 bytes, matches LineFamilyGpu in wipeout_gpu.rs). +struct LineFamily { + cos_a: f32, // base cos(angle) + sin_a: f32, // base sin(angle) + x0: f32, // family origin x + y0: f32, // family origin y + dx: f32, // step vector x + dy: f32, // step vector y + perp_step: f32, // -dx*sin_a + dy*cos_a (perpendicular spacing) + along_step: f32, // dx*cos_a + dy*sin_a (phase shift per step) + line_width: f32, // half-width of each line (|perp_step| × 0.08) + period: f32, // sum(|dashes|) — 0 means solid + n_dashes: u32, // number of dash entries + dash_off: u32, // start index in dash_values +} + +struct FamilyBatch { + families: array, + dash_values: array, 32>, // 128 f32s packed as 32×vec4 (uniform stride=16) + n_families: u32, + _pad0: u32, _pad1: u32, _pad2: u32, +} +@group(1) @binding(2) var f: FamilyBatch; + +// ── Vertex shader ────────────────────────────────────────────────────────── + +struct VIn { @location(0) pos: vec3 } +struct VOut { + @builtin(position) clip: vec4, + @location(0) xz: vec2, +} + +@vertex fn vs_main(v: VIn) -> VOut { + var o: VOut; + // v.pos comes in pre-shifted to hatch-local space (CPU subtracted + // h.origin from the quad). Add origin back inside the view_proj + // multiply to land at the correct world-space clip position. The + // f32+f32 addition can lose sub-mm precision at very far hatches + // but the result feeds view_proj for screen NDC where sub-pixel + // jitter is invisible. The local-space `xz` we export to the + // fragment shader stays at full f32 precision so the in_polygon / + // pattern math doesn't catastrophically cancel. + // Double-single relative-to-eye: anchor high cancels eye_high (Sterbenz); + // boundary-local v.pos + anchor low + (−eye_low) carry the residual. + let hi = vec3(h.origin.x - u.eye_high.x, + h.origin.y - u.eye_high.y, + -u.eye_high.z); + let lo = vec3(v.pos.x + h.origin_low.x - u.eye_low.x, + v.pos.y + h.origin_low.y - u.eye_low.y, + v.pos.z - u.eye_low.z); + o.clip = u.view_rot * vec4(hi + lo, 1.0); + o.xz = vec2(v.pos.x, v.pos.y); + return o; +} + +// ── Point-in-polygon (ray casting) ──────────────────────────────────────── + +fn valid_vertex(p: vec2) -> bool { + return p.x == p.x && p.y == p.y; +} + +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 = b.verts[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 ───────────────────────────────────────────────── +// +// Returns true if world point `xz` falls on a hatch line of `fam`. +// `cos_off` / `sin_off` are cos/sin of `h.angle_offset` (precomputed once). +// `scale` is `h.scale`. + +fn check_family( + xz: vec2, + fam: LineFamily, + cos_off: f32, + sin_off: f32, + scale: f32, +) -> bool { + // Rotate family direction by angle_offset. + let cos_a = fam.cos_a * cos_off - fam.sin_a * sin_off; + let sin_a = fam.sin_a * cos_off + fam.cos_a * sin_off; + + // Rotate and scale the family origin. + let ox = (fam.x0 * cos_off - fam.y0 * sin_off) * scale; + let oz = (fam.x0 * sin_off + fam.y0 * cos_off) * scale; + + let px = xz.x - ox; + let pz = xz.y - oz; + + // Perpendicular distance from the nearest parallel line. + let perp_step = fam.perp_step * scale; + let line_w = abs(fam.line_width * scale); + + let perp = -px * sin_a + pz * cos_a; + let k = round(perp / perp_step); + let dperp = perp - k * perp_step; + let d = abs(dperp); + let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; + + // World units per screen pixel on each axis — used to light exactly the + // one pixel that contains a dot's centre (pixel-snapped, so a dot stays a + // steady single pixel at any pattern angle instead of flickering). + let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); + let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); + + // A fragment within ~1px of a line may be a dot; further out is empty fill. + if d > half_px * 2.0 { return false; } + + // Solid line family — no dash check needed. + if fam.n_dashes == 0u { return d <= half_px; } + + // Dash pattern test: position along line k. + let along_step = fam.along_step * scale; + let period = fam.period * scale; + let along = px * cos_a + pz * sin_a; + let t = along - k * along_step; + let t_mod = ((t % period) + period) % period; + + var pos = 0.0; + for (var j = 0u; j < fam.n_dashes; j++) { + let idx = fam.dash_off + j; + let sv = f.dash_values[idx / 4u][idx % 4u] * scale; // scale dash lengths + if sv > 0.0 { + if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } + pos = pos + sv; + } else if sv < 0.0 { + pos = pos - sv; + } else { + // Dot: signed distance to its lattice centre, rotated back to world + // and snapped to the pixel grid — the grid rotates with the + // pattern but the lit pixel stays a steady single pixel. + let dtv = (t - pos) - round((t - pos) / period) * period; + let owx = -dtv * cos_a + dperp * sin_a; + let owy = -dtv * sin_a - dperp * cos_a; + if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } + } + } + return false; +} + +// ── Fragment shader ──────────────────────────────────────────────────────── + +@fragment fn fs_main(v: VOut) -> @location(0) vec4 { + // 1. Boundary test. + if !in_polygon(v.xz) { discard; } + + // 2. Mode dispatch. + if h.mode == 1u { + // Solid fill. + return h.color; + } else if h.mode == 2u { + // Linear gradient. + let proj = v.xz.x * h.grad_cos + v.xz.y * h.grad_sin; + let t = clamp((proj - h.grad_min) / h.grad_range, 0.0, 1.0); + return mix(h.color, h.color2, t); + } + + // 3. Pattern: LOD substitution — when the densest family's spacing + // projects to less than 2 px, individual lines blur into a solid + // fill and the per-family loop just wastes ALU. Return solid color + // instead. (Phase 3.3 hatch LOD.) + if u.world_per_pixel > 0.0 && f.n_families > 0u { + var min_spacing_world: f32 = 1.0e30; + for (var i = 0u; i < f.n_families; i++) { + let s = abs(f.families[i].perp_step) * h.scale; + if s > 0.0 && s < min_spacing_world { + min_spacing_world = s; + } + } + if min_spacing_world / u.world_per_pixel < 2.0 { + return h.color; + } + } + + // 4. Pattern: v.xz is already in hatch-local space (CPU pre-shifted + // quad + BoundaryData by h.origin). The PAT family origin + // (typically (0,0)) is treated as relative to h.origin, so + // pattern phase is per-hatch rather than world-aligned — that + // trade-off is the price of f32 precision at large drawing + // extents. + let cos_off = cos(h.angle_offset); + let sin_off = sin(h.angle_offset); + for (var i = 0u; i < f.n_families; i++) { + if check_family(v.xz, f.families[i], cos_off, sin_off, h.scale) { + return h.color; + } + } + discard; + // Unreachable: `discard` kills the fragment before this runs, but + // DX12/FXC reports E_FAIL X3507 ("not all control paths return a + // value") without an explicit return after every terminal discard. + return vec4(0.0); +}