fix(renderer): add automatic compat fallback

Use packed wire and texture-backed hatches on low-limit adapters.

Refs #475
This commit is contained in:
Hakan Seven committed 2026-07-24 17:05:54 +03:00
1 parent 2ff37b088b
commit 91326e9a0c
5 files changed
+317 -256

No files matched your search

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