forked from 1b-agent/opencadstudio-upstream
refactor(hatch): unify capability backends
Keep storage and texture transports behind one renderer lifecycle so Pipeline no longer branches by backend.
This commit is contained in:
1 parent
dc7d03dbe1
commit
d80637cda7
7 files changed
+385
-322
No files matched your search
@@ -0,0 +1,287 @@
|
||||
//! Capability-selected hatch renderer.
|
||||
//!
|
||||
//! `Pipeline` sees one [`HatchGpu`]. This module owns the renderer selection,
|
||||
//! render pipeline, resident/preview uploads, visibility updates, and draw
|
||||
//! dispatch. The storage and texture transports remain private implementation
|
||||
//! details because their bind groups and upload layouts are fundamentally
|
||||
//! different.
|
||||
|
||||
mod storage;
|
||||
mod texture;
|
||||
|
||||
use super::device_capabilities::DeviceCapabilities;
|
||||
use crate::scene::model::hatch_model::HatchModel;
|
||||
use iced::wgpu;
|
||||
|
||||
use storage::StorageHatchBatch;
|
||||
use texture::TextureHatch;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum HatchBackendKind {
|
||||
Storage,
|
||||
Texture,
|
||||
}
|
||||
|
||||
impl HatchBackendKind {
|
||||
fn select(capabilities: DeviceCapabilities, force_compatibility: bool) -> Self {
|
||||
if !force_compatibility && capabilities.supports_batched_hatch() {
|
||||
Self::Storage
|
||||
} else {
|
||||
Self::Texture
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HatchBackend {
|
||||
Storage {
|
||||
resident: Option<StorageHatchBatch>,
|
||||
preview: Option<StorageHatchBatch>,
|
||||
},
|
||||
Texture {
|
||||
resident: Vec<TextureHatch>,
|
||||
preview: Vec<TextureHatch>,
|
||||
},
|
||||
}
|
||||
|
||||
/// One hatch renderer whose transport is selected from the active device.
|
||||
pub struct HatchGpu {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
bind_group_layout: wgpu::BindGroupLayout,
|
||||
backend: HatchBackend,
|
||||
}
|
||||
|
||||
impl HatchGpu {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
format: wgpu::TextureFormat,
|
||||
frame_bind_group_layout: &wgpu::BindGroupLayout,
|
||||
content_stencil: &wgpu::StencilState,
|
||||
capabilities: DeviceCapabilities,
|
||||
force_compatibility: bool,
|
||||
) -> Self {
|
||||
let backend_kind = HatchBackendKind::select(capabilities, force_compatibility);
|
||||
let uses_storage = backend_kind == HatchBackendKind::Storage;
|
||||
let bind_group_layout = if uses_storage {
|
||||
StorageHatchBatch::bind_group_layout(device)
|
||||
} else {
|
||||
TextureHatch::bind_group_layout(device)
|
||||
};
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("hatch.pipeline_layout"),
|
||||
bind_group_layouts: &[frame_bind_group_layout, &bind_group_layout],
|
||||
push_constant_ranges: &[],
|
||||
});
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some(if uses_storage {
|
||||
"hatch.storage.shader"
|
||||
} else {
|
||||
"hatch.texture.shader"
|
||||
}),
|
||||
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(if uses_storage {
|
||||
include_str!("../../../shaders/hatch.wgsl")
|
||||
} else {
|
||||
include_str!("../../../shaders/hatch_texture.wgsl")
|
||||
})),
|
||||
});
|
||||
let vertex_layout = if uses_storage {
|
||||
storage::HatchVertex::layout()
|
||||
} else {
|
||||
texture::vertex_layout()
|
||||
};
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(if uses_storage {
|
||||
"hatch.storage.pipeline"
|
||||
} else {
|
||||
"hatch.texture.pipeline"
|
||||
}),
|
||||
layout: Some(&pipeline_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
buffers: &[vertex_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::Depth24PlusStencil8,
|
||||
depth_write_enabled: true,
|
||||
depth_compare: wgpu::CompareFunction::LessEqual,
|
||||
stencil: content_stencil.clone(),
|
||||
bias: wgpu::DepthBiasState {
|
||||
constant: 1,
|
||||
slope_scale: 1.0,
|
||||
clamp: 0.0,
|
||||
},
|
||||
}),
|
||||
multisample: wgpu::MultisampleState {
|
||||
count: super::MSAA_SAMPLES,
|
||||
mask: !0,
|
||||
alpha_to_coverage_enabled: false,
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &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,
|
||||
});
|
||||
let backend = match backend_kind {
|
||||
HatchBackendKind::Storage => HatchBackend::Storage {
|
||||
resident: None,
|
||||
preview: None,
|
||||
},
|
||||
HatchBackendKind::Texture => HatchBackend::Texture {
|
||||
resident: Vec::new(),
|
||||
preview: Vec::new(),
|
||||
},
|
||||
};
|
||||
Self {
|
||||
pipeline,
|
||||
bind_group_layout,
|
||||
backend,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn backend_name(&self) -> &'static str {
|
||||
match self.backend {
|
||||
HatchBackend::Storage { .. } => "storage",
|
||||
HatchBackend::Texture { .. } => "texture",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upload(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, hatches: &[HatchModel]) {
|
||||
match &mut self.backend {
|
||||
HatchBackend::Storage { resident, .. } => {
|
||||
let renderable: Vec<HatchModel> = hatches
|
||||
.iter()
|
||||
.filter(|hatch| hatch.boundary.len() >= 3)
|
||||
.cloned()
|
||||
.collect();
|
||||
*resident = StorageHatchBatch::build(device, &self.bind_group_layout, &renderable);
|
||||
}
|
||||
HatchBackend::Texture { resident, .. } => {
|
||||
*resident = hatches
|
||||
.iter()
|
||||
.filter(|hatch| hatch.boundary.len() >= 3)
|
||||
.map(|hatch| TextureHatch::new(device, queue, hatch, &self.bind_group_layout))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn upload_preview(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
hatches: &[HatchModel],
|
||||
) {
|
||||
match &mut self.backend {
|
||||
HatchBackend::Storage { preview, .. } => {
|
||||
let renderable: Vec<HatchModel> = hatches
|
||||
.iter()
|
||||
.filter(|hatch| hatch.boundary.len() >= 3)
|
||||
.cloned()
|
||||
.collect();
|
||||
*preview = StorageHatchBatch::build(device, &self.bind_group_layout, &renderable);
|
||||
}
|
||||
HatchBackend::Texture { preview, .. } => {
|
||||
*preview = hatches
|
||||
.iter()
|
||||
.filter(|hatch| hatch.boundary.len() >= 3)
|
||||
.map(|hatch| TextureHatch::new(device, queue, hatch, &self.bind_group_layout))
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh resident hatch visibility. Texture hatches retain their existing
|
||||
/// per-draw behavior and return zero because they have no visibility buffer.
|
||||
pub fn update_visibility(
|
||||
&mut self,
|
||||
queue: &wgpu::Queue,
|
||||
mut is_visible: impl FnMut([f32; 4]) -> bool,
|
||||
) -> usize {
|
||||
let HatchBackend::Storage {
|
||||
resident: Some(batch),
|
||||
..
|
||||
} = &mut self.backend
|
||||
else {
|
||||
return 0;
|
||||
};
|
||||
for (index, aabb) in batch.instance_aabbs.iter().copied().enumerate() {
|
||||
batch.visibility[index] = u32::from(is_visible(aabb));
|
||||
}
|
||||
batch.upload_visibility(queue);
|
||||
batch.instance_aabbs.len()
|
||||
}
|
||||
|
||||
pub fn draw<'pass>(
|
||||
&'pass self,
|
||||
pass: &mut wgpu::RenderPass<'pass>,
|
||||
frame_bind_group: &'pass wgpu::BindGroup,
|
||||
stencil_reference: u32,
|
||||
) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, frame_bind_group, &[]);
|
||||
pass.set_stencil_reference(stencil_reference);
|
||||
match &self.backend {
|
||||
HatchBackend::Storage { resident, preview } => {
|
||||
for batch in [resident.as_ref(), preview.as_ref()].into_iter().flatten() {
|
||||
pass.set_bind_group(1, &batch.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
|
||||
pass.draw(0..batch.vertex_count, 0..1);
|
||||
}
|
||||
}
|
||||
HatchBackend::Texture { resident, preview } => {
|
||||
for hatch in resident.iter().chain(preview) {
|
||||
pass.set_bind_group(1, &hatch.bind_group, &[]);
|
||||
pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..));
|
||||
pass.draw(0..6, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DeviceCapabilities, HatchBackendKind};
|
||||
|
||||
fn capabilities(storage_bindings: u32) -> DeviceCapabilities {
|
||||
DeviceCapabilities {
|
||||
max_storage_buffers_per_shader_stage: storage_bindings,
|
||||
max_inter_stage_shader_components: 31,
|
||||
max_vertex_attributes: 16,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_backend_from_device_limits() {
|
||||
assert_eq!(
|
||||
HatchBackendKind::select(capabilities(5), false),
|
||||
HatchBackendKind::Storage
|
||||
);
|
||||
assert_eq!(
|
||||
HatchBackendKind::select(capabilities(4), false),
|
||||
HatchBackendKind::Texture
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatibility_override_selects_texture_backend() {
|
||||
assert_eq!(
|
||||
HatchBackendKind::select(capabilities(8), true),
|
||||
HatchBackendKind::Texture
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
// Hatch rendering — the single, canonical GPU hatch renderer. One draw
|
||||
// call for all hatches, per-instance data fetched from storage buffers in
|
||||
// the vertex shader. There is NO per-family cap here: every pattern line
|
||||
// family is uploaded (`family_count` per instance) and the fragment shader
|
||||
// loops over all of them, so complex baked patterns (ROOFING, GRAVEL,
|
||||
// CAROLINA-LEDGESTONE with dozens–hundreds of families) draw in full,
|
||||
// matching the PDF export. Unavailable on devices that lack vertex-stage
|
||||
// storage buffers. The old per-hatch renderer with the
|
||||
// 16-family cap now lives in `wipeout_gpu.rs` and serves only wipeouts.
|
||||
//
|
||||
// Data layout — three storage buffers fed from `HatchModel`s:
|
||||
//
|
||||
// InstanceBuffer (binding 0) : HatchInstance[] (128 B each)
|
||||
// color, color2, mode, gradient,
|
||||
// pattern angle/scale, world_origin,
|
||||
// boundary_offset, boundary_count,
|
||||
// family_offset, family_count,
|
||||
// dash_offset, dash_count, aabb,
|
||||
// visibility flag (CPU writes; GPU
|
||||
// skip)
|
||||
// BoundaryBuffer (binding 1) : vec4<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
|
||||
/// Gradient shape (`GradientKind::shader_kind`), bit 4 = inverted stops.
|
||||
pub grad_kind: 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(super) struct HatchVertex {
|
||||
pub local_xy: [f32; 2], // 0 — local-space position
|
||||
pub instance_index: u32, // 8 — index into InstanceBuffer
|
||||
}
|
||||
|
||||
impl HatchVertex {
|
||||
pub(super) 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::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.wgsl`.
|
||||
/// Returns `None` when the input slice is empty (caller skips the
|
||||
/// hatch render pass entirely).
|
||||
pub(super) struct StorageHatchBatch {
|
||||
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 StorageHatchBatch {
|
||||
/// One-time build from the full hatch list. Re-uploaded only when
|
||||
/// `geometry_epoch` advances (mirrors the existing per-hatch
|
||||
/// upload trigger). Per-frame visibility flips go through
|
||||
/// [`upload_visibility`].
|
||||
pub(super) 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() {
|
||||
// NaN sub-loop separators become the finite sentinel on the
|
||||
// GPU: Intel drivers fold the shader NaN self-compare to
|
||||
// `true`, turning separators into vertices and bleeding the
|
||||
// fill outside its boundary (#386, #416).
|
||||
if x.is_finite() && y.is_finite() {
|
||||
boundary.push([x, y, 0.0, 0.0]);
|
||||
} else {
|
||||
let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP;
|
||||
boundary.push([s, s, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
let boundary_count = boundary.len() as u32 - boundary_offset;
|
||||
|
||||
|
||||
let family_offset = families.len() as u32;
|
||||
let mut family_count = 0u32;
|
||||
|
||||
let mut grad_kind = 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, kind, invert } => {
|
||||
grad_kind = kind.shader_kind() | if *invert { 16 } else { 0 };
|
||||
if kind.radial() {
|
||||
// Radial fill: the boundary is stored relative to its
|
||||
// centre (`world_origin`), so the centre is the local
|
||||
// origin; radius = the farthest boundary vertex. mode 3.
|
||||
let radius = radial_radius(&h.boundary);
|
||||
(3u32, *color2, 0.0, 0.0, 0.0, radius)
|
||||
} else {
|
||||
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
|
||||
// the storage batch convention: `dy` is
|
||||
// the perpendicular spacing, `dx` is the along-line
|
||||
// phase shift — both in family-local coords. The
|
||||
// shader applies cos_off/sin_off to rotate them.
|
||||
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,
|
||||
grad_kind,
|
||||
_pad2: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Pad the AABB so the quad covers any pattern halo + the
|
||||
// family origin. Mirrors the per-hatch shader's quad sizing
|
||||
// logic — `diag * 0.8 + max_spacing * 2 * scale`.
|
||||
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
|
||||
// `perp_step.abs()` per family — uses the same QCAD local-
|
||||
// frame convention as `LineFamilyGpu.perp_step` above so
|
||||
// the quad padding matches what the shader will sample.
|
||||
let max_spacing = match &h.pattern {
|
||||
HatchPattern::Pattern(fs) => fs
|
||||
.iter()
|
||||
.map(|f| f.dy.abs())
|
||||
.fold(0.0f32, f32::max),
|
||||
_ => 5.0,
|
||||
};
|
||||
let pad = (diag * 0.8 + max_spacing * 2.0 * h.scale).max(1.0);
|
||||
|
||||
let (wo_hi, wo_lo) = split_origin_ds(h.world_origin);
|
||||
instances.push(HatchInstance {
|
||||
color: h.color,
|
||||
color2,
|
||||
aabb: [min_x - pad, min_y - pad, max_x + pad, max_y + pad],
|
||||
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,
|
||||
grad_kind,
|
||||
_pad2: 0,
|
||||
});
|
||||
}
|
||||
// Empty fallbacks — storage buffers can't be zero-sized.
|
||||
if boundary.is_empty() {
|
||||
boundary.push([0.0; 4]);
|
||||
}
|
||||
if families.is_empty() {
|
||||
families.push(LineFamilyGpu::default_filler());
|
||||
}
|
||||
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<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 });
|
||||
}
|
||||
}
|
||||
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("hatch.vertex"),
|
||||
contents: bytemuck::cast_slice(&verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
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_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("hatch.boundary"),
|
||||
contents: bytemuck::cast_slice(&boundary),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
let family_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("hatch.families"),
|
||||
contents: bytemuck::cast_slice(&families),
|
||||
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.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(super) 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(super) 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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// Radius of a radial gradient — the distance from the boundary centre (the
|
||||
/// local origin, since vertices are stored relative to it) to the farthest
|
||||
/// vertex, so `t = 1` (the end colour) reaches the corners.
|
||||
fn radial_radius(boundary: &[[f32; 2]]) -> f32 {
|
||||
boundary
|
||||
.iter()
|
||||
.filter(|p| p[0].is_finite() && p[1].is_finite())
|
||||
.map(|p| (p[0] * p[0] + p[1] * p[1]).sqrt())
|
||||
.fold(0.0_f32, f32::max)
|
||||
.max(1.0)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Storage-free hatch renderer — texture-backed, UNCAPPED.
|
||||
//
|
||||
// Devices without storage buffers use this per-hatch renderer. It reuses the
|
||||
// storage-free hatch algorithm (see wipeout.wgsl / hatch_texture.wgsl) but packs the
|
||||
// variable-length boundary / family / dash arrays into ONE RGBA32F data texture
|
||||
// read via textureLoad — removing the MAX_FAMILIES / MAX_HATCH_BOUNDARY_VERTS /
|
||||
// MAX_DASHES caps of the uniform (WipeoutGpu) path. Every hatch type — solid,
|
||||
// gradient, and arbitrarily complex line patterns — renders in compat mode.
|
||||
//
|
||||
// Storage-capable devices use the sibling storage backend; wipeout masks use
|
||||
// wipeout_gpu.rs.
|
||||
|
||||
use crate::scene::model::hatch_model::{HatchModel, HatchPattern};
|
||||
use iced::wgpu;
|
||||
use iced::wgpu::util::DeviceExt;
|
||||
|
||||
/// Width (in texels) of the RGBA32F data texture. Height grows to fit; a hatch
|
||||
/// with N total texels uses ceil(N / WIDTH) rows.
|
||||
const DATA_TEX_WIDTH: u32 = 1024;
|
||||
|
||||
// ── Vertex ────────────────────────────────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct HatchVertex {
|
||||
pos: [f32; 3],
|
||||
_pad: f32,
|
||||
}
|
||||
|
||||
pub(super) fn vertex_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,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
// ── Per-hatch uniform (binding 0) — 96 bytes, matches HatchUniforms in
|
||||
// hatch_texture.wgsl. ──────────────────────────────────────────────────────
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct TextureHatchUniform {
|
||||
color: [f32; 4], // 0
|
||||
color2: [f32; 4], // 16
|
||||
mode: u32, // 32
|
||||
vcount: u32, // 36
|
||||
angle_offset: f32, // 40
|
||||
scale: f32, // 44
|
||||
grad_cos: f32, // 48
|
||||
grad_sin: f32, // 52
|
||||
grad_min: f32, // 56
|
||||
grad_range: f32, // 60
|
||||
origin: [f32; 2], // 64
|
||||
origin_low: [f32; 2], // 72
|
||||
n_families: u32, // 80
|
||||
fam_off: u32, // 84: texel offset of the family section
|
||||
dash_off: u32, // 88: texel offset of the dash section
|
||||
tex_width: u32, // 92
|
||||
}
|
||||
|
||||
// ── Per-hatch GPU handle ────────────────────────────────────────────────────
|
||||
|
||||
pub(super) struct TextureHatch {
|
||||
pub(super) vertex_buffer: wgpu::Buffer,
|
||||
pub(super) bind_group: wgpu::BindGroup,
|
||||
/// Reserved for per-frame AABB LOD (mirrors `WipeoutGpu`); not yet wired
|
||||
/// into the web hatch draw loop — the native batched path doesn't do
|
||||
/// per-hatch LOD either.
|
||||
#[allow(dead_code)]
|
||||
pub world_aabb: [f32; 4],
|
||||
_uniform_buf: wgpu::Buffer,
|
||||
_data_tex: wgpu::Texture,
|
||||
}
|
||||
|
||||
impl TextureHatch {
|
||||
/// Group-1 layout: uniform header (binding 0) + non-filterable float data
|
||||
/// texture (binding 1).
|
||||
pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
||||
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("hatch.texture.bgl1"),
|
||||
entries: &[
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: wgpu::BufferBindingType::Uniform,
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 1,
|
||||
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
model: &HatchModel,
|
||||
bgl1: &wgpu::BindGroupLayout,
|
||||
) -> Self {
|
||||
// ── Decode pattern mode (mirrors WipeoutGpu::new) ─────────────────
|
||||
// The gradient shape (kind + invert bit) rides in the mode's high
|
||||
// bits — the 96-byte uniform has no spare slot.
|
||||
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, kind, invert } => {
|
||||
let gk = (kind.shader_kind() | if *invert { 16 } else { 0 }) << 8;
|
||||
if kind.radial() {
|
||||
// Radial: centre is the local origin; grad_cos/sin unused.
|
||||
(3u32 | gk, *color2, 0.0, 0.0)
|
||||
} else {
|
||||
let r = angle_deg.to_radians();
|
||||
(2u32 | gk, *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| f.dy.abs()).fold(0.0f32, f32::max)
|
||||
}
|
||||
_ => 5.0,
|
||||
};
|
||||
let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt();
|
||||
let pad = (diag * 0.8 + max_spacing * 2.0 * model.scale).max(1.0);
|
||||
|
||||
// Anchor pattern phase at `world_origin` with the boundary stored raw,
|
||||
// matching the storage backend — NOT WipeoutGpu,
|
||||
// whose f64 origin grid-snap is dead code (wipeouts are always solid)
|
||||
// and would phase-shift every line pattern relative to desktop. No drift.
|
||||
let origin = model.world_origin;
|
||||
let drift = [0.0f32, 0.0f32];
|
||||
let (x0, x1, y0, y1) = (
|
||||
min_x + drift[0] - pad,
|
||||
max_x + drift[0] + pad,
|
||||
min_y + drift[1] - pad,
|
||||
max_y + drift[1] + pad,
|
||||
);
|
||||
|
||||
let quad = [
|
||||
HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 },
|
||||
HatchVertex { pos: [x1, y0, 0.0], _pad: 0.0 },
|
||||
HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 },
|
||||
HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 },
|
||||
HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 },
|
||||
HatchVertex { pos: [x0, y1, 0.0], _pad: 0.0 },
|
||||
];
|
||||
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("hatch.texture.vbuf"),
|
||||
contents: bytemuck::cast_slice(&quad),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
|
||||
// ── Gradient projection range (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);
|
||||
// Floor matches the storage backend.
|
||||
(proj_min, (proj_max - proj_min).max(1.0))
|
||||
}
|
||||
} else if mode == 3 {
|
||||
// Radial: range = the farthest boundary vertex from the centre.
|
||||
let radius = model
|
||||
.boundary
|
||||
.iter()
|
||||
.filter(|v| v[0].is_finite() && v[1].is_finite())
|
||||
.map(|&[x, y]| (x * x + y * y).sqrt())
|
||||
.fold(0.0_f32, f32::max)
|
||||
.max(1.0);
|
||||
(0.0, radius)
|
||||
} else {
|
||||
(0.0, 1.0)
|
||||
};
|
||||
|
||||
// ── Pack the data texture: boundary | families | dashes ───────────
|
||||
let mut texels: Vec<[f32; 4]> = Vec::new();
|
||||
// Boundary section (texels 0..vcount). NaN separators become the
|
||||
// finite GPU sentinel — the shader NaN self-compare is folded to
|
||||
// `true` on some drivers (#386, #416).
|
||||
for &[x, y] in model.boundary.iter() {
|
||||
if x.is_finite() && y.is_finite() {
|
||||
texels.push([x + drift[0], y + drift[1], 0.0, 0.0]);
|
||||
} else {
|
||||
let s = crate::scene::model::hatch_model::GPU_BOUNDARY_SEP;
|
||||
texels.push([s, s, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
let vcount = texels.len() as u32;
|
||||
|
||||
// Family section (3 texels each) + a flat dash pool.
|
||||
let fam_off = texels.len() as u32;
|
||||
let mut n_families = 0u32;
|
||||
let mut dash_pool: Vec<f32> = Vec::new();
|
||||
if let HatchPattern::Pattern(families) = &model.pattern {
|
||||
for fam in families.iter() {
|
||||
let dash_rel = dash_pool.len() as u32;
|
||||
dash_pool.extend_from_slice(&fam.dashes);
|
||||
let n_dashes = fam.dashes.len() as u32;
|
||||
let period: f32 = if n_dashes > 0 {
|
||||
fam.dashes.iter().map(|d| d.abs()).sum()
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let angle_r = fam.angle_deg.to_radians();
|
||||
// QCAD PAT convention: perp_step = dy, along_step = dx.
|
||||
texels.push([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]);
|
||||
texels.push([fam.dx, fam.dy, fam.dy, fam.dx]);
|
||||
// Counts as exact f32 (small integers → no denormal/bitcast risk).
|
||||
texels.push([0.0, period, n_dashes as f32, dash_rel as f32]);
|
||||
n_families += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Dash section (4 values per texel).
|
||||
let dash_off = texels.len() as u32;
|
||||
let mut i = 0usize;
|
||||
while i < dash_pool.len() {
|
||||
let mut t = [0.0f32; 4];
|
||||
for (c, slot) in t.iter_mut().enumerate() {
|
||||
if let Some(&d) = dash_pool.get(i + c) {
|
||||
*slot = d;
|
||||
}
|
||||
}
|
||||
texels.push(t);
|
||||
i += 4;
|
||||
}
|
||||
|
||||
// Upload the texture (min 1 texel; pad the last row).
|
||||
if texels.is_empty() {
|
||||
texels.push([0.0; 4]);
|
||||
}
|
||||
let width = DATA_TEX_WIDTH;
|
||||
let height = ((texels.len() as u32).div_ceil(width)).max(1);
|
||||
texels.resize((width * height) as usize, [0.0; 4]);
|
||||
let data_tex = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("hatch.texture.data_tex"),
|
||||
size: wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
data_tex.as_image_copy(),
|
||||
bytemuck::cast_slice(&texels),
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(width * 16),
|
||||
rows_per_image: Some(height),
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width,
|
||||
height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
let tex_view = data_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
||||
|
||||
// ── Uniform header ────────────────────────────────────────────────
|
||||
let uniform_data = TextureHatchUniform {
|
||||
color: model.color,
|
||||
color2,
|
||||
mode,
|
||||
vcount,
|
||||
angle_offset: model.angle_offset,
|
||||
// Clamp like the desktop renderer so scale==0 can't make perp_step 0
|
||||
// → round(perp/0)=NaN → an invisible hatch.
|
||||
scale: model.scale.max(1e-6),
|
||||
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,
|
||||
],
|
||||
n_families,
|
||||
fam_off,
|
||||
dash_off,
|
||||
tex_width: width,
|
||||
};
|
||||
let _uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("hatch.texture.uniform"),
|
||||
contents: bytemuck::bytes_of(&uniform_data),
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("hatch.texture.bind_group1"),
|
||||
layout: bgl1,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: _uniform_buf.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::TextureView(&tex_view),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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,
|
||||
world_aabb,
|
||||
_uniform_buf,
|
||||
_data_tex: data_tex,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user