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.
This commit is contained in:
Hakan Seven 2026-07-29 12:49:28 +03:00
commit 93b9a840ff
16 changed files with 1010 additions and 181 deletions

1
Cargo.lock generated
View file

@ -36,6 +36,7 @@ dependencies = [
"log",
"lyon_tessellation",
"lzma-sys",
"naga",
"ocs_plugin_api",
"open",
"printpdf",

View file

@ -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).

View file

@ -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

View file

@ -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());
}
}

View file

@ -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 dozenshundreds 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:

View file

@ -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;

View file

@ -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::<MeshVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: ATTRS,
}
}
}
#[repr(C)]

View file

@ -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<wgpu::BindGroupLayout>,
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<Vec<WireGpu>>,
/// 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<wire_arena::WireArena>,
pub(crate) wire_arena: Option<wire_arena::PersistentWireArena>,
/// 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<wire_arena::WireArena>,
pub(crate) wire_arena_mesh: Option<wire_arena::PersistentWireArena>,
/// 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<Vec<WireGpu>>,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) wire_arena_fallback_kind: Option<bool>,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet<acadrust::Handle>,
/// 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<wgpu::BindGroupLayout> = 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),

View file

@ -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 {
<WireConst as bytemuck::Zeroable>::zeroed()
let mut blank = <WireConst as bytemuck::Zeroable>::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<Handle, Slab>,
vacant: FxHashMap<Handle, Slab>,
tombstoned: u32,
mesh_edge: bool,
order_sensitive: bool,
}
struct PreparedPackedPatchRun {
insts: Vec<PackedWireInstance>,
base_depth: f32,
aabb: [f32; 4],
order_sensitive: bool,
}
const MAX_PACKED_INSTANCES: u64 =
268_435_456 / std::mem::size_of::<PackedWireInstance>() as u64;
fn blank_packed_instance() -> PackedWireInstance {
let mut blank = <PackedWireInstance as bytemuck::Zeroable>::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::<PackedWireInstance>() 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<Handle, Slab>,
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<u64, [f32; 2]>,
mesh_edge: bool,
) -> Option<Self> {
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<PackedWireInstance>,
}
use crate::par::prelude::*;
let packed: Vec<PackedSlab> = 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::<PackedWireInstance>() 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<Handle, Vec<&WireModel>>,
new_handles_are_suffix: bool,
depth_map: &FxHashMap<u64, [f32; 2]>,
) -> bool {
let mut prepared: FxHashMap<Handle, PreparedPackedPatchRun> =
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<WireGpu> {
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<WireGpu> {
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<u64, [f32; 2]>,
const_bgl: Option<&wgpu::BindGroupLayout>,
mesh_edge: bool,
) -> Option<Self> {
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<Handle, Vec<&WireModel>>,
new_handles_are_suffix: bool,
depth_map: &FxHashMap<u64, [f32; 2]>,
) -> 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<WireGpu> {
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<WireGpu> {
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);
}
}

View file

@ -58,7 +58,7 @@ fn instance_buffer_mapped<T: bytemuck::Pod>(
// ── 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<T: bytemuck::Pod>(
// 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<u64, [f32; 2]>,
const_bgl: Option<&wgpu::BindGroupLayout>,
) -> Vec<Self> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(const_bgl) = const_bgl {
const MAX_INSTANCES: usize =
268_435_456 / std::mem::size_of::<WireInstance>();
@ -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<u64, [f32; 2]>,
mesh_edge: bool,
const_bgl: &wgpu::BindGroupLayout,
const_bgl: Option<&wgpu::BindGroupLayout>,
) -> Vec<Self> {
let Some(const_bgl) = const_bgl else {
const MAX_INSTANCES: usize =
268_435_456 / std::mem::size_of::<PackedWireInstance>();
use crate::par::prelude::*;
let per: Vec<Vec<PackedWireInstance>> = 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::<WireInstance>();
use crate::par::prelude::*;
@ -752,7 +769,6 @@ impl WireGpu {
mesh_edge: bool,
const_bgl: Option<&wgpu::BindGroupLayout>,
) -> Vec<Self> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(const_bgl) = const_bgl {
const MAX_INSTANCES: usize =
268_435_456 / std::mem::size_of::<WireInstance>();
@ -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 =

View file

@ -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.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,
&regular,
&draw_depths,
bgl,
const_bgl,
false,
);
if inner.wire_arena.is_none() && !regular.is_empty() {
@ -611,7 +615,7 @@ impl shader::Primitive for Primitive {
&regular,
&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,8 +916,6 @@ 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,
@ -942,7 +954,6 @@ impl shader::Primitive for Primitive {
inner.gpu_wires = std::sync::Arc::new(visible);
inner.wire_cull_key = cull_key;
}
}
if vp.show_viewcube {
inner.viewcube.upload(
queue,

View file

@ -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

View file

@ -112,6 +112,35 @@ struct VertexOut {
@location(15) uv_normal: vec2<f32>,
};
struct EdgeVertexIn {
@location(0) position: vec3<f32>,
@location(2) color: vec4<f32>,
@location(3) position_low: vec3<f32>,
};
struct EdgeVertexOut {
@builtin(position) clip_pos: vec4<f32>,
@location(0) color: vec4<f32>,
};
fn relative_position(
position: vec3<f32>,
position_low: vec3<f32>,
instance: MeshInstance,
) -> vec3<f32> {
let world_high = vec3<f32>(
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<f32>(
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<f32>(
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<f32>(
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<f32>(rel, 1.0);
out.color = v.color;
out.normal = normalize(vec3<f32>(
@ -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<f32>(rel, 1.0);
out.color = v.color;
return out;
}
@fragment
fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
var n: vec3<f32>;
@ -327,14 +359,14 @@ fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
// 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<f32> {
fn fs_edge(in: EdgeVertexOut) -> @location(0) vec4<f32> {
return vec4<f32>(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<f32> {
fn fs_edge_black(_in: EdgeVertexOut) -> @location(0) vec4<f32> {
return vec4<f32>(0.0, 0.0, 0.0, 1.0);
}

View file

@ -263,6 +263,11 @@ fn cap_clipped(cap: vec2<f32>, cap_ends: vec3<f32>) -> bool {
}
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
// 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<f32>, cap_ends: vec3<f32>) -> 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<f32> {
if in.pattern_length < 0.0 {
discard;
}
if cap_clipped(in.cap, in.cap_ends) {
discard;
}

View file

@ -220,6 +220,11 @@ fn cap_clipped(cap: vec2<f32>, cap_ends: vec3<f32>) -> bool {
}
@fragment fn fs_main(in: VertexOut) -> @location(0) vec4<f32> {
// 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<f32>, cap_ends: vec3<f32>) -> 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<f32> {
if in.pattern_length < 0.0 {
discard;
}
if cap_clipped(in.cap, in.cap_ends) {
discard;
}

View file

@ -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<storage, read> mesh_instances: array<MeshInstance>;",
"var<uniform> mesh_instances: array<MeshInstance, 1>;",
);
assert_edge_contract(&compat);
}