From 93b9a840ffa4367dfedf25eb5fcf6b0604b4f376 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 29 Jul 2026 12:49:28 +0300 Subject: [PATCH] refactor(renderer): select paths by GPU limits Keep storage fast paths on capable adapters and use packed or texture fallbacks only when device limits require them.\n\nCompact mesh-edge varyings to avoid WebGL shader rejection. --- Cargo.lock | 1 + Cargo.toml | 5 + src/scene/mod.rs | 6 +- src/scene/pipeline/device_capabilities.rs | 93 ++++ src/scene/pipeline/hatch_gpu.rs | 8 +- src/scene/pipeline/hatch_web_gpu.rs | 2 +- src/scene/pipeline/mesh_gpu.rs | 31 ++ src/scene/pipeline/mod.rs | 81 ++- src/scene/pipeline/wire_arena.rs | 588 +++++++++++++++++++++- src/scene/pipeline/wire_gpu.rs | 103 ++-- src/scene/view/render.rs | 125 ++--- src/shaders/hatch_web.wgsl | 6 +- src/shaders/mesh.wgsl | 58 ++- src/shaders/wire.wgsl | 8 + src/shaders/wire_indexed.wgsl | 8 + tests/mesh_shader_limits.rs | 68 +++ 16 files changed, 1010 insertions(+), 181 deletions(-) create mode 100644 src/scene/pipeline/device_capabilities.rs create mode 100644 tests/mesh_shader_limits.rs diff --git a/Cargo.lock b/Cargo.lock index 9f840415..794a9fd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,7 @@ dependencies = [ "log", "lyon_tessellation", "lzma-sys", + "naga", "ocs_plugin_api", "open", "printpdf", diff --git a/Cargo.toml b/Cargo.toml index 073de1e6..8545571b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,11 @@ ttf-parser = "0.25" cosmic-text = "0.15" lyon_tessellation = "1.0.20" +[dev-dependencies] +# Shader-interface regression tests. This is already present transitively +# through wgpu; declaring it here lets tests inspect WGSL entry-point budgets. +naga = { version = "27", features = ["wgsl-in"] } + [target.'cfg(target_os = "windows")'.dependencies] # Win32_System_Com — COM behind "Set as default app" # (IApplicationAssociationRegistrationUI dialog). diff --git a/src/scene/mod.rs b/src/scene/mod.rs index dd21f0be..1a1dd070 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -477,8 +477,8 @@ const GEOMETRY_JOURNAL_CAP: usize = 256; /// Whether the persistent per-entity GPU wire arena (`OCS_WIRE_GPU_PATCH`) is /// enabled — patches one entity's instance slab on an edit instead of rebuilding -/// the whole wire buffer. Opt-in while it is validated visually. Always off on -/// wasm (WebGL2 has the fat self-contained instance, no arena). +/// the whole wire buffer. The render layer selects indexed-storage or packed +/// arena storage from the active device's wire pipeline. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn wire_gpu_patch_enabled() -> bool { use std::sync::OnceLock; @@ -494,7 +494,7 @@ pub(crate) fn wire_gpu_patch_enabled() -> bool { } #[cfg(target_arch = "wasm32")] pub(crate) fn wire_gpu_patch_enabled() -> bool { - false + true } /// Resolve a viewport's paper-to-model scale ratio from its two diff --git a/src/scene/pipeline/device_capabilities.rs b/src/scene/pipeline/device_capabilities.rs new file mode 100644 index 00000000..3f271582 --- /dev/null +++ b/src/scene/pipeline/device_capabilities.rs @@ -0,0 +1,93 @@ +use iced::wgpu; + +/// Renderer-relevant limits of the selected GPU device. +/// +/// Capabilities, not the compilation target, choose the renderer tier. Browser +/// WebGPU and native Vulkan/Metal/DX12 can therefore share storage-backed +/// pipelines, while WebGL2 and weak native adapters use compatibility paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeviceCapabilities { + pub max_storage_buffers_per_shader_stage: u32, + pub max_inter_stage_shader_components: u32, + pub max_vertex_attributes: u32, +} + +impl DeviceCapabilities { + /// Indexed wires read one per-wire constants storage buffer. + const WIRE_STORAGE_BINDINGS: u32 = 1; + + /// Batched hatch uses five storage bindings in one shader stage: + /// instances, boundaries, families, dashes, and visibility. + const HATCH_STORAGE_BINDINGS: u32 = 5; + + /// Mesh compute culling reads one item buffer and writes four indirect + /// command buffers. + const MESH_CULL_BINDINGS: u32 = 5; + + pub fn detect(device: &wgpu::Device) -> Self { + Self::from_limits(&device.limits()) + } + + fn from_limits(limits: &wgpu::Limits) -> Self { + Self { + max_storage_buffers_per_shader_stage: limits.max_storage_buffers_per_shader_stage, + max_inter_stage_shader_components: limits.max_inter_stage_shader_components, + max_vertex_attributes: limits.max_vertex_attributes, + } + } + + /// Mesh instancing needs one read-only storage buffer. + pub fn supports_mesh_storage_instancing(self) -> bool { + self.max_storage_buffers_per_shader_stage >= 1 + } + + pub fn supports_wire_storage(self) -> bool { + self.max_storage_buffers_per_shader_stage >= Self::WIRE_STORAGE_BINDINGS + } + + pub fn supports_batched_hatch(self) -> bool { + self.max_storage_buffers_per_shader_stage >= Self::HATCH_STORAGE_BINDINGS + } + + /// WebGL2 reports zero storage bindings and stays on CPU mesh culling. + pub fn supports_mesh_compute_culling(self) -> bool { + self.max_storage_buffers_per_shader_stage >= Self::MESH_CULL_BINDINGS + } +} + +#[cfg(test)] +mod tests { + use super::DeviceCapabilities; + use iced::wgpu; + + #[test] + fn webgl_limits_select_compatibility_paths() { + let caps = DeviceCapabilities::from_limits(&wgpu::Limits::downlevel_webgl2_defaults()); + assert!(!caps.supports_mesh_storage_instancing()); + assert!(!caps.supports_wire_storage()); + assert!(!caps.supports_batched_hatch()); + assert!(!caps.supports_mesh_compute_culling()); + } + + #[test] + fn default_limits_select_storage_paths() { + let caps = DeviceCapabilities::from_limits(&wgpu::Limits::default()); + assert!(caps.supports_mesh_storage_instancing()); + assert!(caps.supports_wire_storage()); + assert!(caps.supports_batched_hatch()); + assert!(caps.supports_mesh_compute_culling()); + } + + #[test] + fn storage_paths_are_selected_independently() { + let caps = DeviceCapabilities { + max_storage_buffers_per_shader_stage: 1, + max_inter_stage_shader_components: 31, + max_vertex_attributes: 16, + }; + assert!(caps.supports_wire_storage()); + assert!(caps.supports_mesh_storage_instancing()); + assert!(!caps.supports_batched_hatch()); + assert!(!caps.supports_mesh_compute_culling()); + } +} diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index 129c2267..53e83657 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -1,15 +1,11 @@ -// The whole batched renderer is native-only: WebGL2 lacks storage buffers, so -// on wasm hatches go through `hatch_web_gpu` and nothing here is called. -#![cfg_attr(target_arch = "wasm32", allow(dead_code))] - // 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 +// 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: diff --git a/src/scene/pipeline/hatch_web_gpu.rs b/src/scene/pipeline/hatch_web_gpu.rs index b20f3b84..de5fbc47 100644 --- a/src/scene/pipeline/hatch_web_gpu.rs +++ b/src/scene/pipeline/hatch_web_gpu.rs @@ -8,7 +8,7 @@ // MAX_DASHES caps of the uniform (WipeoutGpu) path. Every hatch type — solid, // gradient, and arbitrarily complex line patterns — renders in compat mode. // -// The fast native path remains hatch_gpu.rs; wipeout masks use wipeout_gpu.rs. +// Storage-capable devices use hatch_gpu.rs; wipeout masks use wipeout_gpu.rs. use crate::scene::model::hatch_model::{HatchModel, HatchPattern}; use iced::wgpu; diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index ce4134ab..dcfe3f4c 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -129,6 +129,37 @@ impl MeshVertex { attributes: ATTRS, } } + + /// Minimal layout shared by native and WebGL mesh edge pipelines. + /// + /// Edge fragments only need position and entity color. Advertising the + /// material/normal/UV attributes here would keep the full surface-shader + /// interface alive on WebGL even though the edge entry point never reads + /// those values. + pub fn edge_layout<'a>() -> wgpu::VertexBufferLayout<'a> { + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { + offset: std::mem::offset_of!(MeshVertex, position) as u64, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(MeshVertex, color) as u64, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(MeshVertex, position_low) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Float32x3, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: ATTRS, + } + } } #[repr(C)] diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 3bbbe823..bb66638e 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,3 +1,4 @@ +mod device_capabilities; pub mod face3d_gpu; pub mod hatch_gpu; /// Texture-backed compatibility hatch renderer. Used on WebGL2 and on native @@ -9,10 +10,9 @@ pub mod mesh_gpu; pub mod text_gpu; pub mod uniforms; pub mod viewcube; -/// Persistent per-entity wire instance arena (native), enabled by -/// `OCS_WIRE_GPU_PATCH` — patches one entity's slab via `write_buffer` instead -/// of re-uploading the whole wire set on an edit. -#[cfg(not(target_arch = "wasm32"))] +/// Persistent per-entity wire instance arena. Its indexed-storage and packed +/// adapters share the same patch/cull lifecycle across native, WebGPU, and +/// WebGL2. pub mod wire_arena; pub mod wire_gpu; @@ -31,6 +31,7 @@ use crate::scene::model::hatch_model::HatchModel; use crate::scene::model::image_model::ImageModel; use crate::scene::model::mesh_model::MeshLodSet; use crate::scene::model::wire_model::WireModel; +use device_capabilities::DeviceCapabilities; /// MSAA sample count for the main drawing pipelines. const MSAA_SAMPLES: u32 = 4; @@ -86,7 +87,7 @@ pub struct Pipeline { /// Used to draw ghost copies of selected wires through occluding geometry. wire_xray_pipeline: wgpu::RenderPipeline, /// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire / - /// xray pipelines). `Some` only on the fast native path; `None` in packed + /// xray pipelines). `Some` on any storage-capable device; `None` in packed /// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`. pub(crate) wire_const_bgl: Option, wipeout_pipeline: wgpu::RenderPipeline, @@ -207,30 +208,23 @@ pub struct Pipeline { /// replace this thin draw-range list on camera changes without touching the /// shared resident buffer. pub(crate) gpu_wires: std::sync::Arc>, - /// Persistent per-entity wire instance arena (native, `OCS_WIRE_GPU_PATCH`). + /// Persistent per-entity wire instance arena (capability-selected format). /// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an /// edit patches one entity's slab in place instead of rebuilding every wire. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) wire_arena: Option, + pub(crate) wire_arena: Option, /// Second arena for the mesh/solid EDGE wires (drawn with the mesh-edge skip / /// black treatment); the resident set is split into this + `wire_arena` so /// both patch incrementally. Shares `wire_arena_id`. - #[cfg(not(target_arch = "wasm32"))] - pub(crate) wire_arena_mesh: Option, + pub(crate) wire_arena_mesh: Option, /// Chunked resident buffers for whichever arena partition exceeded one /// GPU buffer. `Some(false)` = regular wires, `Some(true)` = mesh edges. - #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_fallback: std::sync::Arc>, - #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_fallback_kind: Option, - #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet, /// The Model content id both arenas currently mirror (`u64::MAX` = none). - #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_id: u64, /// Last content/camera/viewport tuple used to derive visible instance /// ranges from the resident arena. - #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_cull_key: (u64, u64, u32, u32), /// View/source keys for CPU visibility passes. A plain entity edit changes /// the scene render signature, but it must not rescan every unchanged hatch, @@ -396,31 +390,43 @@ impl Pipeline { }); // ── Wire pipeline ────────────────────────────────────────────────── - // Select once per device. The fast native path hoists shared constants - // into storage; compatibility mode keeps them in 10 packed attributes. - let wire_mode = wire_gpu::WirePipelineMode::select(device); - let renderer_mode_name = - if wire_mode.uses_storage() { "fast-storage" } else { "packed-compat" }; + // Select once from actual device limits. Any device whose compositor + // exposes the required storage limits uses the storage renderer; all + // other devices use the packed/texture compatibility renderer. + let device_caps = DeviceCapabilities::detect(device); + #[cfg(not(target_arch = "wasm32"))] + 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 pipeline: {} (storage buffers/stage: {})", - renderer_mode_name, + "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 pipeline: {} (storage buffers/stage: {})", - renderer_mode_name, + "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(not(target_arch = "wasm32"))] let wire_const_bgl = wire_mode .uses_storage() .then(|| wire_gpu::WireConst::bind_group_layout(device)); - #[cfg(target_arch = "wasm32")] - let wire_const_bgl: Option = None; let mut wire_bgls: Vec<&wgpu::BindGroupLayout> = vec![&frame_bgl]; if let Some(bgl) = &wire_const_bgl { wire_bgls.push(bgl); @@ -437,7 +443,6 @@ impl Pipeline { let wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("wire.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(match wire_mode { - #[cfg(not(target_arch = "wasm32"))] wire_gpu::WirePipelineMode::IndexedStorage => { include_str!("../../shaders/wire_indexed.wgsl") } @@ -759,7 +764,7 @@ impl Pipeline { // ── 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 wire_mode.uses_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"), @@ -820,7 +825,7 @@ impl Pipeline { (None, None) }; - let (hatch_compat_bgl1, hatch_compat_pipeline) = if !wire_mode.uses_storage() { + 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"), @@ -890,8 +895,7 @@ impl Pipeline { }; // ── Mesh pipeline ────────────────────────────────────────────────── - let mesh_storage_instancing = - device.limits().max_storage_buffers_per_shader_stage > 0; + let mesh_storage_instancing = device_caps.supports_mesh_storage_instancing(); let mesh_source = include_str!("../../shaders/mesh.wgsl"); let mesh_source = if mesh_storage_instancing { std::borrow::Cow::Borrowed(mesh_source) @@ -906,7 +910,7 @@ impl Pipeline { source: wgpu::ShaderSource::Wgsl(mesh_source), }); let (mesh_cull_bgl, mesh_cull_pipeline, mesh_cull_uniform) = - if mesh_storage_instancing { + if device_caps.supports_mesh_compute_culling() { let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("mesh.cull.bgl"), @@ -1333,8 +1337,8 @@ impl Pipeline { layout: Some(&mesh_layout), vertex: wgpu::VertexState { module: &mesh_shader, - entry_point: Some("vs_main"), - buffers: &[mesh_gpu::MeshVertex::layout()], + entry_point: Some("vs_edge"), + buffers: &[mesh_gpu::MeshVertex::edge_layout()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { @@ -1810,19 +1814,12 @@ impl Pipeline { blit_uniform_buffer, surface_format: format, gpu_wires: std::sync::Arc::new(vec![]), - #[cfg(not(target_arch = "wasm32"))] wire_arena: None, - #[cfg(not(target_arch = "wasm32"))] wire_arena_mesh: None, - #[cfg(not(target_arch = "wasm32"))] wire_arena_fallback: std::sync::Arc::new(Vec::new()), - #[cfg(not(target_arch = "wasm32"))] wire_arena_fallback_kind: None, - #[cfg(not(target_arch = "wasm32"))] wire_arena_fallback_handles: rustc_hash::FxHashSet::default(), - #[cfg(not(target_arch = "wasm32"))] wire_arena_id: u64::MAX, - #[cfg(not(target_arch = "wasm32"))] wire_cull_key: (u64::MAX, u64::MAX, 0, 0), hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false), wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false), diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 56263be6..84ab54bd 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -1,4 +1,4 @@ -// Persistent per-entity wire instance arena (native, behind OCS_WIRE_GPU_PATCH). +// Persistent per-entity wire instance arena with storage and packed adapters. // // The normal wire path re-emits EVERY wire into a fresh instance buffer whenever // the resident set's content id changes — so any edit on a drawing whose wires @@ -24,19 +24,20 @@ // full rebuild instead of appending. Opaque overlap resolves by the z-bias, so // it is order-independent and safe to relocate. // -// A tombstoned instance points at const slot 0 (a blank WireConst, half_width 0), -// so the shader expands it to a zero-area quad — no pixels. When tombstone waste -// or capacity is exceeded, `patch` returns false and the caller compacts via a -// full rebuild. Because a full rebuild is always the fallback, correctness never -// rides on the fast path. +// A tombstoned instance points at const slot 0, whose negative pattern length +// is a shader-level discard sentinel. When tombstone waste or capacity is +// exceeded, `patch` returns false and the caller compacts via a full rebuild. +// Because a full rebuild is always the fallback, correctness never rides on the +// fast path. // // Scope: a SINGLE batch — the set must have no mesh-edge fills (which force the // draw-order-preserving multi-batch split) and no per-wire scissor (paper content // viewports). Mixed 2D/3D or scissored sets fall back to the batched path. -#![cfg(not(target_arch = "wasm32"))] - -use super::wire_gpu::{emit_wire_native, wire_draw_depth, WireConst, WireGpu, WireInstance}; +use super::wire_gpu::{ + emit_wire_native, emit_wire_packed, wire_draw_depth, PackedWireInstance, WireConst, WireGpu, + WireInstance, +}; use crate::scene::model::wire_model::WireModel; use crate::scene::ChangeKind; use acadrust::Handle; @@ -301,11 +302,15 @@ fn alloc_const_initialized( } fn blank_const() -> WireConst { - ::zeroed() + let mut blank = ::zeroed(); + // Negative pattern length is reserved for arena tombstones. A zero-length + // segment still expands to a half-pixel when LWDISPLAY is off, so geometry + // alone cannot make a tombstone invisible. + blank.pattern_length = -1.0; + blank } -/// A blank instance: zero-length segment at const slot 0 (half_width 0) — the -/// shader expands it to a zero-area quad, so it rasterises nothing. +/// A tombstoned instance. Const slot 0 carries the shader discard sentinel. fn blank_instance() -> WireInstance { WireInstance { pos_a: [0.0; 3], @@ -884,6 +889,527 @@ impl WireArena { } } +// ── Packed arena adapter ─────────────────────────────────────────────────── + +/// Persistent arena for devices without vertex-stage storage buffers. Shared +/// wire constants stay duplicated in each packed instance, but residency, +/// entity-local patches, tombstones, headroom, and visibility ranges match the +/// indexed-storage arena above. +struct PackedWireArena { + inst_buf: wgpu::Buffer, + inst_cap: u32, + inst_tail: u32, + slabs: FxHashMap, + vacant: FxHashMap, + tombstoned: u32, + mesh_edge: bool, + order_sensitive: bool, +} + +struct PreparedPackedPatchRun { + insts: Vec, + base_depth: f32, + aabb: [f32; 4], + order_sensitive: bool, +} + +const MAX_PACKED_INSTANCES: u64 = + 268_435_456 / std::mem::size_of::() as u64; + +fn blank_packed_instance() -> PackedWireInstance { + let mut blank = ::zeroed(); + blank.pattern_length = -1.0; + blank +} + +fn can_resize_packed_terminal_slab( + slab: &Slab, + inst_tail: u32, + inst_cap: u32, + new_inst_len: u32, + change_count: usize, +) -> bool { + change_count == 1 + && slab.inst_off + slab.inst_len == inst_tail + && slab.inst_off + new_inst_len <= inst_cap +} + +fn alloc_packed_initialized( + device: &wgpu::Device, + cap: u64, + data: &[PackedWireInstance], +) -> wgpu::Buffer { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("wire_arena.packed.ibuf"), + size: cap * std::mem::size_of::() as u64, + usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: true, + }); + if !data.is_empty() { + let bytes = bytemuck::cast_slice(data); + let mut mapped = buffer + .slice(..bytes.len() as u64) + .get_mapped_range_mut(); + mapped.copy_from_slice(bytes); + drop(mapped); + } + buffer.unmap(); + buffer +} + +fn visible_ranges( + slabs: &FxHashMap, + view_rot: glam::Mat4, + eye: glam::DVec3, + clip_w: u32, + clip_h: u32, +) -> Vec<(u32, u32)> { + let mut ranges: Vec<(u32, u32)> = slabs + .values() + .filter(|slab| { + slab.inst_len > 0 + && !super::aabb_offscreen(slab.aabb, view_rot, eye, clip_w, clip_h) + }) + .map(|slab| (slab.inst_off, slab.inst_off + slab.inst_len)) + .collect(); + ranges.sort_unstable_by_key(|range| range.0); + + let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len()); + for (start, end) in ranges { + if let Some((_, previous_end)) = merged.last_mut() { + if *previous_end == start { + *previous_end = end; + continue; + } + } + merged.push((start, end)); + } + + // Cap CPU draw-call overhead on pathologically interleaved draw order. + const MAX_RANGES: usize = 64; + if merged.len() > MAX_RANGES { + let group = (merged.len() + MAX_RANGES - 1) / MAX_RANGES; + merged = merged + .chunks(group) + .map(|chunk| (chunk[0].0, chunk[chunk.len() - 1].1)) + .collect(); + } + merged +} + +impl PackedWireArena { + fn build( + device: &wgpu::Device, + wires: &[&WireModel], + depth_map: &FxHashMap, + mesh_edge: bool, + ) -> Option { + let ranges = handle_ranges(wires)?; + let max_instances: usize = wires + .iter() + .map(|wire| wire.points.len().saturating_sub(1)) + .sum(); + if max_instances as u64 > MAX_PACKED_INSTANCES { + return None; + } + + struct PackedSlab { + handle: Handle, + base_depth: f32, + aabb: [f32; 4], + instances: Vec, + } + + use crate::par::prelude::*; + let packed: Vec = ranges + .par_iter() + .map(|&(handle, start, end)| { + let run = &wires[start..end]; + let base_depth = if mesh_edge { + 0.0 + } else { + depth_map.get(&handle.value()).map_or(0.0, |depth| depth[0]) + }; + let mut instances = Vec::with_capacity( + run.iter() + .map(|wire| wire.points.len().saturating_sub(1)) + .sum(), + ); + for &wire in run { + let draw_depth = if mesh_edge { + 0.0 + } else { + wire_draw_depth(wire, depth_map) + }; + instances.extend(emit_wire_packed(wire, wire.color, draw_depth)); + } + PackedSlab { + handle, + base_depth, + aabb: run_aabb(run), + instances, + } + }) + .collect(); + + let inst_count: usize = packed.iter().map(|slab| slab.instances.len()).sum(); + if inst_count as u64 > MAX_PACKED_INSTANCES { + return None; + } + let mut instances = Vec::with_capacity(inst_count); + let mut slabs = + FxHashMap::with_capacity_and_hasher(packed.len(), Default::default()); + for mut packed_slab in packed { + let inst_off = instances.len() as u32; + let inst_len = packed_slab.instances.len() as u32; + instances.append(&mut packed_slab.instances); + slabs.insert( + packed_slab.handle, + Slab { + inst_off, + inst_len, + const_off: 0, + const_len: 0, + aabb: packed_slab.aabb, + base_depth: packed_slab.base_depth, + }, + ); + } + + let inst_tail = instances.len() as u32; + let inst_cap = ((inst_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) + .max(MIN_INST_CAP) + .min(MAX_PACKED_INSTANCES)) as u32; + let inst_buf = alloc_packed_initialized(device, inst_cap as u64, &instances); + + Some(Self { + inst_buf, + inst_cap, + inst_tail, + slabs, + vacant: FxHashMap::default(), + tombstoned: 0, + mesh_edge, + order_sensitive: order_sensitive(wires, depth_map), + }) + } + + fn write_insts(&self, queue: &wgpu::Queue, off: u32, data: &[PackedWireInstance]) { + if data.is_empty() { + return; + } + let size = std::mem::size_of::() as u64; + queue.write_buffer( + &self.inst_buf, + off as u64 * size, + bytemuck::cast_slice(data), + ); + } + + fn patch( + &mut self, + queue: &wgpu::Queue, + changes: &[(Handle, ChangeKind)], + runs: &FxHashMap>, + new_handles_are_suffix: bool, + depth_map: &FxHashMap, + ) -> bool { + let mut prepared: FxHashMap = + FxHashMap::default(); + for &(handle, kind) in changes { + let run = runs.get(&handle).map(Vec::as_slice).unwrap_or(&[]); + if matches!(kind, ChangeKind::Removed) || run.is_empty() { + continue; + } + let mut insts = Vec::new(); + for &wire in run { + let draw_depth = if self.mesh_edge { + 0.0 + } else { + wire_draw_depth(wire, depth_map) + }; + insts.extend(emit_wire_packed(wire, wire.color, draw_depth)); + } + let inst_len = insts.len() as u32; + if matches!(kind, ChangeKind::Modified) { + let known = self + .slabs + .get(&handle) + .or_else(|| self.vacant.get(&handle)); + let shape_changed = + known.is_some_and(|slab| slab.inst_len != inst_len); + let can_resize_tail = self.slabs.get(&handle).is_some_and(|slab| { + can_resize_packed_terminal_slab( + slab, + self.inst_tail, + self.inst_cap, + inst_len, + changes.len(), + ) + }); + if shape_changed && !can_resize_tail { + return false; + } + } + prepared.insert( + handle, + PreparedPackedPatchRun { + insts, + base_depth: if self.mesh_edge { + 0.0 + } else { + depth_map + .get(&handle.value()) + .map_or(0.0, |depth| depth[0]) + }, + aabb: run_aabb(run), + order_sensitive: order_sensitive(run, depth_map), + }, + ); + } + + for &(handle, kind) in changes { + let run = runs.get(&handle).map(Vec::as_slice).unwrap_or(&[]); + if matches!(kind, ChangeKind::Removed) || run.is_empty() { + if let Some(slab) = self.slabs.remove(&handle) { + let blanks = + vec![blank_packed_instance(); slab.inst_len as usize]; + self.write_insts(queue, slab.inst_off, &blanks); + self.tombstoned += slab.inst_len; + if matches!(kind, ChangeKind::Modified) { + self.vacant.insert(handle, slab); + } + } + if matches!(kind, ChangeKind::Removed) { + self.vacant.remove(&handle); + } + continue; + } + + let PreparedPackedPatchRun { + insts, + base_depth, + aabb, + order_sensitive: run_order_sensitive, + } = prepared + .remove(&handle) + .expect("visible packed wire patch run was prepared"); + let inst_len = insts.len() as u32; + + if !self.slabs.contains_key(&handle) + && self + .vacant + .get(&handle) + .is_some_and(|slab| slab.inst_len == inst_len) + { + let slab = self.vacant.remove(&handle).unwrap(); + self.tombstoned = + self.tombstoned.saturating_sub(slab.inst_len); + self.slabs.insert(handle, slab); + } + + if self + .slabs + .get(&handle) + .is_some_and(|slab| slab.inst_len == inst_len) + { + let inst_off = self.slabs[&handle].inst_off; + self.write_insts(queue, inst_off, &insts); + let slab = self.slabs.get_mut(&handle).unwrap(); + slab.base_depth = base_depth; + slab.aabb = aabb; + continue; + } + + let can_resize_tail = self.slabs.get(&handle).is_some_and(|slab| { + can_resize_packed_terminal_slab( + slab, + self.inst_tail, + self.inst_cap, + inst_len, + changes.len(), + ) + }); + if can_resize_tail { + let inst_off = self.slabs[&handle].inst_off; + self.write_insts(queue, inst_off, &insts); + self.inst_tail = inst_off + inst_len; + let slab = self.slabs.get_mut(&handle).unwrap(); + slab.inst_len = inst_len; + slab.base_depth = base_depth; + slab.aabb = aabb; + self.order_sensitive |= run_order_sensitive; + continue; + } + + let is_new = !self.slabs.contains_key(&handle); + let preserves_submission_order = is_new && new_handles_are_suffix; + if (self.order_sensitive || run_order_sensitive || self.mesh_edge) + && !preserves_submission_order + { + return false; + } + if self.inst_tail + inst_len > self.inst_cap { + return false; + } + self.vacant.remove(&handle); + if let Some(slab) = self.slabs.remove(&handle) { + let blanks = + vec![blank_packed_instance(); slab.inst_len as usize]; + self.write_insts(queue, slab.inst_off, &blanks); + self.tombstoned += slab.inst_len; + } + let inst_off = self.inst_tail; + self.write_insts(queue, inst_off, &insts); + self.inst_tail += inst_len; + self.slabs.insert( + handle, + Slab { + inst_off, + inst_len, + const_off: 0, + const_len: 0, + aabb, + base_depth, + }, + ); + self.order_sensitive |= run_order_sensitive; + } + + self.tombstoned <= self.inst_tail / 2 + } + + fn wire_gpus(&self) -> Vec { + if self.inst_tail == 0 { + return Vec::new(); + } + vec![WireGpu { + instance_buffer: self.inst_buf.clone(), + first_instance: 0, + instance_count: self.inst_tail, + is_3d_mesh_edge: self.mesh_edge, + const_bind_group: None, + }] + } + + fn wire_gpus_visible( + &self, + view_rot: glam::Mat4, + eye: glam::DVec3, + clip_w: u32, + clip_h: u32, + ) -> Vec { + if self.inst_tail == 0 { + return Vec::new(); + } + let projected_x = view_rot.transform_vector3(glam::Vec3::X); + let projected_y = view_rot.transform_vector3(glam::Vec3::Y); + let projected_z = view_rot.transform_vector3(glam::Vec3::Z); + let xy_scale = projected_x + .truncate() + .length() + .max(projected_y.truncate().length()) + .max(f32::MIN_POSITIVE); + if projected_z.truncate().length() > xy_scale * 1e-5 { + return self.wire_gpus(); + } + + visible_ranges(&self.slabs, view_rot, eye, clip_w, clip_h) + .into_iter() + .map(|(start, end)| WireGpu { + instance_buffer: self.inst_buf.clone(), + first_instance: start, + instance_count: end - start, + is_3d_mesh_edge: self.mesh_edge, + const_bind_group: None, + }) + .collect() + } +} + +enum PersistentWireArenaKind { + Indexed(WireArena), + Packed(PackedWireArena), +} + +/// Capability-selected arena façade. Callers work with one lifecycle while the +/// adapter preserves the best GPU representation the device supports. +pub struct PersistentWireArena { + inner: PersistentWireArenaKind, +} + +impl PersistentWireArena { + pub fn build( + device: &wgpu::Device, + queue: &wgpu::Queue, + wires: &[&WireModel], + depth_map: &FxHashMap, + const_bgl: Option<&wgpu::BindGroupLayout>, + mesh_edge: bool, + ) -> Option { + let inner = if let Some(const_bgl) = const_bgl { + PersistentWireArenaKind::Indexed(WireArena::build( + device, queue, wires, depth_map, const_bgl, mesh_edge, + )?) + } else { + PersistentWireArenaKind::Packed(PackedWireArena::build( + device, wires, depth_map, mesh_edge, + )?) + }; + Some(Self { inner }) + } + + pub fn patch( + &mut self, + queue: &wgpu::Queue, + changes: &[(Handle, ChangeKind)], + runs: &FxHashMap>, + new_handles_are_suffix: bool, + depth_map: &FxHashMap, + ) -> bool { + match &mut self.inner { + PersistentWireArenaKind::Indexed(arena) => arena.patch( + queue, + changes, + runs, + new_handles_are_suffix, + depth_map, + ), + PersistentWireArenaKind::Packed(arena) => arena.patch( + queue, + changes, + runs, + new_handles_are_suffix, + depth_map, + ), + } + } + + pub fn wire_gpus(&self) -> Vec { + match &self.inner { + PersistentWireArenaKind::Indexed(arena) => arena.wire_gpus(), + PersistentWireArenaKind::Packed(arena) => arena.wire_gpus(), + } + } + + pub fn wire_gpus_visible( + &self, + view_rot: glam::Mat4, + eye: glam::DVec3, + clip_w: u32, + clip_h: u32, + ) -> Vec { + match &self.inner { + PersistentWireArenaKind::Indexed(arena) => { + arena.wire_gpus_visible(view_rot, eye, clip_w, clip_h) + } + PersistentWireArenaKind::Packed(arena) => { + arena.wire_gpus_visible(view_rot, eye, clip_w, clip_h) + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -936,4 +1462,42 @@ mod tests { 2, )); } + + #[test] + fn packed_terminal_resize_obeys_order_and_capacity() { + assert!(can_resize_packed_terminal_slab( + &slab(), + 110, + 1000, + 18, + 1, + )); + assert!(!can_resize_packed_terminal_slab( + &slab(), + 111, + 1000, + 18, + 1, + )); + assert!(!can_resize_packed_terminal_slab( + &slab(), + 110, + 117, + 18, + 1, + )); + assert!(!can_resize_packed_terminal_slab( + &slab(), + 110, + 1000, + 18, + 2, + )); + } + + #[test] + fn tombstones_use_the_shader_discard_sentinel() { + assert!(blank_const().pattern_length < 0.0); + assert!(blank_packed_instance().pattern_length < 0.0); + } } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 3d7c07f1..9a034e5b 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -58,7 +58,7 @@ fn instance_buffer_mapped( // ── Instance layout ─────────────────────────────────────────────────────── -// ── Native: slim per-segment instance + shared per-wire constants ─────────── +// ── Storage path: slim per-segment instance + shared constants ────────────── // // Every segment of a wire used to carry the wire's color / line-weight / dash // pattern / draw-depth (~44 B) on each instance — re-fetched once per segment @@ -67,9 +67,8 @@ fn instance_buffer_mapped( // keeps only the per-segment data (endpoints + arc-length distances). Cuts the // instance from 104 B to one 64-byte cache line and removes the redundant // per-segment re-fetch of the shared constants. WebGL2 has no vertex-stage -// storage buffers, so the wasm build below keeps the original self-contained -// fat instance. -#[cfg(not(target_arch = "wasm32"))] +// storage buffers, so the compatibility path keeps the self-contained fat +// instance. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct WireInstance { @@ -88,7 +87,6 @@ pub struct WireInstance { pub taper_ratio: [u16; 2], } -#[cfg(not(target_arch = "wasm32"))] impl WireInstance { pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { // Must match `InstanceIn` in wire_indexed.wgsl. @@ -110,11 +108,10 @@ impl WireInstance { } } -/// Per-wire constants shared by every segment of a wire (native only). std430 +/// Per-wire constants shared by every segment of a wire (storage path). std430 /// layout: three vec4 then eight scalars = 80 B, matching `WireConst` in /// wire_indexed.wgsl. `align_end` / `align_total` carry the "A"-type endpoint /// alignment (see `wire_distances`); 0.0 total = no alignment. -#[cfg(not(target_arch = "wasm32"))] #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct WireConst { @@ -135,7 +132,6 @@ pub struct WireConst { pub _pad2: f32, } -#[cfg(not(target_arch = "wasm32"))] impl WireConst { /// Bind-group layout for the per-wire storage buffer (group 1 of the wire / /// xray pipelines). Read-only storage, visible to the vertex stage. @@ -158,8 +154,8 @@ impl WireConst { // ── Packed compatibility instance (no vertex-stage storage) ──────────────── // -// Web always uses this layout. Native selects it at runtime for adapters whose -// storage-buffer limits are insufficient, or when --compat-renderer is set. +// Selected at runtime for devices whose storage-buffer limits are insufficient, +// or when --compat-renderer is set. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct PackedWireInstance { @@ -229,15 +225,15 @@ impl PackedWireInstance { /// attributes and hatch data in a texture. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WirePipelineMode { - #[cfg(not(target_arch = "wasm32"))] IndexedStorage, Packed, } -#[cfg(not(target_arch = "wasm32"))] -fn select_native_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> WirePipelineMode { - const REQUIRED_STORAGE_BUFFERS_PER_STAGE: u32 = 5; - if forced || max_storage_buffers_per_stage < REQUIRED_STORAGE_BUFFERS_PER_STAGE { +fn select_pipeline( + capabilities: super::device_capabilities::DeviceCapabilities, + forced: bool, +) -> WirePipelineMode { + if forced || !capabilities.supports_wire_storage() { WirePipelineMode::Packed } else { WirePipelineMode::IndexedStorage @@ -245,24 +241,15 @@ fn select_native_pipeline(max_storage_buffers_per_stage: u32, forced: bool) -> W } impl WirePipelineMode { - pub fn select(device: &wgpu::Device) -> Self { - #[cfg(target_arch = "wasm32")] - { - let _ = device; - Self::Packed - } - #[cfg(not(target_arch = "wasm32"))] - { - select_native_pipeline( - device.limits().max_storage_buffers_per_shader_stage, - crate::cli::gui_config().compat_renderer, - ) - } + pub fn select( + capabilities: super::device_capabilities::DeviceCapabilities, + forced: bool, + ) -> Self { + select_pipeline(capabilities, forced) } pub fn uses_storage(self) -> bool { match self { - #[cfg(not(target_arch = "wasm32"))] Self::IndexedStorage => true, Self::Packed => false, } @@ -270,7 +257,6 @@ impl WirePipelineMode { pub fn layout<'a>(self) -> wgpu::VertexBufferLayout<'a> { match self { - #[cfg(not(target_arch = "wasm32"))] Self::IndexedStorage => WireInstance::layout(), Self::Packed => PackedWireInstance::layout(), } @@ -444,7 +430,7 @@ fn finite3(p: [f32; 3]) -> bool { } /// Emit packed per-segment instances (each carries the wire's constants). -fn emit_wire_packed( +pub(crate) fn emit_wire_packed( wire: &WireModel, color: [f32; 4], draw_depth: f32, @@ -491,9 +477,8 @@ fn emit_wire_packed( instances } -/// Native: emit slim per-segment instances (positions + distances + `wire_id`) +/// Storage path: emit slim instances (positions + distances + `wire_id`) /// plus the one `WireConst` record every segment of this wire shares. -#[cfg(not(target_arch = "wasm32"))] pub(crate) fn emit_wire_native( wire: &WireModel, wire_id: u32, @@ -577,10 +562,9 @@ pub(crate) fn wire_draw_depth( } /// Build the shared per-wire `WireConst` storage buffer and its bind group -/// (native only). All instance-buffer chunks from one build reference the same +/// (storage path). All chunks from one build reference the same /// buffer via their global `wire_id`, so a single bind group is cloned into /// each chunk. -#[cfg(not(target_arch = "wasm32"))] fn build_const_bind_group( device: &wgpu::Device, bgl: &wgpu::BindGroupLayout, @@ -617,7 +601,6 @@ impl WireGpu { depth_map: &rustc_hash::FxHashMap, const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { - #[cfg(not(target_arch = "wasm32"))] if let Some(const_bgl) = const_bgl { const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::(); @@ -685,18 +668,52 @@ impl WireGpu { .collect() } - /// Native-only equivalent of [`from_run`] for an already partitioned set - /// of borrowed wires. Used when one arena partition exceeds the 256 MB - /// buffer limit: the compatible partition stays patchable while only the - /// oversized side uses chunked resident buffers. - #[cfg(not(target_arch = "wasm32"))] + /// Equivalent of [`from_run`] for an already partitioned set of borrowed + /// wires. Used when one arena partition exceeds the 256 MB buffer limit: + /// the compatible partition stays patchable while only the oversized side + /// uses chunked resident buffers. pub fn from_run_refs( device: &wgpu::Device, wires: &[&WireModel], depth_map: &rustc_hash::FxHashMap, mesh_edge: bool, - const_bgl: &wgpu::BindGroupLayout, + const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { + let Some(const_bgl) = const_bgl else { + const MAX_INSTANCES: usize = + 268_435_456 / std::mem::size_of::(); + use crate::par::prelude::*; + let per: Vec> = wires + .par_iter() + .map(|&wire| { + let depth = if mesh_edge { + 0.0 + } else { + wire_draw_depth(wire, depth_map) + }; + emit_wire_packed(wire, wire.color, depth) + }) + .collect(); + let mut instances = + Vec::with_capacity(per.iter().map(Vec::len).sum()); + for mut items in per { + instances.append(&mut items); + } + return instances + .chunks(MAX_INSTANCES) + .map(|chunk| Self { + instance_buffer: instance_buffer_mapped( + device, + "wire.run.hybrid.compat.ibuf", + chunk, + ), + first_instance: 0, + instance_count: chunk.len() as u32, + is_3d_mesh_edge: mesh_edge, + const_bind_group: None, + }) + .collect(); + }; const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::(); use crate::par::prelude::*; @@ -752,7 +769,6 @@ impl WireGpu { mesh_edge: bool, const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { - #[cfg(not(target_arch = "wasm32"))] if let Some(const_bgl) = const_bgl { const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::(); @@ -844,7 +860,6 @@ impl WireGpu { if total_segs == 0 { return vec![]; } - #[cfg(not(target_arch = "wasm32"))] if let Some(const_bgl) = const_bgl { // GPU max buffer size is 256 MB; chunk to stay within the limit. const MAX_INSTANCES: usize = diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 5ba3770d..560a7512 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -278,10 +278,7 @@ impl shader::Primitive for Primitive { inner.cached_mesh_source = None; inner.cached_face3d_source = None; inner.cached_face3d_depth_source = None; - #[cfg(not(target_arch = "wasm32"))] - { - inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); - } + inner.wire_cull_key = (u64::MAX, u64::MAX, 0, 0); inner.hatch_lod_key = (usize::MAX, u64::MAX, 0, 0, false); inner.wipeout_lod_key = (usize::MAX, u64::MAX, 0, 0, false); inner.mesh_lod_key = (usize::MAX, u64::MAX, 0, 0); @@ -468,20 +465,27 @@ impl shader::Primitive for Primitive { // the whole wire buffer. Only for the scissor-free, mesh-free // (single-batch) Model set; scissored paper viewports and mixed // 2D/3D sets fall through to the shared batched path below. - #[cfg(not(target_arch = "wasm32"))] let mut arena_served = false; - #[cfg(target_arch = "wasm32")] - let arena_served = false; - #[cfg(not(target_arch = "wasm32"))] let _perf = crate::perf::enabled(); - #[cfg(not(target_arch = "wasm32"))] let _t0 = iced::time::Instant::now(); - #[cfg(not(target_arch = "wasm32"))] let mut _patched = false; - #[cfg(not(target_arch = "wasm32"))] - if crate::scene::wire_gpu_patch_enabled() && inner.wire_const_bgl.is_some() { - use crate::scene::pipeline::wire_arena::{self, WireArena}; - let bgl = inner.wire_const_bgl.as_ref().unwrap(); + // Storage arenas preserve the existing per-slot fast path. + // Packed arenas start only after the first edit (cold-open keeps + // the exact-sized shared buffer), and one slot owns each shared + // content id so split panes do not duplicate 1.5× headroom. + let packed_arena_owner = self.viewports[..i] + .iter() + .all(|other| other.wire_content_id != vp.wire_content_id); + let use_wire_arena = crate::scene::wire_gpu_patch_enabled() + && (inner.wire_const_bgl.is_some() + || ((vp.wire_patch.is_some() + || inner.wire_arena_id != u64::MAX) + && packed_arena_owner)); + if use_wire_arena { + use crate::scene::pipeline::wire_arena::{ + self, PersistentWireArena as WireArena, + }; + let const_bgl = inner.wire_const_bgl.as_ref(); let base_ok = vp .wire_patch .as_ref() @@ -601,7 +605,7 @@ impl shader::Primitive for Primitive { queue, ®ular, &draw_depths, - bgl, + const_bgl, false, ); if inner.wire_arena.is_none() && !regular.is_empty() { @@ -611,7 +615,7 @@ impl shader::Primitive for Primitive { ®ular, &draw_depths, false, - bgl, + const_bgl, ), ); inner.wire_arena_fallback_kind = Some(false); @@ -637,7 +641,7 @@ impl shader::Primitive for Primitive { queue, &mesh, &draw_depths, - bgl, + const_bgl, true, ); if inner.wire_arena_mesh.is_none() && !mesh.is_empty() { @@ -647,7 +651,7 @@ impl shader::Primitive for Primitive { &mesh, &draw_depths, true, - bgl, + const_bgl, ), ); inner.wire_arena_fallback_kind = Some(true); @@ -712,6 +716,17 @@ impl shader::Primitive for Primitive { inner.wire_arena_fallback_handles.clear(); inner.wire_arena_id = u64::MAX; } + } else if inner.wire_const_bgl.is_none() { + // This packed slot is no longer the owner of its shared + // content. Drop stale arena state before the shared-cache + // buffer is installed; otherwise the camera-cull refresh + // below could resurrect its old draw ranges. + inner.wire_arena = None; + inner.wire_arena_mesh = None; + inner.wire_arena_fallback = std::sync::Arc::new(Vec::new()); + inner.wire_arena_fallback_kind = None; + inner.wire_arena_fallback_handles.clear(); + inner.wire_arena_id = u64::MAX; } // Share one copy of the resident wire buffers across every slot // (and every pane — one MultiPipeline backs them all) rendering @@ -748,7 +763,6 @@ impl shader::Primitive for Primitive { inner.wire_handle_index = built.1; } // end !arena_served inner.cached_wire_id = vp.wire_content_id; - #[cfg(not(target_arch = "wasm32"))] if _perf { let gi: u32 = inner.gpu_wires.iter().map(|w| w.instance_count).sum(); let outcome = if !arena_served { @@ -902,46 +916,43 @@ impl shader::Primitive for Primitive { ); inner.mesh_lod_key = mesh_lod_key; } - #[cfg(not(target_arch = "wasm32"))] + let cull_key = ( + vp.wire_content_id, + vp.camera_generation, + clip_size.width, + clip_size.height, + ); + if inner.wire_arena_id == vp.wire_content_id + && inner.wire_cull_key != cull_key { - let cull_key = ( - vp.wire_content_id, - vp.camera_generation, - clip_size.width, - clip_size.height, - ); - if inner.wire_arena_id == vp.wire_content_id - && inner.wire_cull_key != cull_key - { - let mut visible = if inner.wire_arena_fallback_kind == Some(false) { - inner.wire_arena_fallback.as_ref().clone() - } else { - inner - .wire_arena - .as_ref() - .map(|arena| { - arena.wire_gpus_visible( - view_rot, - eye, - clip_size.width, - clip_size.height, - ) - }) - .unwrap_or_default() - }; - if inner.wire_arena_fallback_kind == Some(true) { - visible.extend(inner.wire_arena_fallback.iter().cloned()); - } else if let Some(arena) = inner.wire_arena_mesh.as_ref() { - visible.extend(arena.wire_gpus_visible( - view_rot, - eye, - clip_size.width, - clip_size.height, - )); - } - inner.gpu_wires = std::sync::Arc::new(visible); - inner.wire_cull_key = cull_key; + let mut visible = if inner.wire_arena_fallback_kind == Some(false) { + inner.wire_arena_fallback.as_ref().clone() + } else { + inner + .wire_arena + .as_ref() + .map(|arena| { + arena.wire_gpus_visible( + view_rot, + eye, + clip_size.width, + clip_size.height, + ) + }) + .unwrap_or_default() + }; + if inner.wire_arena_fallback_kind == Some(true) { + visible.extend(inner.wire_arena_fallback.iter().cloned()); + } else if let Some(arena) = inner.wire_arena_mesh.as_ref() { + visible.extend(arena.wire_gpus_visible( + view_rot, + eye, + clip_size.width, + clip_size.height, + )); } + inner.gpu_wires = std::sync::Arc::new(visible); + inner.wire_cull_key = cull_key; } if vp.show_viewcube { inner.viewcube.upload( diff --git a/src/shaders/hatch_web.wgsl b/src/shaders/hatch_web.wgsl index 33a474e8..3899f1bd 100644 --- a/src/shaders/hatch_web.wgsl +++ b/src/shaders/hatch_web.wgsl @@ -1,12 +1,12 @@ // Hatch shader (WebGL2) — texture-backed, UNCAPPED variant of wipeout.wgsl. // -// WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer -// renderer (hatch.wgsl) is disabled on wasm. This shader keeps the exact +// Devices without vertex/fragment storage buffers cannot use the batched +// storage-buffer renderer (hatch.wgsl). This shader keeps the exact // wipeout.wgsl hatch algorithm (in_polygon + per-family line/dash/dot + solid + // gradient) but reads the variable-length boundary / family / dash arrays from a // single RGBA32F data texture via textureLoad instead of fixed-size uniforms — // removing the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS (1024) / MAX_DASHES -// (128) caps of the uniform path. Native (hatch.wgsl) is untouched. +// (128) caps of the uniform path. // // Data texture layout (row-major, width = h.tex_width, one vec4 per texel): // [ 0 .. vcount ) boundary verts, .xy = local XY, NaN = break diff --git a/src/shaders/mesh.wgsl b/src/shaders/mesh.wgsl index 4d4258b2..9ab66db2 100644 --- a/src/shaders/mesh.wgsl +++ b/src/shaders/mesh.wgsl @@ -112,6 +112,35 @@ struct VertexOut { @location(15) uv_normal: vec2, }; +struct EdgeVertexIn { + @location(0) position: vec3, + @location(2) color: vec4, + @location(3) position_low: vec3, +}; + +struct EdgeVertexOut { + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, +}; + +fn relative_position( + position: vec3, + position_low: vec3, + instance: MeshInstance, +) -> vec3 { + let world_high = vec3( + dot(instance.model_row_0.xyz, position) + instance.model_row_0.w, + dot(instance.model_row_1.xyz, position) + instance.model_row_1.w, + dot(instance.model_row_2.xyz, position) + instance.model_row_2.w, + ); + let world_low = vec3( + dot(instance.model_row_0.xyz, position_low), + dot(instance.model_row_1.xyz, position_low), + dot(instance.model_row_2.xyz, position_low), + ) + instance.translation_low.xyz; + return (world_high - u.eye_high) + (world_low - u.eye_low); +} + @vertex fn vs_main( v: VertexIn, @@ -119,17 +148,7 @@ fn vs_main( ) -> VertexOut { var out: VertexOut; let instance = mesh_instances[instance_index]; - let world_high = vec3( - dot(instance.model_row_0.xyz, v.position) + instance.model_row_0.w, - dot(instance.model_row_1.xyz, v.position) + instance.model_row_1.w, - dot(instance.model_row_2.xyz, v.position) + instance.model_row_2.w, - ); - let world_low = vec3( - dot(instance.model_row_0.xyz, v.position_low), - dot(instance.model_row_1.xyz, v.position_low), - dot(instance.model_row_2.xyz, v.position_low), - ) + instance.translation_low.xyz; - let rel = (world_high - u.eye_high) + (world_low - u.eye_low); + let rel = relative_position(v.position, v.position_low, instance); out.clip_pos = u.view_rot * vec4(rel, 1.0); out.color = v.color; out.normal = normalize(vec3( @@ -154,6 +173,19 @@ fn vs_main( return out; } +@vertex +fn vs_edge( + v: EdgeVertexIn, + @builtin(instance_index) instance_index: u32, +) -> EdgeVertexOut { + var out: EdgeVertexOut; + let instance = mesh_instances[instance_index]; + let rel = relative_position(v.position, v.position_low, instance); + out.clip_pos = u.view_rot * vec4(rel, 1.0); + out.color = v.color; + return out; +} + @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { var n: vec3; @@ -327,14 +359,14 @@ fn fs_main(in: VertexOut) -> @location(0) vec4 { // Edge fragment (LineList): flat entity colour, no lighting. Used for the // wireframe and hidden-line edge passes so lines read at their true colour. @fragment -fn fs_edge(in: VertexOut) -> @location(0) vec4 { +fn fs_edge(in: EdgeVertexOut) -> @location(0) vec4 { return vec4(in.color.rgb, 1.0); } // Edge fragment for filled render modes: force black so edges frame the shaded // fill regardless of the solid's colour. @fragment -fn fs_edge_black(in: VertexOut) -> @location(0) vec4 { +fn fs_edge_black(_in: EdgeVertexOut) -> @location(0) vec4 { return vec4(0.0, 0.0, 0.0, 1.0); } diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index f6a52465..c684aef5 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -263,6 +263,11 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { } @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + // Negative pattern length is the persistent-arena tombstone sentinel. + // Discard before cap/alpha work so deleted slabs cannot write color/depth. + if in.pattern_length < 0.0 { + discard; + } if cap_clipped(in.cap, in.cap_ends) { discard; } @@ -287,6 +292,9 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { // mesh reads as a shaded surface framed by black edges. Keeps the dash/LOD // logic identical to `fs_main`; only the RGB is forced to black. @fragment fn fs_black(in: VertexOut) -> @location(0) vec4 { + if in.pattern_length < 0.0 { + discard; + } if cap_clipped(in.cap, in.cap_ends) { discard; } diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index 7693c950..9e1bd174 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -220,6 +220,11 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { } @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + // Negative pattern length is the persistent-arena tombstone sentinel. + // Discard before cap/alpha work so deleted slabs cannot write color/depth. + if in.pattern_length < 0.0 { + discard; + } if cap_clipped(in.cap, in.cap_ends) { discard; } @@ -238,6 +243,9 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { // mesh reads as a shaded surface framed by black edges. Keeps the dash/LOD // logic identical to `fs_main`; only the RGB is forced to black. @fragment fn fs_black(in: VertexOut) -> @location(0) vec4 { + if in.pattern_length < 0.0 { + discard; + } if cap_clipped(in.cap, in.cap_ends) { discard; } diff --git a/tests/mesh_shader_limits.rs b/tests/mesh_shader_limits.rs new file mode 100644 index 00000000..ddcb5880 --- /dev/null +++ b/tests/mesh_shader_limits.rs @@ -0,0 +1,68 @@ +use naga::{Binding, ShaderStage, TypeInner}; + +const MESH_SHADER: &str = include_str!("../src/shaders/mesh.wgsl"); +const WEBGL_INTER_STAGE_COMPONENT_LIMIT: u32 = 31; + +fn validate(source: &str) -> naga::Module { + let module = naga::front::wgsl::parse_str(source).expect("mesh WGSL must parse"); + naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::empty(), + ) + .validate(&module) + .expect("mesh WGSL must validate"); + module +} + +fn location_components(module: &naga::Module, type_name: &str) -> u32 { + let ty = module + .types + .iter() + .find_map(|(_, ty)| (ty.name.as_deref() == Some(type_name)).then_some(ty)) + .unwrap_or_else(|| panic!("{type_name} missing")); + let TypeInner::Struct { members, .. } = &ty.inner else { + panic!("{type_name} must remain a struct"); + }; + members + .iter() + .filter(|member| matches!(member.binding, Some(Binding::Location { .. }))) + .map(|member| match module.types[member.ty].inner { + TypeInner::Scalar(_) => 1, + TypeInner::Vector { size, .. } => u32::from(size), + ref other => panic!("unsupported inter-stage type: {other:?}"), + }) + .sum() +} + +fn assert_edge_contract(source: &str) { + let module = validate(source); + assert!(module + .entry_points + .iter() + .any(|entry| entry.stage == ShaderStage::Vertex && entry.name == "vs_edge")); + assert!(module + .entry_points + .iter() + .any(|entry| entry.stage == ShaderStage::Fragment && entry.name == "fs_edge")); + + let components = location_components(&module, "EdgeVertexOut"); + assert_eq!(components, 4, "edge output should carry only RGBA color"); + assert!( + components <= WEBGL_INTER_STAGE_COMPONENT_LIMIT, + "edge output exceeds WebGL2's inter-stage component budget" + ); +} + +#[test] +fn native_mesh_edge_shader_stays_webgl_compatible() { + assert_edge_contract(MESH_SHADER); +} + +#[test] +fn storage_free_mesh_edge_shader_stays_webgl_compatible() { + let compat = MESH_SHADER.replace( + "var mesh_instances: array;", + "var mesh_instances: array;", + ); + assert_edge_contract(&compat); +}