diff --git a/src/cli.rs b/src/cli.rs index b93e81a72..845c08dc5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -50,6 +50,11 @@ pub struct Cli { #[arg(long, visible_alias = "no-gpu")] pub safe_mode: bool, + /// Force the packed renderer path that avoids shader storage buffers. + /// Normally selected automatically for adapters with insufficient limits. + #[arg(long)] + pub compat_renderer: bool, + /// Run the headless JSON automation server (stdin/stdout, or --port). #[arg(long)] pub serve: bool, @@ -90,6 +95,8 @@ pub struct GuiConfig { pub new: bool, /// Saving disabled for this session. pub read_only: bool, + /// Force storage-buffer-free wire and hatch pipelines. + pub compat_renderer: bool, /// Command lines to run once the editor is up. pub script_lines: Vec, } diff --git a/src/main.rs b/src/main.rs index 1b1c737a6..6738a2ecc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -150,6 +150,7 @@ fn main() -> iced::Result { files: if args.new { Vec::new() } else { args.files }, new: args.new, read_only: args.read_only, + compat_renderer: args.compat_renderer, script_lines, }); diff --git a/src/scene/pipeline/hatch_web_gpu.rs b/src/scene/pipeline/hatch_web_gpu.rs index d1ceaae87..b20f3b847 100644 --- a/src/scene/pipeline/hatch_web_gpu.rs +++ b/src/scene/pipeline/hatch_web_gpu.rs @@ -1,16 +1,14 @@ -// WebGL2 hatch renderer — texture-backed, UNCAPPED (wasm only). +// Storage-free hatch renderer — texture-backed, UNCAPPED. // -// WebGL2 has no storage buffers, so the batched storage-buffer renderer -// (hatch_gpu.rs + hatch.wgsl) is disabled on wasm and real hatch fills never -// rendered on the web build (issue #204). This per-hatch renderer reuses the -// WebGL2-safe hatch algorithm (see wipeout.wgsl / hatch_web.wgsl) but packs the +// WebGL2 has no storage buffers; some native adapters also expose insufficient +// limits. This per-hatch renderer reuses the WebGL2-safe hatch algorithm (see +// wipeout.wgsl / hatch_web.wgsl) but packs the // variable-length boundary / family / dash arrays into ONE RGBA32F data texture // read via textureLoad — removing the MAX_FAMILIES / MAX_HATCH_BOUNDARY_VERTS / // MAX_DASHES caps of the uniform (WipeoutGpu) path. Every hatch type — solid, -// gradient, and arbitrarily complex line patterns — renders on the web. +// gradient, and arbitrarily complex line patterns — renders in compat mode. // -// Native (hatch_gpu.rs) and the wipeout mask renderer (wipeout_gpu.rs) are -// untouched; this module is compiled only for wasm32. +// The fast native path remains 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/mod.rs b/src/scene/pipeline/mod.rs index bf552d382..de0f342f1 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,8 +1,7 @@ pub mod face3d_gpu; pub mod hatch_gpu; -/// WebGL2 texture-backed hatch renderer (wasm only) — the storage-buffer -/// `hatch_gpu` path is unavailable on WebGL2, so hatches render through this. -#[cfg(target_arch = "wasm32")] +/// Texture-backed compatibility hatch renderer. Used on WebGL2 and on native +/// adapters that cannot support the storage-buffer batch. pub mod hatch_web_gpu; pub mod wipeout_gpu; pub mod image_gpu; @@ -48,22 +47,18 @@ 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` 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. + /// xray pipelines). `Some` only on the fast native path; `None` in packed + /// compatibility mode. Passed to `WireGpu::from_run` / `from_batch`. pub(crate) wire_const_bgl: Option, wipeout_pipeline: wgpu::RenderPipeline, /// Phase 4-B — single-draw batched hatch pipeline. Per-instance /// data lives in storage buffers; one draw call covers every - /// hatch in the frame. `None` on WebGL2 (wasm), which lacks - /// vertex-stage storage buffers. + /// hatch in the frame. `None` in compatibility mode. hatch_pipeline: Option, - /// WebGL2 (wasm) texture-backed hatch pipeline + its group-1 layout. Used - /// where `hatch_pipeline` is `None` because WebGL2 lacks storage buffers. - #[cfg(target_arch = "wasm32")] - hatch_web_bgl1: wgpu::BindGroupLayout, - #[cfg(target_arch = "wasm32")] - hatch_web_pipeline: wgpu::RenderPipeline, + /// Texture-backed compatibility hatch pipeline + its group-1 layout. + /// Present whenever the storage-buffer batch is unavailable or forced off. + hatch_compat_bgl1: Option, + hatch_compat_pipeline: Option, image_pipeline: wgpu::RenderPipeline, /// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the /// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts). @@ -91,8 +86,8 @@ pub struct Pipeline { uniform_bind_group: wgpu::BindGroup, wipeout_bgl1: wgpu::BindGroupLayout, /// Group-1 layout for the batched hatch pipeline (storage buffers - /// for instances / boundary / families / dashes). `None` on WebGL2, where - /// hatches render through `hatch_web_gpu` and this is never read. + /// for instances / boundary / families / dashes). `None` in compatibility + /// mode, where hatches render through `hatch_web_gpu`. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] hatch_bgl1: Option, image_bgl1: wgpu::BindGroupLayout, @@ -183,12 +178,10 @@ pub struct Pipeline { gpu_preview_wires: Vec, /// The canonical hatch fills — a single batched GPU resource drawn in one /// call with per-instance visibility masking the rest. All pattern line - /// families are uploaded (no cap). `None` on WebGL2. + /// families are uploaded (no cap). `None` in compatibility mode. gpu_hatch: Option, - /// WebGL2 hatch fills — one texture-backed `HatchWebGpu` per hatch, drawn in - /// the hatch pass where the batched `gpu_hatch` is `None` (wasm). - #[cfg(target_arch = "wasm32")] - gpu_hatches_web: Vec, + /// Compatibility hatch fills — one texture-backed GPU object per hatch. + gpu_hatches_compat: Vec, /// Wipeout masks — solid fills rendered after wires in a separate pass via /// the legacy per-primitive `WipeoutGpu` renderer. gpu_wipeouts: Vec, @@ -321,19 +314,38 @@ 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. + // 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" }; #[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]; + if std::env::var_os("RUST_LOG").is_some() { + eprintln!( + "renderer pipeline: {} (storage buffers/stage: {})", + renderer_mode_name, + device.limits().max_storage_buffers_per_shader_stage + ); + } #[cfg(target_arch = "wasm32")] - let wire_bgls: &[&wgpu::BindGroupLayout] = &[&frame_bgl]; + log::info!( + "renderer pipeline: {} (storage buffers/stage: {})", + renderer_mode_name, + 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); + } let wire_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("wire.pipeline_layout"), - bind_group_layouts: wire_bgls, + bind_group_layouts: &wire_bgls, push_constant_ranges: &[], }); @@ -342,14 +354,13 @@ 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" - ))), + 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") + } + wire_gpu::WirePipelineMode::Packed => include_str!("../../shaders/wire.wgsl"), + })), }); // Stencil test shared by every paper content pipeline: draw only where @@ -377,7 +388,7 @@ impl Pipeline { vertex: wgpu::VertexState { module: &wire_shader, entry_point: Some("vs_main"), - buffers: &[wire_gpu::WireInstance::layout()], + buffers: &[wire_mode.layout()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { @@ -498,7 +509,7 @@ impl Pipeline { vertex: wgpu::VertexState { module: &wire_shader, entry_point: Some("vs_main"), - buffers: &[wire_gpu::WireInstance::layout()], + buffers: &[wire_mode.layout()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { @@ -541,7 +552,7 @@ impl Pipeline { vertex: wgpu::VertexState { module: &wire_shader, entry_point: Some("vs_main"), - buffers: &[wire_gpu::WireInstance::layout()], + buffers: &[wire_mode.layout()], compilation_options: wgpu::PipelineCompilationOptions::default(), }, primitive: wgpu::PrimitiveState { @@ -663,94 +674,85 @@ impl Pipeline { cache: None, }); - // ── Hatch batched pipeline (Phase 4-B) ───────────────────────────── - // WebGL2 (the wasm fallback backend) has no vertex-stage storage - // buffers, which this pipeline's bind group requires. Skip it on wasm - // and run without the batched-hatch optimization. - #[cfg(target_arch = "wasm32")] - let (hatch_bgl1, hatch_pipeline): ( - Option, - Option, - ) = (None, None); - #[cfg(not(target_arch = "wasm32"))] - let (hatch_bgl1, hatch_pipeline) = { - let hatch_bgl1 = hatch_gpu::HatchGpu::bind_group_layout(device); - let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("hatch.pipeline_layout"), - bind_group_layouts: &[&frame_bgl, &hatch_bgl1], - push_constant_ranges: &[], - }); - let hatch_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("hatch.shader"), - source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( - "../../shaders/hatch.wgsl" - ))), - }); - let hatch_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("hatch.pipeline"), - layout: Some(&hatch_layout), - vertex: wgpu::VertexState { - module: &hatch_shader, - entry_point: Some("vs_main"), - buffers: &[hatch_gpu::HatchVertex::layout()], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }, - primitive: wgpu::PrimitiveState { - topology: wgpu::PrimitiveTopology::TriangleList, - cull_mode: None, - ..Default::default() - }, - depth_stencil: Some(wgpu::DepthStencilState { - format: wgpu::TextureFormat::Depth24PlusStencil8, - depth_write_enabled: true, - depth_compare: wgpu::CompareFunction::LessEqual, - stencil: content_stencil.clone(), - bias: wgpu::DepthBiasState { - constant: 1, - slope_scale: 1.0, - clamp: 0.0, + // ── 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_gpu::HatchGpu::bind_group_layout(device); + let hatch_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hatch.pipeline_layout"), + bind_group_layouts: &[&frame_bgl, &hatch_bgl1], + push_constant_ranges: &[], + }); + let hatch_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("hatch.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/hatch.wgsl" + ))), + }); + let hatch_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("hatch.pipeline"), + layout: Some(&hatch_layout), + vertex: wgpu::VertexState { + module: &hatch_shader, + entry_point: Some("vs_main"), + buffers: &[hatch_gpu::HatchVertex::layout()], + compilation_options: wgpu::PipelineCompilationOptions::default(), }, - }), - multisample: wgpu::MultisampleState { - count: MSAA_SAMPLES, - mask: !0, - alpha_to_coverage_enabled: false, - }, - fragment: Some(wgpu::FragmentState { - module: &hatch_shader, - entry_point: Some("fs_main"), - targets: &[Some(wgpu::ColorTargetState { - format, - blend: Some(wgpu::BlendState::ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: wgpu::PipelineCompilationOptions::default(), - }), - multiview: None, - cache: None, - }); - (Some(hatch_bgl1), Some(hatch_pipeline)) + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState { + constant: 1, + slope_scale: 1.0, + clamp: 0.0, + }, + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &hatch_shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + (Some(hatch_bgl1), Some(hatch_pipeline)) + } else { + (None, None) }; - // WebGL2 hatch pipeline: texture-backed, replaces the storage-buffer - // batched path (disabled above on wasm). Same frame layout / depth / - // MSAA state as the wipeout pass. - #[cfg(target_arch = "wasm32")] - let (hatch_web_bgl1, hatch_web_pipeline) = { + let (hatch_compat_bgl1, hatch_compat_pipeline) = if !wire_mode.uses_storage() { let bgl1 = hatch_web_gpu::HatchWebGpu::bind_group_layout(device); let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("hatch_web.pipeline_layout"), + label: Some("hatch_compat.pipeline_layout"), bind_group_layouts: &[&frame_bgl, &bgl1], push_constant_ranges: &[], }); let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { - label: Some("hatch_web.shader"), + label: Some("hatch_compat.shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( "../../shaders/hatch_web.wgsl" ))), }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("hatch_web.pipeline"), + label: Some("hatch_compat.pipeline"), layout: Some(&layout), vertex: wgpu::VertexState { module: &shader, @@ -800,7 +802,9 @@ impl Pipeline { multiview: None, cache: None, }); - (bgl1, pipeline) + (Some(bgl1), Some(pipeline)) + } else { + (None, None) }; // ── Mesh pipeline ────────────────────────────────────────────────── @@ -1386,16 +1390,11 @@ impl Pipeline { clip_mask_pipeline, wire_black_pipeline, wire_xray_pipeline, - #[cfg(not(target_arch = "wasm32"))] - wire_const_bgl: Some(wire_const_bgl), - #[cfg(target_arch = "wasm32")] - wire_const_bgl: None, + wire_const_bgl, wipeout_pipeline, hatch_pipeline, - #[cfg(target_arch = "wasm32")] - hatch_web_bgl1, - #[cfg(target_arch = "wasm32")] - hatch_web_pipeline, + hatch_compat_bgl1, + hatch_compat_pipeline, image_pipeline, text_pipeline, text_atlas_bgl, @@ -1445,8 +1444,7 @@ impl Pipeline { gpu_selected_wires: vec![], gpu_preview_wires: vec![], gpu_hatch: None, - #[cfg(target_arch = "wasm32")] - gpu_hatches_web: vec![], + gpu_hatches_compat: vec![], gpu_wipeouts: vec![], wipeout_skip_flags: vec![], gpu_images: vec![], @@ -2044,28 +2042,25 @@ impl Pipeline { queue: &wgpu::Queue, hatches: &[HatchModel], ) { - // WebGL2: no storage-buffer batched pipeline — render each hatch through - // the texture-backed per-hatch renderer instead (issue #204). - #[cfg(target_arch = "wasm32")] - { - let webs: Vec = hatches + if let Some(bgl1) = &self.hatch_compat_bgl1 { + self.gpu_hatches_compat = hatches .iter() .filter(|h| h.boundary.len() >= 3) - .map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, &self.hatch_web_bgl1)) + .map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, bgl1)) .collect(); - self.gpu_hatches_web = webs; - } - #[cfg(not(target_arch = "wasm32"))] - { - let _ = queue; - let Some(bgl1) = &self.hatch_bgl1 else { - self.gpu_hatch = None; - return; - }; - let renderable: Vec = - hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); - self.gpu_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable); + self.gpu_hatch = None; + return; } + + self.gpu_hatches_compat.clear(); + let _ = queue; + let Some(bgl1) = &self.hatch_bgl1 else { + self.gpu_hatch = None; + return; + }; + let renderable: Vec = + hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); + self.gpu_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable); } pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) { @@ -2244,18 +2239,18 @@ impl Pipeline { } } - // WebGL2: the batched `gpu_hatch` is always None (no storage - // buffers), so draw the texture-backed per-hatch renderer in this - // same pass (before wires, so wires stay on top). (#204) - #[cfg(target_arch = "wasm32")] + // Storage-free compatibility path. Draw before wires so outlines + // remain on top, matching the batched path. if !self.skip_hatch_frame { - pass.set_pipeline(&self.hatch_web_pipeline); - pass.set_bind_group(0, &self.uniform_bind_group, &[]); - pass.set_stencil_reference(stencil_ref); - for hatch in &self.gpu_hatches_web { - pass.set_bind_group(1, &hatch.bind_group, &[]); - pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); - pass.draw(0..6, 0..1); + if let Some(pipeline) = &self.hatch_compat_pipeline { + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + pass.set_stencil_reference(stencil_ref); + for hatch in &self.gpu_hatches_compat { + pass.set_bind_group(1, &hatch.bind_group, &[]); + pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); + pass.draw(0..6, 0..1); + } } } } diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 13fe3bc5d..4217ff357 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -33,15 +33,15 @@ use iced::wgpu; /// `create_buffer_init` performs and avoids holding a second `Vec` worth of /// memory during upload — meaningful on cold open where wire buffers can run /// into the hundreds of MB. -fn instance_buffer_mapped( +fn instance_buffer_mapped( device: &wgpu::Device, label: &str, - data: &[WireInstance], + data: &[T], ) -> wgpu::Buffer { let bytes: &[u8] = bytemuck::cast_slice(data); // wgpu rejects size-0 buffers; the renderer already guards `instance_count` // before issuing a draw, so a placeholder allocation is fine here. - let size = bytes.len().max(std::mem::size_of::()) as u64; + let size = bytes.len().max(std::mem::size_of::()) as u64; let buf = device.create_buffer(&wgpu::BufferDescriptor { label: Some(label), size, @@ -156,11 +156,13 @@ impl WireConst { } } -// ── Web (WebGL2): self-contained fat instance (no vertex-stage storage) ───── -#[cfg(target_arch = "wasm32")] +// ── 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. #[repr(C)] #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] -pub struct WireInstance { +pub struct PackedWireInstance { pub pos_a: [f32; 3], pub pos_a_low: [f32; 3], pub pos_b: [f32; 3], @@ -191,8 +193,7 @@ pub struct WireInstance { pub world_hw_b: f32, } -#[cfg(target_arch = "wasm32")] -impl WireInstance { +impl PackedWireInstance { pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { // Offsets come from the struct layout (must match the shader location // indices in wire.wgsl). Scalars ride in PACKED vec4/vec2 attributes — @@ -201,28 +202,84 @@ impl WireInstance { // the web viewport drew no lines at all (#414). The struct fields are // laid out so each packed group is contiguous. 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, color) as u64, shader_location: 2, format: wgpu::VertexFormat::Unorm8x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, pos_a) as u64, shader_location: 0, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, pos_b) as u64, shader_location: 1, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, color) as u64, shader_location: 2, format: wgpu::VertexFormat::Unorm8x4 }, // dists = (distance_a, distance_b, half_width, pattern_length) - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, distance_a) as u64, shader_location: 3, format: wgpu::VertexFormat::Float32x4 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pat0) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32x4 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pat1) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, distance_a) as u64, shader_location: 3, format: wgpu::VertexFormat::Float32x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, pat0) as u64, shader_location: 4, format: wgpu::VertexFormat::Float32x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, pat1) as u64, shader_location: 5, format: wgpu::VertexFormat::Float32x4 }, // misc = (draw_depth, align_end, align_total, world_half_width) - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, draw_depth) as u64, shader_location: 6, format: wgpu::VertexFormat::Float32x4 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_a_low) as u64, shader_location: 7, format: wgpu::VertexFormat::Float32x3 }, - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, pos_b_low) as u64, shader_location: 8, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, draw_depth) as u64, shader_location: 6, format: wgpu::VertexFormat::Float32x4 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, pos_a_low) as u64, shader_location: 7, format: wgpu::VertexFormat::Float32x3 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, pos_b_low) as u64, shader_location: 8, format: wgpu::VertexFormat::Float32x3 }, // taper = (world_hw_a, world_hw_b) - wgpu::VertexAttribute { offset: std::mem::offset_of!(WireInstance, world_hw_a) as u64, shader_location: 9, format: wgpu::VertexFormat::Float32x2 }, + wgpu::VertexAttribute { offset: std::mem::offset_of!(PackedWireInstance, world_hw_a) as u64, shader_location: 9, format: wgpu::VertexFormat::Float32x2 }, ]; wgpu::VertexBufferLayout { - array_stride: std::mem::size_of::() as u64, + array_stride: std::mem::size_of::() as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: ATTRS, } } } +#[cfg(target_arch = "wasm32")] +pub type WireInstance = PackedWireInstance; + +/// Wire and hatch pipelines switch together: the fast path uses storage +/// buffers; the compatibility path carries wire constants in packed vertex +/// 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 { + WirePipelineMode::Packed + } else { + WirePipelineMode::IndexedStorage + } +} + +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 uses_storage(self) -> bool { + match self { + #[cfg(not(target_arch = "wasm32"))] + Self::IndexedStorage => true, + Self::Packed => false, + } + } + + pub fn layout<'a>(self) -> wgpu::VertexBufferLayout<'a> { + match self { + #[cfg(not(target_arch = "wasm32"))] + Self::IndexedStorage => WireInstance::layout(), + Self::Packed => PackedWireInstance::layout(), + } + } +} + // ── GPU handle ──────────────────────────────────────────────────────────── pub struct WireGpu { @@ -244,7 +301,6 @@ pub struct WireGpu { /// 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, @@ -387,9 +443,12 @@ 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 { +/// Emit packed per-segment instances (each carries the wire's constants). +fn emit_wire_packed( + 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]]; @@ -402,14 +461,14 @@ fn emit_wire_instances(wire: &WireModel, color: [f32; 4], draw_depth: f32) -> Ve let (dists, align_end, align_total) = wire_distances(wire); let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 }; - let mut instances: Vec = Vec::with_capacity(seg_count); + 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 { + instances.push(PackedWireInstance { pos_a: a, pos_a_low: low(i), pos_b: b, @@ -556,9 +615,10 @@ impl WireGpu { mesh_edge: bool, const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { - const MAX_INSTANCES: usize = 268_435_456 / std::mem::size_of::(); #[cfg(not(target_arch = "wasm32"))] - { + if let Some(const_bgl) = const_bgl { + const MAX_INSTANCES: usize = + 268_435_456 / std::mem::size_of::(); 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 @@ -585,8 +645,8 @@ impl WireGpu { if instances.is_empty() { return vec![]; } - let bg = const_bgl.map(|bgl| build_const_bind_group(device, bgl, &consts)); - instances + let bg = build_const_bind_group(device, const_bgl, &consts); + return instances .chunks(MAX_INSTANCES) .map(|chunk| { let buf = instance_buffer_mapped(device, "wire.run.ibuf", chunk); @@ -594,42 +654,42 @@ impl WireGpu { instance_buffer: buf, instance_count: chunk.len() as u32, is_3d_mesh_edge: mesh_edge, - const_bind_group: bg.clone(), + const_bind_group: Some(bg.clone()), } }) - .collect() - } - #[cfg(target_arch = "wasm32")] - { - let _ = const_bgl; - let per: Vec> = wires - .iter() - .map(|w| { - let dd = if mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) }; - emit_wire_instances(w, w.color, dd) - }) .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, - is_3d_mesh_edge: mesh_edge, - const_bind_group: None, - } - }) - .collect() } + + let _ = const_bgl; + const MAX_PACKED_INSTANCES: usize = + 268_435_456 / std::mem::size_of::(); + let per: Vec> = wires + .iter() + .map(|w| { + let dd = if mesh_edge { 0.0 } else { wire_draw_depth(w, depth_map) }; + emit_wire_packed(w, w.color, dd) + }) + .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_PACKED_INSTANCES) + .map(|chunk| { + let buf = instance_buffer_mapped(device, "wire.run.compat.ibuf", chunk); + Self { + instance_buffer: buf, + instance_count: chunk.len() as u32, + is_3d_mesh_edge: mesh_edge, + const_bind_group: None, + } + }) + .collect() } /// Merge multiple WireModels into GPU instance buffers, chunked to fit the @@ -645,11 +705,11 @@ impl WireGpu { if total_segs == 0 { 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::(); - #[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 = + 268_435_456 / std::mem::size_of::(); 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 @@ -671,8 +731,8 @@ impl WireGpu { if instances.is_empty() { return vec![]; } - let bg = const_bgl.map(|bgl| build_const_bind_group(device, bgl, &consts)); - instances + let bg = build_const_bind_group(device, const_bgl, &consts); + return instances .chunks(MAX_INSTANCES) .enumerate() .map(|(i, chunk)| { @@ -682,35 +742,35 @@ impl WireGpu { instance_buffer, instance_count: chunk.len() as u32, is_3d_mesh_edge: false, - const_bind_group: bg.clone(), + const_bind_group: Some(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, - is_3d_mesh_edge: false, - const_bind_group: None, - } - }) - .collect() } + + let _ = const_bgl; + const MAX_PACKED_INSTANCES: usize = + 268_435_456 / std::mem::size_of::(); + let instances: Vec = wires + .iter() + .flat_map(|w| emit_wire_packed(w, w.color, wire_draw_depth(w, depth_map))) + .collect(); + if instances.is_empty() { + return vec![]; + } + instances + .chunks(MAX_PACKED_INSTANCES) + .enumerate() + .map(|(i, chunk)| { + let label = format!("wire.batch.compat.ibuf.{i}"); + let instance_buffer = instance_buffer_mapped(device, &label, chunk); + Self { + instance_buffer, + instance_count: chunk.len() as u32, + is_3d_mesh_edge: false, + const_bind_group: None, + } + }) + .collect() } }