From 0317dad14e01449f43b006e45fd5b32f6aa95545 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 26 Jun 2026 10:39:23 +0300 Subject: [PATCH] perf(render): hoist per-wire constants to a storage buffer (native) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every wire segment is one instance, and each carried the wire's color, line-weight, dash pattern and draw-depth (~44 B) inline — re-fetched per segment even though they're constant along the wire. On native, move those into a per-wire `WireConst` storage buffer (group 1) indexed by a `wire_id` on the instance. The instance drops from 104 B to 60 B (~42 %) across every entity's wires, and the shared constants are fetched once per wire (cached) instead of once per segment — less vertex-attribute bandwidth and less GPU upload on rebuilds. WebGL2 has no vertex-stage storage buffers, so the wasm build keeps the original self-contained fat instance and wire.wgsl; native uses the new slim instance, wire_indexed.wgsl, and a group-1 bind group built per batch in WireGpu::from_run / from_batch. Visual output is unchanged (colors are now full f32 rather than 8-bit, if anything more precise). Co-Authored-By: Claude Opus 4.8 --- src/scene/pipeline/mod.rs | 48 +++- src/scene/pipeline/wire_gpu.rs | 420 +++++++++++++++++++++++++-------- src/shaders/wire_indexed.wgsl | 151 ++++++++++++ 3 files changed, 516 insertions(+), 103 deletions(-) create mode 100644 src/shaders/wire_indexed.wgsl diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 1c8fe9fb0..60cc182dc 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -32,6 +32,11 @@ pub struct Pipeline { /// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false. /// 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` on native; `None` on web (fat instance, no + /// storage). Passed to `WireGpu::from_run` / `from_batch` so they can build + /// each batch's per-wire bind group. + wire_const_bgl: Option, hatch_pipeline: wgpu::RenderPipeline, /// Phase 4-B — single-draw batched hatch pipeline. Per-instance /// data lives in storage buffers; one draw call covers every @@ -181,9 +186,19 @@ impl Pipeline { }); // ── Wire pipeline ────────────────────────────────────────────────── + // Native: per-wire constants live in a storage buffer (group 1), so the + // slim instance only carries per-segment data. WebGL2 has no + // vertex-stage storage buffers, so the wasm build keeps the fat instance + // and a single bind group. + #[cfg(not(target_arch = "wasm32"))] + let wire_const_bgl = wire_gpu::WireConst::bind_group_layout(device); + #[cfg(not(target_arch = "wasm32"))] + let wire_bgls: &[&wgpu::BindGroupLayout] = &[&frame_bgl, &wire_const_bgl]; + #[cfg(target_arch = "wasm32")] + let wire_bgls: &[&wgpu::BindGroupLayout] = &[&frame_bgl]; let wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("wire.pipeline_layout"), - bind_group_layouts: &[&frame_bgl], + bind_group_layouts: wire_bgls, push_constant_ranges: &[], }); @@ -192,6 +207,11 @@ impl Pipeline { let wire_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("wire.shader"), + #[cfg(not(target_arch = "wasm32"))] + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/wire_indexed.wgsl" + ))), + #[cfg(target_arch = "wasm32")] source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( "../../shaders/wire.wgsl" ))), @@ -909,6 +929,10 @@ impl Pipeline { Self { wire_pipeline, wire_xray_pipeline, + #[cfg(not(target_arch = "wasm32"))] + wire_const_bgl: Some(wire_const_bgl), + #[cfg(target_arch = "wasm32")] + wire_const_bgl: None, hatch_pipeline, hatch_batched_pipeline, image_pipeline, @@ -983,7 +1007,7 @@ impl Pipeline { { j += 1; } - batches.extend(WireGpu::from_run(device, &wires[i..j], depth_map, scissor, mesh_edge)); + batches.extend(WireGpu::from_run(device, &wires[i..j], depth_map, scissor, mesh_edge, self.wire_const_bgl.as_ref())); i = j; } self.gpu_wires = batches; @@ -1044,7 +1068,8 @@ impl Pipeline { if let Some(h) = hover { push(h.value(), WireModel::HOVER, wires, &mut out); } - self.gpu_selected_wires = WireGpu::from_run(device, &out, depth_map, None, false); + self.gpu_selected_wires = + WireGpu::from_run(device, &out, depth_map, None, false, self.wire_const_bgl.as_ref()); } /// Upload the live overlay (command preview / interim / grip-drag) wires. @@ -1059,7 +1084,7 @@ impl Pipeline { self.gpu_preview_wires = if wires.is_empty() { vec![] } else { - WireGpu::from_run(device, wires, depth_map, None, false) + WireGpu::from_run(device, wires, depth_map, None, false, self.wire_const_bgl.as_ref()) }; } @@ -1141,7 +1166,8 @@ impl Pipeline { ) { // Edge buffer is always built from `face3d_wires`, so 3DFACE // outlines stay on the screen regardless of mode. - self.gpu_face3d_edges = WireGpu::from_batch(device, face3d_wires, depth_map); + self.gpu_face3d_edges = + WireGpu::from_batch(device, face3d_wires, depth_map, self.wire_const_bgl.as_ref()); // Fill buffer split: 3D quads + PolyfaceMesh / PolygonMesh face // tris go to `vertex_buffer_3d` (gated by `keep_3d_mesh_fills`); // 2D fills (text-LOD greek, MultiLeader background) go to @@ -1612,6 +1638,9 @@ impl Pipeline { pass.set_bind_group(0, &self.uniform_bind_group, &[]); for edges in &self.gpu_face3d_edges { if edges.instance_count > 0 { + if let Some(bg) = &edges.const_bind_group { + pass.set_bind_group(1, bg.as_ref(), &[]); + } pass.set_vertex_buffer(0, edges.instance_buffer.slice(..)); pass.draw(0..6, 0..edges.instance_count); } @@ -1670,6 +1699,9 @@ impl Pipeline { } _ => {} } + if let Some(bg) = &wire.const_bind_group { + pass.set_bind_group(1, bg.as_ref(), &[]); + } pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); pass.draw(0..6, 0..wire.instance_count); } @@ -1685,6 +1717,9 @@ impl Pipeline { pass.set_pipeline(&self.wire_xray_pipeline); for pw in &self.gpu_preview_wires { if pw.instance_count > 0 { + if let Some(bg) = &pw.const_bind_group { + pass.set_bind_group(1, bg.as_ref(), &[]); + } pass.set_vertex_buffer(0, pw.instance_buffer.slice(..)); pass.draw(0..6, 0..pw.instance_count); } @@ -1775,6 +1810,9 @@ impl Pipeline { pass.set_bind_group(0, &self.uniform_bind_group, &[]); for wire in &self.gpu_selected_wires { if wire.instance_count > 0 { + if let Some(bg) = &wire.const_bind_group { + pass.set_bind_group(1, bg.as_ref(), &[]); + } pass.set_vertex_buffer(0, wire.instance_buffer.slice(..)); pass.draw(0..6, 0..wire.instance_count); } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index b942dd07c..5c7079ad2 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -60,6 +60,90 @@ fn instance_buffer_mapped( // ── Instance layout ─────────────────────────────────────────────────────── +// ── Native: slim per-segment instance + shared per-wire 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 +// even though it's constant along the wire. On native we hoist those into a +// per-wire `WireConst` storage buffer indexed by `wire_id`, so the instance +// keeps only the per-segment data (endpoints + arc-length distances). Cuts the +// instance from 104 B to 60 B (~42 %) 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"))] +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct WireInstance { + pub pos_a: [f32; 3], + pub pos_a_low: [f32; 3], + pub pos_b: [f32; 3], + pub pos_b_low: [f32; 3], + pub distance_a: f32, + pub distance_b: f32, + /// Index into the per-wire `WireConst` storage buffer (group 1). + pub wire_id: u32, +} + +#[cfg(not(target_arch = "wasm32"))] +impl WireInstance { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + // Must match `InstanceIn` in wire_indexed.wgsl. + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_a) as u64, shader_location: 0, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_b) as u64, shader_location: 1, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_a_low) as u64, shader_location: 2, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_b_low) as u64, shader_location: 3, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_b) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, wire_id) as u64, shader_location: 6, format: wgpu::VertexFormat::Uint32 }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: ATTRS, + } + } +} + +/// Per-wire constants shared by every segment of a wire (native only). std430 +/// layout: three vec4 then four scalars = 64 B, matching `WireConst` in +/// wire_indexed.wgsl. +#[cfg(not(target_arch = "wasm32"))] +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +pub struct WireConst { + pub color: [f32; 4], + pub pat0: [f32; 4], + pub pat1: [f32; 4], + pub half_width: f32, + pub pattern_length: f32, + pub draw_depth: f32, + pub _pad: 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. + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("wire_const.bgl"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + }) + } +} + +// ── Web (WebGL2): self-contained fat instance (no vertex-stage storage) ───── +#[cfg(target_arch = "wasm32")] #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] pub struct WireInstance { @@ -82,6 +166,7 @@ pub struct WireInstance { pub draw_depth: f32, } +#[cfg(target_arch = "wasm32")] impl WireInstance { pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { // Offsets come from the struct layout (must match the shader location @@ -123,12 +208,17 @@ pub struct WireGpu { /// in shaded modes so the surface reads as a clean solid; pure /// wireframe / HiddenLine / *WithEdges modes draw them. pub is_3d_mesh_edge: bool, + /// Per-wire constants storage (group 1), shared across all chunks of one + /// build. `None` on web (the fat instance carries the constants inline) and + /// for empty buffers. The draw loop binds it as group 1 when present. + pub const_bind_group: Option>, } /// Expand one `WireModel` into its per-segment instance stream (1 instance per /// finite segment). Pulled out so both the single-wire and batched paths share /// the same emission logic, and so the batched path can `par_iter` across /// wires on cold open. +#[cfg(target_arch = "wasm32")] fn pack_color(color: [f32; 4]) -> [u8; 4] { [ (color[0].clamp(0.0, 1.0) * 255.0 + 0.5) as u8, @@ -138,28 +228,10 @@ fn pack_color(color: [f32; 4]) -> [u8; 4] { ] } -fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Vec { - let color_u8 = pack_color(color); - let pat0 = [ - wire.pattern[0], - wire.pattern[1], - wire.pattern[2], - wire.pattern[3], - ]; - let pat1 = [ - wire.pattern[4], - wire.pattern[5], - wire.pattern[6], - wire.pattern[7], - ]; - let half_width = wire.line_weight_px * 0.5; - +/// Cumulative arc-length per point (NaN-break aware), with the dash pattern +/// centred on the wire. Shared by the wasm and native emission paths. +fn wire_distances(wire: &WireModel) -> Vec { let n = wire.points.len(); - let seg_count = n.saturating_sub(1); - if seg_count == 0 { - return Vec::new(); - } - let mut dists = vec![0.0_f32; n]; let mut has_break = false; for i in 1..n { @@ -206,22 +278,33 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve } } } + dists +} - // Low residual paired index-for-index with points; empty → all-zero - // (interactive / preview wires that don't need sub-f32 precision). +#[inline] +fn finite3(p: [f32; 3]) -> bool { + p[0].is_finite() && p[1].is_finite() && p[2].is_finite() +} + +/// Web: emit fat per-segment instances (each carries the wire's constants). +#[cfg(target_arch = "wasm32")] +fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Vec { + let color_u8 = pack_color(color); + let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; + let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + let half_width = wire.line_weight_px * 0.5; + let n = wire.points.len(); + let seg_count = n.saturating_sub(1); + if seg_count == 0 { + return Vec::new(); + } + let dists = wire_distances(wire); let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; let b = wire.points[i + 1]; - if !a[0].is_finite() - || !a[1].is_finite() - || !a[2].is_finite() - || !b[0].is_finite() - || !b[1].is_finite() - || !b[2].is_finite() - { + if !finite3(a) || !finite3(b) { continue; } instances.push(WireInstance { @@ -242,6 +325,51 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve instances } +/// Native: emit slim per-segment instances (positions + distances + `wire_id`) +/// plus the one `WireConst` record every segment of this wire shares. +#[cfg(not(target_arch = "wasm32"))] +fn emit_wire_native( + wire: &WireModel, + wire_id: u32, + color: [f32; 4], + draw_depth: f32, +) -> (Vec, WireConst) { + let cst = WireConst { + color, + pat0: [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]], + pat1: [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]], + half_width: wire.line_weight_px * 0.5, + pattern_length: wire.pattern_length, + draw_depth, + _pad: 0.0, + }; + let n = wire.points.len(); + let seg_count = n.saturating_sub(1); + if seg_count == 0 { + return (Vec::new(), cst); + } + let dists = wire_distances(wire); + let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; + let mut instances: Vec = Vec::with_capacity(seg_count); + for i in 0..seg_count { + let a = wire.points[i]; + let b = wire.points[i + 1]; + if !finite3(a) || !finite3(b) { + continue; + } + instances.push(WireInstance { + pos_a: a, + pos_a_low: low(i), + pos_b: b, + pos_b_low: low(i + 1), + distance_a: dists[i], + distance_b: dists[i + 1], + wire_id, + }); + } + (instances, cst) +} + /// Looks up a wire's draw-order depth from the per-entity map using the /// handle encoded in its `name`. Falls back to 0.0 (transient / preview /// wires that carry no document handle). @@ -254,6 +382,36 @@ fn wire_draw_depth(wire: &WireModel, depth_map: &rustc_hash::FxHashMap .unwrap_or(0.0) } +/// Build the shared per-wire `WireConst` storage buffer and its bind group +/// (native only). All instance-buffer 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, + consts: &[WireConst], +) -> std::sync::Arc { + use wgpu::util::DeviceExt; + // wgpu rejects zero-sized buffers; pad with one zeroed record when empty. + let one = [::zeroed()]; + let data: &[WireConst] = if consts.is_empty() { &one } else { consts }; + let buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("wire_const.buf"), + contents: bytemuck::cast_slice(data), + usage: wgpu::BufferUsages::STORAGE, + }); + let bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("wire_const.bg"), + layout: bgl, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buf.as_entire_binding(), + }], + }); + std::sync::Arc::new(bg) +} + impl WireGpu { /// Merge a run of WireModels that share scissor + mesh-edge state into one @@ -272,32 +430,76 @@ impl WireGpu { depth_map: &rustc_hash::FxHashMap, scissor: Option<[f32; 4]>, mesh_edge: bool, + const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { - use crate::par::prelude::*; const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::(); - let per: Vec> = wires - .par_iter() - .map(|w| emit_wire_instances(w, w.color, wire_draw_depth(w, depth_map))) - .collect(); - let mut instances: Vec = Vec::with_capacity(per.iter().map(Vec::len).sum()); - for mut v in per { - instances.append(&mut v); + #[cfg(not(target_arch = "wasm32"))] + { + use crate::par::prelude::*; + // Global `wire_id` = wire index; one shared WireConst buffer for all + // chunks. Indexed `collect` preserves wire order (the pass relies on + // submission order for depth-biased / transparent overlap). + let per: Vec<(Vec, WireConst)> = wires + .par_iter() + .enumerate() + .map(|(idx, w)| { + emit_wire_native(w, idx as u32, w.color, wire_draw_depth(w, depth_map)) + }) + .collect(); + let mut instances: Vec = + Vec::with_capacity(per.iter().map(|(v, _)| v.len()).sum()); + let mut consts: Vec = Vec::with_capacity(per.len()); + for (mut v, c) in per { + instances.append(&mut v); + consts.push(c); + } + if instances.is_empty() { + return vec![]; + } + let bg = const_bgl.map(|bgl| build_const_bind_group(device, bgl, &consts)); + instances + .chunks(MAX_INSTANCES) + .map(|chunk| { + let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk); + Self { + instance_buffer: buf, + instance_count: chunk.len() as u32, + vp_scissor: scissor, + is_3d_mesh_edge: mesh_edge, + const_bind_group: bg.clone(), + } + }) + .collect() } - if instances.is_empty() { - return vec![]; + #[cfg(target_arch = "wasm32")] + { + let _ = const_bgl; + let per: Vec> = wires + .iter() + .map(|w| emit_wire_instances(w, w.color, wire_draw_depth(w, depth_map))) + .collect(); + let mut instances: Vec = + Vec::with_capacity(per.iter().map(Vec::len).sum()); + for mut v in per { + instances.append(&mut v); + } + if instances.is_empty() { + return vec![]; + } + instances + .chunks(MAX_INSTANCES) + .map(|chunk| { + let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk); + Self { + instance_buffer: buf, + instance_count: chunk.len() as u32, + vp_scissor: scissor, + is_3d_mesh_edge: mesh_edge, + const_bind_group: None, + } + }) + .collect() } - instances - .chunks(MAX_INSTANCES) - .map(|chunk| { - let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk); - Self { - instance_buffer: buf, - instance_count: chunk.len() as u32, - vp_scissor: scissor, - is_3d_mesh_edge: mesh_edge, - } - }) - .collect() } /// Merge multiple WireModels into GPU instance buffers, chunked to fit the @@ -307,58 +509,80 @@ impl WireGpu { device: &wgpu::Device, wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, + const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { let total_segs: usize = wires.iter().map(|w| w.points.len().saturating_sub(1)).sum(); if total_segs == 0 { return vec![]; } - - // Parallel per-wire instance emission. Each wire's stream is - // independent — `block_cache` groups wires by style upstream, so order - // within a batch does not affect correctness. `reduce` concatenates the - // per-wire Vec chunks while letting rayon spread work - // across cores. - #[cfg(not(target_arch = "wasm32"))] - let instances: Vec = wires - .par_iter() - .map(|wire| emit_wire_instances(wire, wire.color, wire_draw_depth(wire, depth_map))) - .reduce(Vec::new, |mut acc, mut chunk| { - if acc.is_empty() { - chunk - } else if chunk.is_empty() { - acc - } else { - acc.append(&mut chunk); - acc - } - }); - // Web: no thread pool — concatenate the per-wire streams sequentially. - #[cfg(target_arch = "wasm32")] - let instances: Vec = wires - .iter() - .flat_map(|wire| emit_wire_instances(wire, wire.color, wire_draw_depth(wire, depth_map))) - .collect(); - - if instances.is_empty() { - return vec![]; - } - // GPU max buffer size is 256 MB; chunk to stay within the limit. const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::(); - instances - .chunks(MAX_INSTANCES) - .enumerate() - .map(|(i, chunk)| { - let label = format!("wire.batch.ibuf.{i}"); - let instance_buffer = instance_buffer_mapped(device, &label, chunk); - Self { - instance_buffer, - instance_count: chunk.len() as u32, - vp_scissor: None, - is_3d_mesh_edge: false, - } - }) - .collect() + #[cfg(not(target_arch = "wasm32"))] + { + use crate::par::prelude::*; + // `block_cache` groups wires by style upstream; order within a batch + // doesn't affect correctness, but indexed `collect` gives each wire a + // stable `wire_id` into the shared WireConst buffer. + let per: Vec<(Vec, WireConst)> = wires + .par_iter() + .enumerate() + .map(|(idx, w)| { + emit_wire_native(w, idx as u32, w.color, wire_draw_depth(w, depth_map)) + }) + .collect(); + let mut instances: Vec = + Vec::with_capacity(per.iter().map(|(v, _)| v.len()).sum()); + let mut consts: Vec = Vec::with_capacity(per.len()); + for (mut v, c) in per { + instances.append(&mut v); + consts.push(c); + } + if instances.is_empty() { + return vec![]; + } + let bg = const_bgl.map(|bgl| build_const_bind_group(device, bgl, &consts)); + instances + .chunks(MAX_INSTANCES) + .enumerate() + .map(|(i, chunk)| { + let label = format!("wire.batch.ibuf.{i}"); + let instance_buffer = instance_buffer_mapped(device, &label, chunk); + Self { + instance_buffer, + instance_count: chunk.len() as u32, + vp_scissor: None, + is_3d_mesh_edge: false, + const_bind_group: bg.clone(), + } + }) + .collect() + } + #[cfg(target_arch = "wasm32")] + { + let _ = const_bgl; + let instances: Vec = wires + .iter() + .flat_map(|w| emit_wire_instances(w, w.color, wire_draw_depth(w, depth_map))) + .collect(); + if instances.is_empty() { + return vec![]; + } + instances + .chunks(MAX_INSTANCES) + .enumerate() + .map(|(i, chunk)| { + let label = format!("wire.batch.ibuf.{i}"); + let instance_buffer = instance_buffer_mapped(device, &label, chunk); + Self { + instance_buffer, + instance_count: chunk.len() as u32, + vp_scissor: None, + is_3d_mesh_edge: false, + const_bind_group: None, + } + }) + .collect() + } } } diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl new file mode 100644 index 000000000..2e47d84ab --- /dev/null +++ b/src/shaders/wire_indexed.wgsl @@ -0,0 +1,151 @@ +// Wire shader (native) — same as wire.wgsl, but the per-wire constants +// (color / half_width / dash pattern / draw_depth) live in a per-wire storage +// buffer indexed by `wire_id` instead of being replicated on every segment +// instance. Cuts the instance from 104 B to 60 B and removes the redundant +// per-segment re-fetch of constants. WebGL2 has no vertex-stage storage +// buffers, so the wasm build uses wire.wgsl (fat instance) instead. + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + _pad: vec2, + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +// Per-wire constants (std430). Must match `WireConst` in wire_gpu.rs. +struct WireConst { + color: vec4, + pat0: vec4, + pat1: vec4, + half_width: f32, + pattern_length: f32, + draw_depth: f32, + _pad: f32, +} +@group(1) @binding(0) var wire_consts: array; + +struct InstanceIn { + @location(0) pos_a: vec3, + @location(1) pos_b: vec3, + @location(2) pos_a_low: vec3, + @location(3) pos_b_low: vec3, + @location(4) distance_a: f32, + @location(5) distance_b: f32, + @location(6) wire_id: u32, +} + +const DRAW_ORDER_BIAS: f32 = 0.001; + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) color: vec4, + @location(1) distance: f32, + @location(2) pattern_length: f32, + @location(3) pat0: vec4, + @location(4) pat1: vec4, + @location(5) @interpolate(flat) min_elem: f32, +} + +@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut { + let c = wire_consts[in.wire_id]; + + let which_end_arr = array(0.0, 1.0, 1.0, 0.0, 1.0, 0.0); + let side_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let which_end = which_end_arr[vid]; + let side = side_arr[vid]; + + let rel_a = (in.pos_a - u.eye_high) + (in.pos_a_low - u.eye_low); + let rel_b = (in.pos_b - u.eye_high) + (in.pos_b_low - u.eye_low); + let clip_a = u.view_rot * vec4(rel_a, 1.0); + let clip_b = u.view_rot * vec4(rel_b, 1.0); + + let ndc_a = clip_a.xy / clip_a.w; + let ndc_b = clip_b.xy / clip_b.w; + + let screen_a = ndc_a * u.viewport_size * 0.5; + let screen_b = ndc_b * u.viewport_size * 0.5; + + let seg = screen_b - screen_a; + let seg_len = length(seg); + var perp: vec2; + if seg_len > 1e-4 { + let dir = seg / seg_len; + perp = vec2(-dir.y, dir.x); + } else { + perp = vec2(0.0, 1.0); + } + + let perp_ndc = perp / (u.viewport_size * 0.5); + + let clip_pos = mix(clip_a, clip_b, which_end); + + let hw = select(0.5, c.half_width, u.lwdisplay_enable > 0.5); + + let ndc_offset = perp_ndc * hw * side; + let final_clip = clip_pos + vec4(ndc_offset * clip_pos.w, 0.0, 0.0); + + var min_elem: f32 = c.pattern_length; + let elems = array( + c.pat0.x, c.pat0.y, c.pat0.z, c.pat0.w, + c.pat1.x, c.pat1.y, c.pat1.z, c.pat1.w, + ); + for (var i = 0u; i < 8u; i++) { + let e = abs(elems[i]); + if e > 0.0 && e < min_elem { min_elem = e; } + } + + var out: VertexOut; + out.clip_pos = final_clip; + out.clip_pos.z = out.clip_pos.z - c.draw_depth * DRAW_ORDER_BIAS * out.clip_pos.w; + out.color = c.color; + out.distance = mix(in.distance_a, in.distance_b, which_end); + out.pattern_length = c.pattern_length; + out.pat0 = c.pat0; + out.pat1 = c.pat1; + out.min_elem = min_elem; + return out; +} + +fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { + let d = ((dist % pat_len) + pat_len) % pat_len; + var pos = 0.0f; + let dot_half = u.world_per_pixel * 0.75; + let elems = array(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w); + var count = 0u; + for (var i = 0u; i < 8u; i++) { + if elems[i] != 0.0 { count = i + 1u; } + } + for (var i = 0u; i < count; i++) { + let elem = elems[i]; + if elem == 0.0 { + let dd = abs(d - pos); + if min(dd, pat_len - dd) <= dot_half { return true; } + } else if elem > 0.0 { + if d >= pos && d < pos + elem { return true; } + pos += elem; + } else { + pos += -elem; + } + } + return false; +} + +@fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + if in.pattern_length > 0.0 { + if in.min_elem >= u.world_per_pixel { + if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1) { + discard; + } + } + } + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5); + return vec4(in.color.rgb, alpha); +}