diff --git a/src/scene/pipeline/hatch_gpu/mod.rs b/src/scene/pipeline/hatch_gpu/mod.rs new file mode 100644 index 00000000..44b44695 --- /dev/null +++ b/src/scene/pipeline/hatch_gpu/mod.rs @@ -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, + preview: Option, + }, + Texture { + resident: Vec, + preview: Vec, + }, +} + +/// 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 = 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 = 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 + ); + } +} diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu/storage.rs similarity index 98% rename from src/scene/pipeline/hatch_gpu.rs rename to src/scene/pipeline/hatch_gpu/storage.rs index 53e83657..f6683fa4 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu/storage.rs @@ -126,13 +126,13 @@ const _: () = assert!(std::mem::size_of::() == 48); /// 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(super) struct HatchVertex { pub local_xy: [f32; 2], // 0 — local-space position pub instance_index: u32, // 8 — index into InstanceBuffer } impl HatchVertex { - pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + pub(super) fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { wgpu::VertexBufferLayout { array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Vertex, @@ -158,7 +158,7 @@ impl HatchVertex { /// 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(super) struct StorageHatchBatch { pub vertex_buffer: wgpu::Buffer, pub vertex_count: u32, // The four storage buffers below are referenced via `bind_group` — @@ -190,12 +190,12 @@ pub struct HatchGpu { pub instance_aabbs: Vec<[f32; 4]>, } -impl HatchGpu { +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 fn build( + pub(super) fn build( device: &wgpu::Device, bgl: &wgpu::BindGroupLayout, hatches: &[HatchModel], @@ -257,7 +257,7 @@ impl HatchGpu { } 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 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. @@ -501,7 +501,7 @@ impl HatchGpu { /// 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) { + pub(super) fn upload_visibility(&self, queue: &wgpu::Queue) { queue.write_buffer( &self.visibility_buffer, 0, @@ -514,7 +514,7 @@ impl HatchGpu { /// 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 { + pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { let entry = |binding: u32| wgpu::BindGroupLayoutEntry { binding, visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, diff --git a/src/scene/pipeline/hatch_web_gpu.rs b/src/scene/pipeline/hatch_gpu/texture.rs similarity index 89% rename from src/scene/pipeline/hatch_web_gpu.rs rename to src/scene/pipeline/hatch_gpu/texture.rs index de5fbc47..fc3376fb 100644 --- a/src/scene/pipeline/hatch_web_gpu.rs +++ b/src/scene/pipeline/hatch_gpu/texture.rs @@ -1,14 +1,14 @@ // Storage-free hatch renderer — texture-backed, UNCAPPED. // -// WebGL2 has no storage buffers; some native adapters also expose insufficient -// limits. This per-hatch renderer reuses the WebGL2-safe hatch algorithm (see -// wipeout.wgsl / hatch_web.wgsl) but packs the +// 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 hatch_gpu.rs; wipeout masks use wipeout_gpu.rs. +// 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; @@ -27,12 +27,24 @@ struct HatchVertex { _pad: f32, } +pub(super) fn vertex_layout<'a>() -> wgpu::VertexBufferLayout<'a> { + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }], + } +} + // ── Per-hatch uniform (binding 0) — 96 bytes, matches HatchUniforms in -// hatch_web.wgsl. ───────────────────────────────────────────────────────── +// hatch_texture.wgsl. ────────────────────────────────────────────────────── #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -struct HatchWebUniform { +struct TextureHatchUniform { color: [f32; 4], // 0 color2: [f32; 4], // 16 mode: u32, // 32 @@ -53,9 +65,9 @@ struct HatchWebUniform { // ── Per-hatch GPU handle ──────────────────────────────────────────────────── -pub struct HatchWebGpu { - pub vertex_buffer: wgpu::Buffer, - pub bind_group: wgpu::BindGroup, +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. @@ -65,12 +77,12 @@ pub struct HatchWebGpu { _data_tex: wgpu::Texture, } -impl HatchWebGpu { +impl TextureHatch { /// Group-1 layout: uniform header (binding 0) + non-filterable float data /// texture (binding 1). - pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("hatch_web.bgl1"), + label: Some("hatch.texture.bgl1"), entries: &[ wgpu::BindGroupLayoutEntry { binding: 0, @@ -96,7 +108,7 @@ impl HatchWebGpu { }) } - pub fn new( + pub(super) fn new( device: &wgpu::Device, queue: &wgpu::Queue, model: &HatchModel, @@ -143,7 +155,7 @@ impl HatchWebGpu { 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 desktop batched renderer (hatch_gpu.rs) — NOT WipeoutGpu, + // 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; @@ -164,7 +176,7 @@ impl HatchWebGpu { HatchVertex { pos: [x0, y1, 0.0], _pad: 0.0 }, ]; let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch_web.vbuf"), + label: Some("hatch.texture.vbuf"), contents: bytemuck::cast_slice(&quad), usage: wgpu::BufferUsages::VERTEX, }); @@ -182,7 +194,7 @@ impl HatchWebGpu { } 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 desktop hatch renderer (hatch_gpu.rs). + // Floor matches the storage backend. (proj_min, (proj_max - proj_min).max(1.0)) } } else if mode == 3 { @@ -260,7 +272,7 @@ impl HatchWebGpu { 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_web.data_tex"), + label: Some("hatch.texture.data_tex"), size: wgpu::Extent3d { width, height, @@ -290,7 +302,7 @@ impl HatchWebGpu { let tex_view = data_tex.create_view(&wgpu::TextureViewDescriptor::default()); // ── Uniform header ──────────────────────────────────────────────── - let uniform_data = HatchWebUniform { + let uniform_data = TextureHatchUniform { color: model.color, color2, mode, @@ -314,13 +326,13 @@ impl HatchWebGpu { tex_width: width, }; let _uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("hatch_web.uniform"), + 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_web.bind_group1"), + label: Some("hatch.texture.bind_group1"), layout: bgl1, entries: &[ wgpu::BindGroupEntry { diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index bb66638e..30a45b31 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,9 +1,6 @@ mod device_capabilities; pub mod face3d_gpu; pub mod hatch_gpu; -/// Texture-backed compatibility hatch renderer. Used on WebGL2 and on native -/// adapters that cannot support the storage-buffer batch. -pub mod hatch_web_gpu; pub mod wipeout_gpu; pub mod image_gpu; pub mod mesh_gpu; @@ -91,14 +88,9 @@ pub struct Pipeline { /// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`. pub(crate) wire_const_bgl: Option, 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` in compatibility mode. - hatch_pipeline: Option, - /// Texture-backed compatibility hatch pipeline + its group-1 layout. - /// Present whenever the storage-buffer batch is unavailable or forced off. - hatch_compat_bgl1: Option, - hatch_compat_pipeline: Option, + /// Capability-selected hatch renderer. Storage and texture transports are + /// private backends behind one upload/LOD/draw lifecycle. + hatch_gpu: hatch_gpu::HatchGpu, 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). @@ -140,11 +132,6 @@ pub struct Pipeline { uniform_buffer: wgpu::Buffer, uniform_bind_group: wgpu::BindGroup, wipeout_bgl1: wgpu::BindGroupLayout, - /// Group-1 layout for the batched hatch pipeline (storage buffers - /// for instances / boundary / families / dashes). `None` in compatibility - /// mode, where hatches render through `hatch_web_gpu`. - #[cfg_attr(target_arch = "wasm32", allow(dead_code))] - hatch_bgl1: Option, image_bgl1: wgpu::BindGroupLayout, /// Group-1 layout for the text pipeline (atlas texture + sampler). text_atlas_bgl: wgpu::BindGroupLayout, @@ -245,16 +232,6 @@ 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, - /// 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` in compatibility mode. - gpu_hatch: Option, - /// Compatibility hatch fills — one texture-backed GPU object per hatch. - gpu_hatches_compat: Vec, - /// Tiny live hatch batch used by grip previews. Separate from the resident - /// batch so an interactive edit uploads only the hatch being changed. - gpu_preview_hatch: Option, - gpu_preview_hatches_compat: Vec, /// Wipeout masks — solid fills rendered after wires in a separate pass via /// the legacy per-primitive `WipeoutGpu` renderer. gpu_wipeouts: Vec, @@ -398,32 +375,8 @@ impl Pipeline { let force_compat_renderer = crate::cli::gui_config().compat_renderer; #[cfg(target_arch = "wasm32")] let force_compat_renderer = false; - let wire_mode = wire_gpu::WirePipelineMode::select( - device_caps, - force_compat_renderer, - ); - let hatch_uses_storage = - !force_compat_renderer && device_caps.supports_batched_hatch(); - #[cfg(not(target_arch = "wasm32"))] - if std::env::var_os("RUST_LOG").is_some() { - eprintln!( - "renderer pipelines: wire={} hatch={} mesh={} compute-cull={} (storage buffers/stage: {})", - if wire_mode.uses_storage() { "storage" } else { "packed" }, - if hatch_uses_storage { "storage" } else { "texture" }, - if device_caps.supports_mesh_storage_instancing() { "storage" } else { "uniform" }, - if device_caps.supports_mesh_compute_culling() { "gpu" } else { "cpu" }, - device.limits().max_storage_buffers_per_shader_stage - ); - } - #[cfg(target_arch = "wasm32")] - log::info!( - "renderer pipelines: wire={} hatch={} mesh={} compute-cull={} (storage buffers/stage: {})", - if wire_mode.uses_storage() { "storage" } else { "packed" }, - if hatch_uses_storage { "storage" } else { "texture" }, - if device_caps.supports_mesh_storage_instancing() { "storage" } else { "uniform" }, - if device_caps.supports_mesh_compute_culling() { "gpu" } else { "cpu" }, - device.limits().max_storage_buffers_per_shader_stage - ); + let wire_mode = + wire_gpu::WirePipelineMode::select(device_caps, force_compat_renderer); let wire_const_bgl = wire_mode .uses_storage() .then(|| wire_gpu::WireConst::bind_group_layout(device)); @@ -761,138 +714,36 @@ impl Pipeline { cache: None, }); - // ── Hatch pipelines ──────────────────────────────────────────────── - // Fast mode batches every hatch through five storage buffers. Compat - // mode uses one data texture per hatch and therefore needs no storage. - let (hatch_bgl1, hatch_pipeline) = if hatch_uses_storage { - 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), - vertex: wgpu::VertexState { - module: &hatch_shader, - entry_point: Some("vs_main"), - buffers: &[hatch_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::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: MSAA_SAMPLES, - mask: !0, - alpha_to_coverage_enabled: false, - }, - fragment: Some(wgpu::FragmentState { - module: &hatch_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_bgl1), Some(hatch_pipeline)) - } else { - (None, None) - }; - - let (hatch_compat_bgl1, hatch_compat_pipeline) = if !hatch_uses_storage { - let bgl1 = hatch_web_gpu::HatchWebGpu::bind_group_layout(device); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("hatch_compat.pipeline_layout"), - bind_group_layouts: &[&frame_bgl, &bgl1], - push_constant_ranges: &[], - }); - let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("hatch_compat.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/hatch_web.wgsl" - ))), - }); - let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("hatch_compat.pipeline"), - layout: Some(&layout), - vertex: wgpu::VertexState { - module: &shader, - entry_point: Some("vs_main"), - buffers: &[wgpu::VertexBufferLayout { - array_stride: 16, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &[wgpu::VertexAttribute { - offset: 0, - shader_location: 0, - format: wgpu::VertexFormat::Float32x3, - }], - }], - 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: 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, - }); - (Some(bgl1), Some(pipeline)) - } else { - (None, None) - }; + // ── Hatch renderer ───────────────────────────────────────────────── + // The façade owns capability selection plus both backend lifecycles. + let hatch_gpu = hatch_gpu::HatchGpu::new( + device, + format, + &frame_bgl, + &content_stencil, + device_caps, + force_compat_renderer, + ); + #[cfg(not(target_arch = "wasm32"))] + if std::env::var_os("RUST_LOG").is_some() { + eprintln!( + "renderer pipelines: wire={} hatch={} mesh={} compute-cull={} (storage buffers/stage: {})", + if wire_mode.uses_storage() { "storage" } else { "packed" }, + hatch_gpu.backend_name(), + if device_caps.supports_mesh_storage_instancing() { "storage" } else { "uniform" }, + if device_caps.supports_mesh_compute_culling() { "gpu" } else { "cpu" }, + device.limits().max_storage_buffers_per_shader_stage + ); + } + #[cfg(target_arch = "wasm32")] + log::info!( + "renderer pipelines: wire={} hatch={} mesh={} compute-cull={} (storage buffers/stage: {})", + if wire_mode.uses_storage() { "storage" } else { "packed" }, + hatch_gpu.backend_name(), + if device_caps.supports_mesh_storage_instancing() { "storage" } else { "uniform" }, + if device_caps.supports_mesh_compute_culling() { "gpu" } else { "cpu" }, + device.limits().max_storage_buffers_per_shader_stage + ); // ── Mesh pipeline ────────────────────────────────────────────────── let mesh_storage_instancing = device_caps.supports_mesh_storage_instancing(); @@ -1758,9 +1609,7 @@ impl Pipeline { wire_xray_pipeline, wire_const_bgl, wipeout_pipeline, - hatch_pipeline, - hatch_compat_bgl1, - hatch_compat_pipeline, + hatch_gpu, image_pipeline, text_pipeline, text_highlight_pipeline, @@ -1798,7 +1647,6 @@ impl Pipeline { uniform_buffer, uniform_bind_group, wipeout_bgl1, - hatch_bgl1, image_bgl1, depth_texture_size: Size::new(1, 1), // (0, 0) forces the first `ensure_depth_texture` to allocate at the @@ -1827,10 +1675,6 @@ impl Pipeline { clip_boundary: None, gpu_selected_wires: vec![], gpu_preview_wires: vec![], - gpu_hatch: None, - gpu_hatches_compat: vec![], - gpu_preview_hatch: None, - gpu_preview_hatches_compat: vec![], gpu_wipeouts: vec![], wipeout_skip_flags: vec![], gpu_images: vec![], @@ -2352,22 +2196,20 @@ impl Pipeline { clip_h: u32, ) { let perf_started = crate::perf::enabled().then(iced::time::Instant::now); - let Some(batch) = &mut self.gpu_hatch else { + let instance_count = self.hatch_gpu.update_visibility(queue, |aabb| { + !aabb_below_pixel(aabb, view_rot, eye, clip_w, clip_h, 2.0) + && !aabb_offscreen(aabb, view_rot, eye, clip_w, clip_h) + }); + if instance_count == 0 { return; - }; - for (i, aabb) in batch.instance_aabbs.iter().enumerate() { - let skip = aabb_below_pixel(*aabb, view_rot, eye, clip_w, clip_h, 2.0) - || aabb_offscreen(*aabb, view_rot, eye, clip_w, clip_h); - batch.visibility[i] = if skip { 0 } else { 1 }; } - batch.upload_visibility(queue); if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; if elapsed_ms >= 1.0 { crate::perf_record!( "[perf] hatch-lod {:>7.1}ms instances={}", elapsed_ms, - batch.instance_aabbs.len(), + instance_count, ); } } @@ -2851,32 +2693,18 @@ impl Pipeline { hatches: &[HatchModel], ) { let perf_started = crate::perf::enabled().then(iced::time::Instant::now); - if let Some(bgl1) = &self.hatch_compat_bgl1 { - self.gpu_hatches_compat = hatches - .iter() - .filter(|h| h.boundary.len() >= 3) - .map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, bgl1)) - .collect(); - self.gpu_hatch = None; - return; - } - - self.gpu_hatches_compat.clear(); - let _ = queue; - let Some(bgl1) = &self.hatch_bgl1 else { - self.gpu_hatch = None; - return; - }; - let renderable: Vec = - hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); - self.gpu_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable); + let renderable_count = hatches + .iter() + .filter(|hatch| hatch.boundary.len() >= 3) + .count(); + self.hatch_gpu.upload(device, queue, hatches); if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; if elapsed_ms >= 1.0 { crate::perf_record!( "[perf] hatch-upload {:>7.1}ms models={}", elapsed_ms, - renderable.len(), + renderable_count, ); } } @@ -2888,27 +2716,7 @@ impl Pipeline { queue: &wgpu::Queue, hatches: &[HatchModel], ) { - if let Some(bgl1) = &self.hatch_compat_bgl1 { - self.gpu_preview_hatches_compat = hatches - .iter() - .filter(|h| h.boundary.len() >= 3) - .map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, bgl1)) - .collect(); - self.gpu_preview_hatch = None; - return; - } - - self.gpu_preview_hatches_compat.clear(); - let Some(bgl1) = &self.hatch_bgl1 else { - self.gpu_preview_hatch = None; - return; - }; - let renderable: Vec = hatches - .iter() - .filter(|h| h.boundary.len() >= 3) - .cloned() - .collect(); - self.gpu_preview_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable); + self.hatch_gpu.upload_preview(device, queue, hatches); } pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) { @@ -3077,57 +2885,13 @@ impl Pipeline { pass.set_vertex_buffer(0, vbuf.slice(..)); pass.draw(0..*vcount, 0..1); } - // Phase 4-B — single batched draw covers every hatch. - // Vertex shader culls per-instance via the `visibility` - // buffer (sub-pixel LOD + frustum cull written each frame - // by `compute_hatch_lod`). Per-hatch viewport scissor - // (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, &self.hatch_pipeline) - { - // Skipped while navigating (interaction LOD) — the per-pixel - // hatch pass dominates the GPU frame on hatch-heavy drawings. - if !self.skip_hatch_frame { - pass.set_pipeline(pipeline); - pass.set_stencil_reference(stencil_ref); - pass.set_bind_group(0, &self.uniform_bind_group, &[]); - 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); - } - } - if let (Some(batch), Some(pipeline)) = - (&self.gpu_preview_hatch, &self.hatch_pipeline) - { - if !self.skip_hatch_frame { - pass.set_pipeline(pipeline); - pass.set_stencil_reference(stencil_ref); - pass.set_bind_group(0, &self.uniform_bind_group, &[]); - 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); - } - } - - // Storage-free compatibility path. Draw before wires so outlines - // remain on top, matching the batched path. + // The capability-selected façade dispatches storage or texture + // draws before wires so outlines remain on top in either backend. + // Skipped while navigating because per-pixel hatch work dominates + // hatch-heavy drawings. if !self.skip_hatch_frame { - if let Some(pipeline) = &self.hatch_compat_pipeline { - pass.set_pipeline(pipeline); - pass.set_bind_group(0, &self.uniform_bind_group, &[]); - pass.set_stencil_reference(stencil_ref); - for hatch in &self.gpu_hatches_compat { - pass.set_bind_group(1, &hatch.bind_group, &[]); - pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); - pass.draw(0..6, 0..1); - } - for hatch in &self.gpu_preview_hatches_compat { - pass.set_bind_group(1, &hatch.bind_group, &[]); - pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); - pass.draw(0..6, 0..1); - } - } + self.hatch_gpu + .draw(&mut pass, &self.uniform_bind_group, stencil_ref); } } diff --git a/src/scene/pipeline/wipeout_gpu.rs b/src/scene/pipeline/wipeout_gpu.rs index 4b7472f7..4e068c0e 100644 --- a/src/scene/pipeline/wipeout_gpu.rs +++ b/src/scene/pipeline/wipeout_gpu.rs @@ -1,6 +1,6 @@ // 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. +// fills go through the capability-selected `hatch_gpu` 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 diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index 18de5e86..d1ce2a00 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -5,7 +5,7 @@ // positions so we don't depend on @builtin(instance_index) edge cases // across backends). // -// Layout — matches `hatch_gpu.rs`: +// Layout — matches `hatch_gpu/storage.rs`: // group 1 binding 0 InstanceBuffer HatchInstance[] (128 B / inst) // group 1 binding 1 BoundaryBuffer vec4[] (xy in .xy) // group 1 binding 2 FamilyBuffer LineFamilyGpu[] (48 B / fam) diff --git a/src/shaders/hatch_web.wgsl b/src/shaders/hatch_texture.wgsl similarity index 99% rename from src/shaders/hatch_web.wgsl rename to src/shaders/hatch_texture.wgsl index 3899f1bd..94e7f602 100644 --- a/src/shaders/hatch_web.wgsl +++ b/src/shaders/hatch_texture.wgsl @@ -1,4 +1,4 @@ -// Hatch shader (WebGL2) — texture-backed, UNCAPPED variant of wipeout.wgsl. +// Hatch texture shader — storage-free, UNCAPPED variant of wipeout.wgsl. // // Devices without vertex/fragment storage buffers cannot use the batched // storage-buffer renderer (hatch.wgsl). This shader keeps the exact