forked from 1b-agent/opencadstudio-upstream
refactor(pipeline): name the real hatch renderer hatch_gpu, legacy → wipeout_gpu
The batched path is the sole hatch renderer and has no family cap, yet it was named hatch_batched_gpu while the old capped per-primitive renderer kept the hatch_gpu / MAX_FAMILIES name — which reads as if visible hatch fills are truncated at 16 families (they are not). Swap the names so the active renderer is hatch_gpu.rs / hatch.wgsl and the legacy one, now used only for wipeout masks, is wipeout_gpu.rs / wipeout.wgsl. MAX_FAMILIES now lives only in the wipeout module. Pure rename; no behaviour change. Refs #323. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
8820b43127
commit
d37aacd895
7 files changed
+1504
-1492
No files matched your search
@@ -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<f32>[] (all boundary
|
||||
// verts concatenated; NaN markers
|
||||
// preserved as separators just like
|
||||
// the per-hatch path)
|
||||
// FamilyBuffer (binding 2) : LineFamilyGpu[] (all line
|
||||
// families concatenated)
|
||||
// DashBuffer (binding 3) : f32[] (all dash
|
||||
// lengths concatenated)
|
||||
//
|
||||
// The vertex buffer holds each hatch's tessellated boundary triangles as
|
||||
// per-vertex local-space positions (`local_xy`) plus a per-vertex
|
||||
// `instance_index` (u32 attribute) — instance_index lets us avoid relying
|
||||
// on `@builtin(instance_index)` for portability. An instance whose
|
||||
// boundary failed to tessellate instead gets an AABB quad here, with
|
||||
// `poly_test == 1` on its `HatchInstance` so the fragment shader still
|
||||
// runs the `in_polygon` test to clip the quad to the real shape; on the
|
||||
// tessellated fast path (`poly_test == 0`) the triangles already bound
|
||||
// the fill and the fragment shader skips that test. When the visibility
|
||||
// flag is 0, the vertex shader returns a degenerate position and the
|
||||
// fragment shader runs zero times for that instance.
|
||||
//
|
||||
// Two storage usages — vertex shader reads InstanceBuffer + Boundary
|
||||
// for the AABB / boundary range; fragment shader reads
|
||||
// InstanceBuffer + Boundary + Family + Dash. Both stages share group
|
||||
// 1 with `read_only` access.
|
||||
|
||||
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily};
|
||||
use 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::<HatchInstance>() == 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::<LineFamilyGpu>() == 48);
|
||||
|
||||
/// Per-vertex data — tessellated boundary triangles in local (world_origin-
|
||||
/// relative) space, each carrying its instance index (avoids relying on
|
||||
/// `@builtin(instance_index)` across backends). Instances whose boundary
|
||||
/// failed to tessellate emit an AABB quad here instead (see `build`).
|
||||
#[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::<HatchBatchedVertex>() 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<u32>,
|
||||
/// 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<Self> {
|
||||
if hatches.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut instances: Vec<HatchInstance> = Vec::with_capacity(hatches.len());
|
||||
let mut boundary: Vec<[f32; 4]> = Vec::new();
|
||||
let mut families: Vec<LineFamilyGpu> = Vec::new();
|
||||
let mut dashes: Vec<f32> = Vec::new();
|
||||
|
||||
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<HatchBatchedVertex> = 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<u32> = 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)
|
||||
}
|
||||
+494
-394
@@ -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<f32>[] (all boundary
|
||||
// verts concatenated; NaN markers
|
||||
// preserved as separators just like
|
||||
// the per-hatch path)
|
||||
// FamilyBuffer (binding 2) : LineFamilyGpu[] (all line
|
||||
// families concatenated)
|
||||
// DashBuffer (binding 3) : f32[] (all dash
|
||||
// lengths concatenated)
|
||||
//
|
||||
// The vertex buffer holds each hatch's tessellated boundary triangles as
|
||||
// per-vertex local-space positions (`local_xy`) plus a per-vertex
|
||||
// `instance_index` (u32 attribute) — instance_index lets us avoid relying
|
||||
// on `@builtin(instance_index)` for portability. An instance whose
|
||||
// boundary failed to tessellate instead gets an AABB quad here, with
|
||||
// `poly_test == 1` on its `HatchInstance` so the fragment shader still
|
||||
// runs the `in_polygon` test to clip the quad to the real shape; on the
|
||||
// tessellated fast path (`poly_test == 0`) the triangles already bound
|
||||
// the fill and the fragment shader skips that test. When the visibility
|
||||
// flag is 0, the vertex shader returns a degenerate position and the
|
||||
// fragment shader runs zero times for that instance.
|
||||
//
|
||||
// Two storage usages — vertex shader reads InstanceBuffer + Boundary
|
||||
// for the AABB / boundary range; fragment shader reads
|
||||
// InstanceBuffer + Boundary + Family + Dash. Both stages share group
|
||||
// 1 with `read_only` access.
|
||||
|
||||
use crate::scene::model::hatch_model::{HatchModel, HatchPattern, PatFamily, 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::<HatchInstance>() == 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::<LineFamilyGpu>() == 48);
|
||||
|
||||
/// Per-vertex data — tessellated boundary triangles in local (world_origin-
|
||||
/// relative) space, each carrying its instance index (avoids relying on
|
||||
/// `@builtin(instance_index)` across backends). Instances whose boundary
|
||||
/// failed to tessellate emit an AABB quad here instead (see `build`).
|
||||
#[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::<HatchVertex>() 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<f32> (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<u32>,
|
||||
/// 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<Self> {
|
||||
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<HatchInstance> = Vec::with_capacity(hatches.len());
|
||||
let mut boundary: Vec<[f32; 4]> = Vec::new();
|
||||
let mut families: Vec<LineFamilyGpu> = Vec::new();
|
||||
let mut dashes: Vec<f32> = Vec::new();
|
||||
|
||||
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<HatchVertex> = Vec::with_capacity(instances.len() * 6);
|
||||
for (i, inst) in instances.iter().enumerate() {
|
||||
let [xmin, ymin, xmax, ymax] = inst.aabb;
|
||||
let quad = [
|
||||
[xmin, ymin], [xmax, ymin], [xmin, ymax],
|
||||
[xmax, ymin], [xmax, ymax], [xmin, ymax],
|
||||
];
|
||||
for c in quad {
|
||||
verts.push(HatchVertex { local_xy: c, instance_index: i as u32 });
|
||||
}
|
||||
}
|
||||
// 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<f32> = 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<u32> = instances.iter().map(|i| i.visible).collect();
|
||||
let visibility_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("hatch.visibility"),
|
||||
contents: bytemuck::cast_slice(&visibility),
|
||||
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)
|
||||
}
|
||||
+97
-98
@@ -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<wgpu::BindGroupLayout>,
|
||||
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<wgpu::RenderPipeline>,
|
||||
hatch_pipeline: Option<wgpu::RenderPipeline>,
|
||||
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<wgpu::BindGroupLayout>,
|
||||
hatch_bgl1: Option<wgpu::BindGroupLayout>,
|
||||
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<WireGpu>,
|
||||
/// Phase 4-B — single batched-hatch GPU resource. Drawn in one
|
||||
/// indexed call with per-instance visibility masking the rest.
|
||||
/// Legacy per-hatch `Vec<HatchGpu>` + 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<hatch_batched_gpu::HatchBatchedGpu>,
|
||||
/// Wipeout fills — rendered after wires in a separate pass.
|
||||
gpu_wipeouts: Vec<HatchGpu>,
|
||||
/// 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<hatch_gpu::HatchGpu>,
|
||||
/// Wipeout masks — solid fills rendered after wires in a separate pass via
|
||||
/// the legacy per-primitive `WipeoutGpu` renderer.
|
||||
gpu_wipeouts: Vec<WipeoutGpu>,
|
||||
/// 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<wgpu::BindGroupLayout>,
|
||||
Option<wgpu::RenderPipeline>,
|
||||
) = (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<wgpu::BindGroupLayout>,
|
||||
Option<wgpu::RenderPipeline>,
|
||||
) = (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<HatchModel> =
|
||||
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(
|
||||
|
||||
@@ -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::<HatchVertex>() 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<f32> (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<f32> = 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
|
||||
}
|
||||
+147
-133
@@ -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<f32>[] (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<f32>,
|
||||
@@ -28,94 +37,109 @@ struct Uniforms {
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> 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<f32>, // 0: primary RGBA
|
||||
color2: vec4<f32>, // 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<f32>, // 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<f32>, // 72: anchor low residual (double-single)
|
||||
struct HatchInstance {
|
||||
color: vec4<f32>,
|
||||
color2: vec4<f32>,
|
||||
aabb: vec4<f32>, // (xmin, ymin, xmax, ymax) — local space
|
||||
world_origin: vec2<f32>, // anchor high half
|
||||
world_origin_low: vec2<f32>, // 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<uniform> h: HatchUniforms;
|
||||
|
||||
struct Boundary {
|
||||
verts: array<vec4<f32>, 1024>,
|
||||
}
|
||||
@group(1) @binding(1) var<uniform> 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<LineFamily, 16>,
|
||||
dash_values: array<vec4<f32>, 32>, // 128 f32s packed as 32×vec4 (uniform stride=16)
|
||||
n_families: u32,
|
||||
_pad0: u32, _pad1: u32, _pad2: u32,
|
||||
@group(1) @binding(0) var<storage, read> instances: array<HatchInstance>;
|
||||
@group(1) @binding(1) var<storage, read> boundary: array<vec4<f32>>;
|
||||
@group(1) @binding(2) var<storage, read> families: array<LineFamily>;
|
||||
@group(1) @binding(3) var<storage, read> dashes: array<f32>;
|
||||
// Per-instance visibility (Phase 4-B sub-pixel + frustum skip).
|
||||
// CPU writes `1` to draw / `0` to skip every frame; vertex shader
|
||||
// emits an out-of-NDC clip position for 0-instances so the GPU
|
||||
// rasterizer culls the primitive before any fragment runs.
|
||||
@group(1) @binding(4) var<storage, read> visibility: array<u32>;
|
||||
|
||||
// ── Vertex shader ────────────────────────────────────────────────────────
|
||||
|
||||
struct VIn {
|
||||
@location(0) local_xy: vec2<f32>,
|
||||
@location(1) instance_index: u32,
|
||||
}
|
||||
@group(1) @binding(2) var<uniform> f: FamilyBatch;
|
||||
|
||||
// ── Vertex shader ──────────────────────────────────────────────────────────
|
||||
|
||||
struct VIn { @location(0) pos: vec3<f32> }
|
||||
struct VOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) xz: vec2<f32>,
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) xz: vec2<f32>,
|
||||
@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<f32>(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<f32>(2.0, 2.0, 2.0, 1.0);
|
||||
o.xz = vec2<f32>(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<f32>(inst.world_origin.x - u.eye_high.x,
|
||||
inst.world_origin.y - u.eye_high.y,
|
||||
-u.eye_high.z);
|
||||
let lo = vec3<f32>(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<f32>(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<f32>(hi + lo, 1.0);
|
||||
o.xz = vec2<f32>(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<f32>) -> bool {
|
||||
return p.x == p.x && p.y == p.y;
|
||||
@@ -129,14 +153,13 @@ fn edge_crosses(p: vec2<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
fn in_polygon(p: vec2<f32>) -> bool {
|
||||
fn in_polygon(p: vec2<f32>, offset: u32, count: u32) -> bool {
|
||||
var inside = false;
|
||||
let n = h.vcount;
|
||||
var prev = vec2<f32>(0.0, 0.0);
|
||||
var first = vec2<f32>(0.0, 0.0);
|
||||
var have_prev = false;
|
||||
for (var i = 0u; i < n; i++) {
|
||||
let vi = 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<f32>) -> 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<f32>,
|
||||
@@ -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<f32>(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<f32>(dpdx(xz.x), dpdy(xz.x)));
|
||||
let wpy = length(vec2<f32>(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<f32> {
|
||||
// 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;
|
||||
|
||||
@@ -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<f32>[] (xy in .xy)
|
||||
// group 1 binding 2 FamilyBuffer LineFamilyGpu[] (48 B / fam)
|
||||
// group 1 binding 3 DashBuffer f32[]
|
||||
//
|
||||
// The vertex shader emits tessellated boundary triangles in local space
|
||||
// (an instance whose boundary failed to tessellate falls back to an
|
||||
// AABB quad with `poly_test == 1`, so the fragment shader still runs
|
||||
// `in_polygon` to clip it to the real shape). A `visible == 0` instance
|
||||
// gets an out-of-NDC clip position so the fragment shader never runs
|
||||
// for it — that's the GPU-side cull (Phase 4-B equivalent of
|
||||
// `compute_hatch_lod` writing `hatch_skip_flags`).
|
||||
|
||||
// ── Group 0: shared frame uniforms (matches hatch.wgsl) ──────────────────
|
||||
|
||||
struct Uniforms {
|
||||
viewport_size: vec2<f32>,
|
||||
world_per_pixel: f32,
|
||||
lwdisplay_enable: f32,
|
||||
flat_shade: f32,
|
||||
transparency_enable: f32,
|
||||
_pad: vec2<f32>,
|
||||
// Relative-to-eye (double-single): see wire.wgsl.
|
||||
view_rot: mat4x4<f32>,
|
||||
eye_high: vec3<f32>,
|
||||
_pad_eh: f32,
|
||||
eye_low: vec3<f32>,
|
||||
_pad_el: f32,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
// ── Group 1: batched hatch storage ───────────────────────────────────────
|
||||
|
||||
struct HatchInstance {
|
||||
color: vec4<f32>,
|
||||
color2: vec4<f32>,
|
||||
aabb: vec4<f32>, // (xmin, ymin, xmax, ymax) — local space
|
||||
world_origin: vec2<f32>, // anchor high half
|
||||
world_origin_low: vec2<f32>, // 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<storage, read> instances: array<HatchInstance>;
|
||||
@group(1) @binding(1) var<storage, read> boundary: array<vec4<f32>>;
|
||||
@group(1) @binding(2) var<storage, read> families: array<LineFamily>;
|
||||
@group(1) @binding(3) var<storage, read> dashes: array<f32>;
|
||||
// Per-instance visibility (Phase 4-B sub-pixel + frustum skip).
|
||||
// CPU writes `1` to draw / `0` to skip every frame; vertex shader
|
||||
// emits an out-of-NDC clip position for 0-instances so the GPU
|
||||
// rasterizer culls the primitive before any fragment runs.
|
||||
@group(1) @binding(4) var<storage, read> visibility: array<u32>;
|
||||
|
||||
// ── Vertex shader ────────────────────────────────────────────────────────
|
||||
|
||||
struct VIn {
|
||||
@location(0) local_xy: vec2<f32>,
|
||||
@location(1) instance_index: u32,
|
||||
}
|
||||
|
||||
struct VOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) xz: vec2<f32>,
|
||||
@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<f32>(2.0, 2.0, 2.0, 1.0);
|
||||
o.xz = vec2<f32>(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<f32>(inst.world_origin.x - u.eye_high.x,
|
||||
inst.world_origin.y - u.eye_high.y,
|
||||
-u.eye_high.z);
|
||||
let lo = vec3<f32>(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<f32>(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<f32>) -> bool {
|
||||
return p.x == p.x && p.y == p.y;
|
||||
}
|
||||
|
||||
fn edge_crosses(p: vec2<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
|
||||
if (a.y > p.y) != (c.y > p.y) {
|
||||
let x_int = (c.x - a.x) * (p.y - a.y) / (c.y - a.y) + a.x;
|
||||
return p.x < x_int;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn in_polygon(p: vec2<f32>, offset: u32, count: u32) -> bool {
|
||||
var inside = false;
|
||||
var prev = vec2<f32>(0.0, 0.0);
|
||||
var first = vec2<f32>(0.0, 0.0);
|
||||
var have_prev = false;
|
||||
for (var i = 0u; i < count; i++) {
|
||||
let vi = boundary[offset + i].xy;
|
||||
if !valid_vertex(vi) {
|
||||
// Close the sub-loop that just ended (last → first edge). An
|
||||
// unclosed boundary — e.g. a SOLID's 4 corners, which are not
|
||||
// repeated — otherwise miscounts crossings and the fill bleeds
|
||||
// outside the shape. (#140)
|
||||
if have_prev && edge_crosses(p, prev, first) {
|
||||
inside = !inside;
|
||||
}
|
||||
have_prev = false;
|
||||
continue;
|
||||
}
|
||||
if have_prev {
|
||||
if edge_crosses(p, prev, vi) {
|
||||
inside = !inside;
|
||||
}
|
||||
} else {
|
||||
first = vi;
|
||||
}
|
||||
prev = vi;
|
||||
have_prev = true;
|
||||
}
|
||||
if have_prev && edge_crosses(p, prev, first) {
|
||||
inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
// ── Per-family hatch test (same math as hatch.wgsl, dashes from
|
||||
// global DashBuffer instead of per-hatch FamilyBatch) ────────────────────
|
||||
|
||||
fn check_family(
|
||||
xz: vec2<f32>,
|
||||
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<f32>(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<f32>(dpdx(xz.x), dpdy(xz.x)));
|
||||
let wpy = length(vec2<f32>(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<f32> {
|
||||
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<f32>(0.0);
|
||||
}
|
||||
@@ -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<f32>,
|
||||
world_per_pixel: f32,
|
||||
lwdisplay_enable: f32,
|
||||
flat_shade: f32,
|
||||
transparency_enable: f32,
|
||||
_pad: vec2<f32>,
|
||||
// Relative-to-eye (double-single): see wire.wgsl.
|
||||
view_rot: mat4x4<f32>,
|
||||
eye_high: vec3<f32>,
|
||||
_pad_eh: f32,
|
||||
eye_low: vec3<f32>,
|
||||
_pad_el: f32,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> 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<f32>, // 0: primary RGBA
|
||||
color2: vec4<f32>, // 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<f32>, // 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<f32>, // 72: anchor low residual (double-single)
|
||||
}
|
||||
@group(1) @binding(0) var<uniform> h: HatchUniforms;
|
||||
|
||||
struct Boundary {
|
||||
verts: array<vec4<f32>, 1024>,
|
||||
}
|
||||
@group(1) @binding(1) var<uniform> 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<LineFamily, 16>,
|
||||
dash_values: array<vec4<f32>, 32>, // 128 f32s packed as 32×vec4 (uniform stride=16)
|
||||
n_families: u32,
|
||||
_pad0: u32, _pad1: u32, _pad2: u32,
|
||||
}
|
||||
@group(1) @binding(2) var<uniform> f: FamilyBatch;
|
||||
|
||||
// ── Vertex shader ──────────────────────────────────────────────────────────
|
||||
|
||||
struct VIn { @location(0) pos: vec3<f32> }
|
||||
struct VOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) xz: vec2<f32>,
|
||||
}
|
||||
|
||||
@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<f32>(h.origin.x - u.eye_high.x,
|
||||
h.origin.y - u.eye_high.y,
|
||||
-u.eye_high.z);
|
||||
let lo = vec3<f32>(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<f32>(hi + lo, 1.0);
|
||||
o.xz = vec2<f32>(v.pos.x, v.pos.y);
|
||||
return o;
|
||||
}
|
||||
|
||||
// ── Point-in-polygon (ray casting) ────────────────────────────────────────
|
||||
|
||||
fn valid_vertex(p: vec2<f32>) -> bool {
|
||||
return p.x == p.x && p.y == p.y;
|
||||
}
|
||||
|
||||
fn edge_crosses(p: vec2<f32>, a: vec2<f32>, c: vec2<f32>) -> bool {
|
||||
if (a.y > p.y) != (c.y > p.y) {
|
||||
let x_int = (c.x - a.x) * (p.y - a.y) / (c.y - a.y) + a.x;
|
||||
return p.x < x_int;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn in_polygon(p: vec2<f32>) -> bool {
|
||||
var inside = false;
|
||||
let n = h.vcount;
|
||||
var prev = vec2<f32>(0.0, 0.0);
|
||||
var first = vec2<f32>(0.0, 0.0);
|
||||
var have_prev = false;
|
||||
for (var i = 0u; i < n; i++) {
|
||||
let vi = 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<f32>,
|
||||
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<f32>(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<f32>(dpdx(xz.x), dpdy(xz.x)));
|
||||
let wpy = length(vec2<f32>(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<f32> {
|
||||
// 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<f32>(0.0);
|
||||
}
|
||||
Reference in new issue
Block a user