From fe202f5f8ee52fcb62534e70a2b3c116d27fa36f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 17 May 2026 19:45:26 +0300 Subject: [PATCH] feat(pipeline): batched-hatch GPU resources + builder (Phase 4-B, step 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand-alone module — no consumers yet. Packs an entire `&[HatchModel]` into four storage buffers + a per-vertex (corner, instance_index) stream: - `InstanceBuffer` — `HatchInstance[]` (112 B each): color, mode, gradient params, world_origin, local-space aabb, boundary range, family range, visibility flag. - `BoundaryBuffer` — flat `vec4[]` (NaN markers preserved as per-path separators, just like the per-hatch shader expects). - `FamilyBuffer` — flat `LineFamilyGpu[]` (matches the per-hatch family layout one-for-one apart from `dash_offset` indexing into a separate dash buffer). - `DashBuffer` — flat `f32[]`. The per-vertex buffer holds `6 × instance_count` records of `(corner: u32, instance_index: u32)`. Step 2 will add the matching WGSL shader that uses these to project a per-instance AABB quad and run the in-polygon test against `BoundaryBuffer[offset..offset+count]`. iced reports 8 storage buffers per stage, 128 MB binding cap, 256 MB buffer cap — plenty for ~10 k hatches × 112 B + ~32 MB boundaries. Co-Authored-By: Claude Opus 4.7 --- src/scene/pipeline/hatch_batched_gpu.rs | 456 ++++++++++++++++++++++++ src/scene/pipeline/mod.rs | 1 + 2 files changed, 457 insertions(+) create mode 100644 src/scene/pipeline/hatch_batched_gpu.rs diff --git a/src/scene/pipeline/hatch_batched_gpu.rs b/src/scene/pipeline/hatch_batched_gpu.rs new file mode 100644 index 000000000..17c4bbe6d --- /dev/null +++ b/src/scene/pipeline/hatch_batched_gpu.rs @@ -0,0 +1,456 @@ +// 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[] (~96 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 6 corner indices repeated per-instance and a +// per-vertex `instance_index` (u32 attribute) — instance_index lets us +// avoid relying on `@builtin(instance_index)` for portability. The +// vertex shader reads the per-instance AABB from the InstanceBuffer +// and emits the quad corner for that instance. When the visibility +// flag is 0, it 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::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 added back in VS) + pub angle_offset: f32, // 56 + pub scale: f32, // 60 + pub grad_cos: f32, // 64 + pub grad_sin: f32, // 68 + pub grad_min: f32, // 72 + pub grad_range: f32, // 76 + pub mode: u32, // 80 (0=pattern, 1=solid, 2=gradient) + pub visible: u32, // 84 (CPU sets to 0 to skip) + pub boundary_offset: u32, // 88 (first boundary vert index) + pub boundary_count: u32, // 92 + pub family_offset: u32, // 96 + pub family_count: u32, // 100 + pub _pad0: [u32; 2], // 104 (pad to 112 = 16-byte stride) +} + +const _: () = assert!(std::mem::size_of::() == 112); + +/// 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 — 6 verts per instance. Instance_index here so we +/// can match WebGL2 backends that don't expose builtin(instance_index) +/// uniformly. +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct HatchBatchedVertex { + pub corner: u32, // 0..6 — which AABB corner + pub instance_index: u32, // 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::Uint32, + }, + wgpu::VertexAttribute { + offset: 4, + 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, + pub instance_buffer: wgpu::Buffer, + pub boundary_buffer: wgpu::Buffer, + pub family_buffer: wgpu::Buffer, + pub dash_buffer: wgpu::Buffer, + pub bind_group: wgpu::BindGroup, + pub instance_count: u32, + /// CPU-side mirror of `HatchInstance.visible` — `compute_hatch_lod` + /// writes 0/1 here, then `upload_visibility` pushes the changed + /// `visible` field through to the GPU `instance_buffer`. Storing + /// just the flag (not the whole HatchInstance) keeps the per-frame + /// upload small. + pub visibility: Vec, +} + +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); + let perp_step = -fam.dx * fam.angle_deg.to_radians().sin() + + fam.dy * fam.angle_deg.to_radians().cos(); + let along_step = fam.dx * fam.angle_deg.to_radians().cos() + + fam.dy * fam.angle_deg.to_radians().sin(); + let line_width = perp_step.abs() * 0.08; + 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, + 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). + instances.push(HatchInstance { + color: h.color, + color2, + aabb: [0.0, 0.0, 0.0, 0.0], + world_origin: h.world_origin.map(|v| v as f32), + 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, + _pad0: [0; 2], + }); + 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(); + let max_spacing = match &h.pattern { + HatchPattern::Pattern(fs) => fs + .iter() + .map(|f| { + let a = f.angle_deg.to_radians(); + (-f.dx * a.sin() + f.dy * a.cos()).abs() + }) + .fold(0.0f32, f32::max), + _ => 5.0, + }; + let pad = (diag * 0.8 + max_spacing * 2.0 * h.scale).max(1.0); + + instances.push(HatchInstance { + color: h.color, + color2, + aabb: [min_x - pad, min_y - pad, max_x + pad, max_y + pad], + world_origin: h.world_origin.map(|v| v as f32), + 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, + _pad0: [0; 2], + }); + } + + // 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 — 6 verts per instance (two triangles for the + // AABB quad), indexed by (corner, instance_index). + let mut verts: Vec = Vec::with_capacity(instances.len() * 6); + for (i, _) in instances.iter().enumerate() { + for corner in 0u32..6 { + verts.push(HatchBatchedVertex { + corner, + 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 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(), + }, + ], + }); + + let visibility = instances.iter().map(|i| i.visible).collect(); + Some(Self { + vertex_buffer, + vertex_count: verts.len() as u32, + instance_buffer, + boundary_buffer, + family_buffer, + dash_buffer, + bind_group, + instance_count: instances.len() as u32, + 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). + 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)], + }) + } +} + +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/mod.rs b/src/scene/pipeline/mod.rs index e9d7c1275..27f9931c8 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,4 +1,5 @@ pub mod face3d_gpu; +pub mod hatch_batched_gpu; pub mod hatch_gpu; pub mod image_gpu; pub mod mesh_gpu;