From 9d991d7febb464ab806711a1ae80226ae62140d9 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 4 Sep 2026 20:26:29 +0300 Subject: [PATCH] Bound GPU uploads and complete Greek and performance integration --- locales/el-GR/opencadstudio.ftl | 19 +++ src/i18n.rs | 30 ++++ src/modules/draw/modify/join.rs | 4 +- src/perf.rs | 5 + src/scene/mod.rs | 101 +++++++------- src/scene/pipeline/gpu_budget.rs | 34 ++--- src/scene/pipeline/gpu_tests.rs | 177 +++++++++++++++++++++++ src/scene/pipeline/gpu_upload.rs | 33 +---- src/scene/pipeline/mesh_gpu.rs | 233 +++++++++++++++++++++++-------- src/scene/pipeline/mod.rs | 73 ++++------ src/scene/pipeline/text_gpu.rs | 67 +++++---- src/scene/pipeline/wire_arena.rs | 34 ++--- src/scene/pipeline/wire_gpu.rs | 191 ++++++++++++------------- src/scene/selection.rs | 57 +++++--- 14 files changed, 678 insertions(+), 380 deletions(-) create mode 100644 src/scene/pipeline/gpu_tests.rs diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index b0ea9afe1..c97e91a90 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -237,6 +237,8 @@ command-copy = } | Διάταξη | Enter=τέλος | βάση { $x },{ $y }]: common = + .revolve-created-objects-failed-sources = REVOLVE: δημιουργήθηκαν __ocs_arg_created__ αντικείμενα· __ocs_arg_failed__ αρχικά αντικείμενα δεν μπόρεσαν να περιστραφούν. + .revolve-no-selected-object-could-be-revolved = REVOLVE: κανένα επιλεγμένο αντικείμενο δεν μπόρεσε να περιστραφεί με τις ζητούμενες επιλογές. .none-use-shortcuts-set-key-command = (κανένα — χρησιμοποιήστε: SHORTCUTS SET <πλήκτρο> <εντολή>) .alignment-indented-label-title-case = Στοίχιση: .background-fill-enabled = Γέμισμα φόντου ενεργό @@ -1988,6 +1990,13 @@ annotate = .tcount-numbered-n-text-object-s-from-start-by = TCOUNT: αριθμήθηκαν __ocs_fmt_0__ αντικείμενο(α) κειμένου από __ocs_fmt_1__ με βήμα __ocs_fmt_2__ (__ocs_fmt_3__). insert = + .revolve-creation-mode-solid-surface = REVOLVE Τρόπος δημιουργίας [Solid/Surface] + .revolve-enter-angle-expression = REVOLVE Δώστε έκφραση γωνίας: + .revolve-select-line-ray-or-construction-line-for-axis = REVOLVE Επιλέξτε γραμμή, ημιευθεία ή βοηθητική ευθεία ως άξονα: + .revolve-select-objects-or-mode = REVOLVE Επιλέξτε αντικείμενα προς περιστροφή ή [Mode] (Enter για τερματισμό): + .revolve-specify-angle-or-options = REVOLVE Ορίστε γωνία περιστροφής ή [Start angle/Reverse/Expression] + .revolve-specify-axis-start-or-options = REVOLVE Ορίστε αρχικό σημείο άξονα ή [Object/X/Y/Z/Mode]: + .revolve-specify-start-angle = REVOLVE Ορίστε αρχική γωνία .point-clouds = Νέφη σημείων @@ -2414,6 +2423,16 @@ view = .viewport-s-in-layout = __ocs_fmt_0__ θύρα(ες) προβολής στη διάταξη "__ocs_fmt_1__": properties = + .angle-of-revolution = Γωνία περιστροφής + .axis-direction-x = Διεύθυνση άξονα X + .axis-direction-y = Διεύθυνση άξονα Y + .axis-direction-z = Διεύθυνση άξονα Z + .axis-position-x = Θέση άξονα X + .axis-position-y = Θέση άξονα Y + .axis-position-z = Θέση άξονα Z + .show-history = Εμφάνιση ιστορικού + .solid-history = Ιστορικό στερεού + .solid-type = Τύπος στερεού .count-objects-selected = __ocs_arg_count__ αντικείμενα επιλεγμένα .scope-visual-style = Οπτικό στυλ __ocs_arg_scope__ .value-3d-face = Έδρα 3D diff --git a/src/i18n.rs b/src/i18n.rs index 6748f8995..ba6d98e82 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -402,3 +402,33 @@ macro_rules! tf { $crate::i18n::translate_format($template, rendered) }}; } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn greek_catalog_loads_and_covers_the_source_catalog() { + let loader = FluentLanguageLoader::new("opencadstudio", "en-US".parse().unwrap()); + load_language(&loader, Language::ElGr).expect("Greek Fluent resources must parse"); + let keys = |language: &str| -> BTreeSet<(String, String)> { + loader.with_message_iter(&language.parse().unwrap(), |messages| { + messages + .flat_map(|message| { + std::iter::once((message.id.name.to_owned(), String::new())).chain( + message.attributes.iter().map(|attribute| { + (message.id.name.to_owned(), attribute.id.name.to_owned()) + }), + ) + }) + .collect() + }) + }; + let source = keys("en-US"); + assert!(!source.is_empty()); + assert_eq!(keys("el-GR"), source); + assert_eq!(loader.get_attr("language", "greek"), "Ελληνικά"); + assert_eq!(serde_json::to_string(&Language::ElGr).unwrap(), "\"el-GR\""); + } +} diff --git a/src/modules/draw/modify/join.rs b/src/modules/draw/modify/join.rs index 8d1eca9d7..9a101dea7 100644 --- a/src/modules/draw/modify/join.rs +++ b/src/modules/draw/modify/join.rs @@ -366,8 +366,8 @@ mod join_tests { unreachable!(); }; source.normal = Vector3::new(0.0, 0.0, -1.0); - // A corner keeps the chain from collapsing back to a single Line. - let e2 = line(10.0, 0.0, 10.0, 10.0, 0.0); + // The -Z normal maps the source endpoint to world X=-10. + let e2 = line(-10.0, 0.0, -10.0, 10.0, 0.0); let (removed, out) = join_entities(&[(h1, &e1), (h2, &e2)]).expect("chain joins"); assert_eq!(removed.len(), 2); let Some(EntityType::LwPolyline(pl)) = out.first() else { diff --git a/src/perf.rs b/src/perf.rs index 1e3197c2f..23c6f0a1e 100644 --- a/src/perf.rs +++ b/src/perf.rs @@ -23,6 +23,11 @@ pub fn enabled() -> bool { || *ENV_ENABLED.get_or_init(|| std::env::var_os("PERF").is_some()) } +/// Elapsed diagnostic time; a disabled timer never reads the clock. +pub fn elapsed_ms(start: Option) -> f64 { + start.map_or(0.0, |start| start.elapsed().as_secs_f64() * 1000.0) +} + /// Enable or disable collection driven by the in-app `PERF` command. pub fn set_ui_enabled(enabled: bool) { UI_ENABLED.store(enabled, Ordering::Relaxed); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 107eaefdd..da511e24f 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -987,21 +987,21 @@ pub fn prepare_open_geometry( scene.current_layout = "Model".to_string(); let camera = scene.camera.borrow().clone(); let perf = crate::perf::enabled(); - let t_wires = iced::time::Instant::now(); + let t_wires = perf.then(iced::time::Instant::now); let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1.0); - let wires_ms = t_wires.elapsed().as_secs_f64() * 1000.0; - let t_index = iced::time::Instant::now(); + let wires_ms = crate::perf::elapsed_ms(t_wires); + let t_index = perf.then(iced::time::Instant::now); let interaction_index = if scene.interaction_index_worthwhile(&wires) { let index = Arc::new(crate::scene::pick::interaction_index::InteractionIndex::build(&wires)); - let build_ms = t_index.elapsed().as_secs_f64() * 1000.0; - let t_screen = iced::time::Instant::now(); + let build_ms = crate::perf::elapsed_ms(t_index); + let t_screen = perf.then(iced::time::Instant::now); index.prepare_screen(); if perf { crate::perf_record!( "[perf] open-index build={:.1}ms prepare_screen={:.1}ms", build_ms, - t_screen.elapsed().as_secs_f64() * 1000.0, + crate::perf::elapsed_ms(t_screen), ); } Some(index) @@ -1012,7 +1012,7 @@ pub fn prepare_open_geometry( crate::perf_record!( "[perf] open-finalize wires={:.1}ms index={:.1}ms wire_models={}", wires_ms, - t_index.elapsed().as_secs_f64() * 1000.0, + crate::perf::elapsed_ms(t_index), wires.len(), ); } @@ -5195,37 +5195,36 @@ impl Scene { // Build once: full tessellation, no cull (region = None), no zoom LOD // (wpp = None) — for every space, exactly like the Model static-hold. let t_tess = iced::time::Instant::now(); - let mut wires = - self.wires_for_block_culled( - block, - None, - None, - frozen_layers, - anno_scale_override, - annotation_scale_handle, - all_visible, - style_viewport, - ); + let mut wires = self.wires_for_block_culled( + block, + None, + None, + frozen_layers, + anno_scale_override, + annotation_scale_handle, + all_visible, + style_viewport, + ); let perf = crate::perf::enabled(); - let t_post = iced::time::Instant::now(); + let t_post = perf.then(iced::time::Instant::now); // Synthesized nonprint markers (geo-location daisy) live in model space // only and are derived from document objects, not entities — append them // to the freshly built resident set (incremental patches preserve them). if block == self.model_space_block_handle() { self.append_scene_markers(&mut wires, bg); } - let markers_ms = t_post.elapsed().as_secs_f64() * 1000.0; - let t_fade = iced::time::Instant::now(); + let markers_ms = crate::perf::elapsed_ms(t_post); + let t_fade = perf.then(iced::time::Instant::now); self.apply_refedit_fade(&mut wires, bg); - let fade_ms = t_fade.elapsed().as_secs_f64() * 1000.0; - let t_layout = iced::time::Instant::now(); + let fade_ms = crate::perf::elapsed_ms(t_fade); + let t_layout = perf.then(iced::time::Instant::now); let layout = Self::resident_wire_layout(&wires); if perf { crate::perf_record!( "[perf] resident-post markers={:.1}ms fade={:.1}ms layout={:.1}ms wires={}", markers_ms, fade_ms, - t_layout.elapsed().as_secs_f64() * 1000.0, + crate::perf::elapsed_ms(t_layout), wires.len(), ); } @@ -8764,11 +8763,9 @@ impl Scene { ) -> Vec { use acadrust::objects::ObjectType; - // Stage timings for the load-path investigation. This function also - // runs per frame on the cull path, so the reporting stays behind the - // PERF gate. `t_fn` doubles as the start of the sort-cache stage. + // Skip diagnostic clock reads when PERF is disabled. let perf = crate::perf::enabled(); - let t_fn = iced::time::Instant::now(); + let t_fn = perf.then(iced::time::Instant::now); // ── Ensure sort-order index is current ──────────────────────────── // Replaces the old O(objects) find_map with one rebuild per epoch, @@ -8811,8 +8808,8 @@ impl Scene { ) }; - let sort_cache_ms = t_fn.elapsed().as_secs_f64() * 1000.0; - let t_visible = iced::time::Instant::now(); + let sort_cache_ms = crate::perf::elapsed_ms(t_fn); + let t_visible = perf.then(iced::time::Instant::now); // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate @@ -8864,7 +8861,7 @@ impl Scene { .collect() }; - let visible_ms = t_visible.elapsed().as_secs_f64() * 1000.0; + let visible_ms = crate::perf::elapsed_ms(t_visible); let visible_count = visible.len(); // Tessellate in parallel across all available CPU cores. @@ -8899,9 +8896,10 @@ impl Scene { // Content shown inside a paper-space viewport carries a scale override; // only there does PSLTSCALE resize linetypes by the viewport scale. let paper = anno_scale_override.is_some(); - let t_blk = iced::time::Instant::now(); - let blk_cache = self.block_cache_arc_for(annotation_scale_handle, all_visible, style_viewport); - let blk_ms = t_blk.elapsed().as_secs_f64() * 1000.0; + let t_blk = perf.then(iced::time::Instant::now); + let blk_cache = + self.block_cache_arc_for(annotation_scale_handle, all_visible, style_viewport); + let blk_ms = crate::perf::elapsed_ms(t_blk); let blk_ref: &cache::block_cache::BlockCache = &blk_cache; // Per-entity tessellation memo. Same classify/tessellate logic, two // SEPARATE stores so they can't thrash each other: @@ -8938,7 +8936,7 @@ impl Scene { let mut materialize_ms = 0.0f64; let mut memo_hits = 0usize; let memo_misses; - let t_build = iced::time::Instant::now(); + let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { // Guard hash of everything tessellate_entity output depends on // besides the entity itself. A mismatch (zoom/tol, view, anno, @@ -8977,7 +8975,7 @@ impl Scene { guard_cell.set(guard); } // Classify (serial, cheap): reuse memoized Arcs, collect misses. - let t_classify = iced::time::Instant::now(); + let t_classify = perf.then(iced::time::Instant::now); let mut hit_arcs: Vec>> = Vec::new(); let mut misses: Vec<&EntityType> = Vec::new(); { @@ -8990,17 +8988,17 @@ impl Scene { } } } - classify_ms = t_classify.elapsed().as_secs_f64() * 1000.0; + classify_ms = crate::perf::elapsed_ms(t_classify); memo_hits = hit_arcs.len(); memo_misses = misses.len(); // Materialize hits + tessellate misses, both in parallel. - let t_hits = iced::time::Instant::now(); + let t_hits = perf.then(iced::time::Instant::now); let hit_wires: Vec = hit_arcs .par_iter() .flat_map_iter(|a| a.iter().cloned()) .collect(); - hits_ms = t_hits.elapsed().as_secs_f64() * 1000.0; - let t_tess_miss = iced::time::Instant::now(); + hits_ms = crate::perf::elapsed_ms(t_hits); + let t_tess_miss = perf.then(iced::time::Instant::now); let miss_pairs: Vec<(Handle, Arc>)> = misses .par_iter() .map(|e| { @@ -9021,12 +9019,9 @@ impl Scene { (e.common().handle, Arc::new(w)) }) .collect(); - tess_ms = t_tess_miss.elapsed().as_secs_f64() * 1000.0; - // Serial deep-clone of every freshly tessellated wire, while the - // memo retains a second copy of the same geometry. Suspected - // dominant cost of a cold open; measuring it is the point of this - // whole breakdown. - let t_materialize = iced::time::Instant::now(); + tess_ms = crate::perf::elapsed_ms(t_tess_miss); + // Materialize new wires while the memo retains their shared source. + let t_materialize = perf.then(iced::time::Instant::now); let mut out = hit_wires; { let mut memo = memo_cell.borrow_mut(); @@ -9035,10 +9030,10 @@ impl Scene { memo.insert(*h, Arc::clone(a)); } } - materialize_ms = t_materialize.elapsed().as_secs_f64() * 1000.0; + materialize_ms = crate::perf::elapsed_ms(t_materialize); out } else { - let t_tess_all = iced::time::Instant::now(); + let t_tess_all = perf.then(iced::time::Instant::now); let out: Vec = visible .into_par_iter() .flat_map(|e| { @@ -9057,14 +9052,14 @@ impl Scene { ) }) .collect(); - tess_ms = t_tess_all.elapsed().as_secs_f64() * 1000.0; + tess_ms = crate::perf::elapsed_ms(t_tess_all); memo_misses = visible_count; out }; - let build_ms = t_build.elapsed().as_secs_f64() * 1000.0; + let build_ms = crate::perf::elapsed_ms(t_build); // Apply draw order via the cached index (O(1) block lookup). - let t_sort = iced::time::Instant::now(); + let t_sort = perf.then(iced::time::Instant::now); let mut sorted = false; { let cache = self.sort_cache.borrow(); @@ -9089,7 +9084,7 @@ impl Scene { "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ entities={} memo_hit={} memo_miss={} wires={} memo={}", - t_fn.elapsed().as_secs_f64() * 1000.0, + crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, blk_ms, @@ -9098,7 +9093,7 @@ entities={} memo_hit={} memo_miss={} wires={} memo={}", hits_ms, tess_ms, materialize_ms, - t_sort.elapsed().as_secs_f64() * 1000.0, + crate::perf::elapsed_ms(t_sort), if sorted { "applied" } else { "skipped" }, visible_count, memo_hits, diff --git a/src/scene/pipeline/gpu_budget.rs b/src/scene/pipeline/gpu_budget.rs index 0ea3c750f..5338a2310 100644 --- a/src/scene/pipeline/gpu_budget.rs +++ b/src/scene/pipeline/gpu_budget.rs @@ -1,33 +1,14 @@ -//! One VRAM-aware budget for every GPU buffer this renderer allocates. -//! -//! `wgpu::Limits::max_buffer_size` is an **API validation limit**, not a -//! statement about physical memory: NVIDIA reports several GB of it on a 2 GB -//! card. Sizing a chunk from that limit therefore produces buffers larger than -//! the whole device, the allocation fails, and the frame dies — issue #203 -//! (`mesh_gpu`), #358 (`face3d_gpu`) and the block-wire path were all the same -//! bug rediscovered. -//! -//! `mesh_gpu.rs` was the only site that clamped the result. This module makes -//! that clamp the single shared rule so a new upload path cannot reintroduce -//! the mistake by copying one of the unclamped expressions. +//! Per-buffer upload cap, bounded by device limits. This does not measure free VRAM. use iced::wgpu; -/// Upper bound on any single buffer, regardless of what the device claims it -/// would validate. Chosen to match the clamp `mesh_gpu` has used since #203: -/// large enough that chunk overhead stays negligible, small enough that a -/// failed allocation is recoverable on a low-VRAM card. const HARD_CAP_BYTES: usize = 32 * 1024 * 1024; -/// 10% headroom below the device's validation limit, matching the `/ 10 * 9` -/// the individual sites used before they shared this helper. fn device_ceiling(device: &wgpu::Device) -> usize { - (device.limits().max_buffer_size as usize / 10) * 9 + (usize::try_from(device.limits().max_buffer_size).unwrap_or(usize::MAX) / 10) * 9 } -/// Test/debug override, in MiB. Raising it above the real VRAM is the -/// cheapest way to force an out-of-memory path and check that the renderer -/// degrades the frame instead of aborting. +/// Optional per-buffer cap in MiB, useful for testing smaller chunks. fn cap_bytes() -> usize { static CAP: std::sync::OnceLock = std::sync::OnceLock::new(); *CAP.get_or_init(|| { @@ -35,7 +16,7 @@ fn cap_bytes() -> usize { .ok() .and_then(|value| value.trim().parse::().ok()) .filter(|mib| *mib > 0) - .map(|mib| mib * 1024 * 1024) + .and_then(|mib| mib.checked_mul(1024 * 1024)) .unwrap_or(HARD_CAP_BYTES) }) } @@ -60,3 +41,10 @@ pub fn max_elements_grouped(device: &wgpu::Device, group: usize) -> usize { let group = group.max(1); (max_elements::(device) / group).max(1) * group } + +/// Storage buffers must also fit in one binding. +pub fn max_storage_elements(device: &wgpu::Device) -> usize { + (buffer_budget(device).min(device.limits().max_storage_buffer_binding_size as usize) + / std::mem::size_of::().max(1)) + .max(1) +} diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs new file mode 100644 index 000000000..7a235b5a9 --- /dev/null +++ b/src/scene/pipeline/gpu_tests.rs @@ -0,0 +1,177 @@ +use super::*; +use crate::scene::model::{mesh_model::MeshLodSet, mesh_model::MeshModel, wire_model::WireModel}; +use acadrust::{types::Transform, Handle}; +use iced::futures::executor::block_on; + +// OCS_GPU_CHUNK_MIB=1 cargo test --lib bounded_gpu_uploads -- --ignored --nocapture +#[test] +#[ignore = "requires a GPU adapter and OCS_GPU_CHUNK_MIB=1"] +fn bounded_gpu_uploads_preserve_geometry_and_instancing() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + eprintln!("GPU regression adapter: {:?}", adapter.get_info()); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let budget = gpu_budget::buffer_budget(&device) as u64; + assert_eq!(budget, 1024 * 1024, "run with OCS_GPU_CHUNK_MIB=1"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + + let line = WireModel { + points: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]], + ..Default::default() + }; + let count = gpu_budget::max_storage_elements::(&device) + 2; + let wires = vec![line; count]; + for bgl in [pipeline.wire_const_bgl.as_ref(), None] { + let chunks = + wire_gpu::WireGpu::from_run(&device, &queue, &wires, &Default::default(), false, bgl); + assert!(chunks.len() > 1); + assert_eq!( + chunks + .iter() + .map(|chunk| chunk.instance_count as usize) + .sum::(), + count + ); + assert!(chunks + .iter() + .all(|chunk| chunk.instance_buffer.size() <= budget)); + } + let segments = gpu_budget::max_elements::(&device) + 1; + let dense = WireModel { + points: (0..=segments).map(|i| [i as f32, 0.0, 0.0]).collect(), + ..Default::default() + }; + let chunks = wire_gpu::WireGpu::from_run( + &device, + &queue, + &[dense], + &Default::default(), + false, + pipeline.wire_const_bgl.as_ref(), + ); + assert!(chunks.len() > 1); + assert_eq!( + chunks + .iter() + .map(|chunk| chunk.instance_count as usize) + .sum::(), + segments + ); + assert!(chunks + .iter() + .all(|chunk| chunk.instance_buffer.size() <= budget)); + + let vertices = vec![ + bytemuck::Zeroable::zeroed(); + gpu_budget::max_elements_grouped::(&device, 6) + 6 + ]; + let text = text_gpu::upload_vertices(&device, &queue, &vertices); + assert_eq!(text.len(), 2); + assert_eq!( + text.iter() + .map(|chunk| chunk.vertex_count as usize) + .sum::(), + vertices.len() + ); + assert!(text + .iter() + .all(|chunk| chunk.vertex_count % 6 == 0 && chunk.vertex_buffer.size() <= budget)); + + let source = |triangles: usize| { + let mut set = MeshLodSet::from_single(MeshModel { + name: "7".to_owned(), + verts: (0..triangles) + .flat_map(|_| [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + .collect(), + verts_low: Vec::new(), + normals: Vec::new(), + indices: (0..triangles as u32 * 3).collect(), + triangle_material_handles: Vec::new(), + triangle_colors: Vec::new(), + color: [1.0; 4], + selected: false, + }); + set.prepare_instance_source(Handle::new(7)); + set.lods.clear(); + set.instance_transform = Some(Transform::identity()); + set.instance_handle = Some(Handle::new(101)); + set + }; + let triangles = gpu_budget::max_elements::(&device) / 3 + 1; + let first = source(triangles); + let mut second = first.clone(); + second.instance_handle = Some(Handle::new(102)); + let (mesh, total) = mesh_gpu::build_mesh_batch(&device, &queue, &[first, second]); + assert!(mesh.len() > 1); + assert_eq!(total, (triangles * 2) as u64); + assert_eq!( + mesh.iter() + .map(|chunk| chunk.index_count as usize / 3) + .sum::(), + triangles + ); + for chunk in &mesh { + assert_eq!( + chunk.instance_count, 2, + "large geometry must remain instanced" + ); + assert_eq!(chunk.handles.len(), 2); + assert_eq!(chunk.highlight_ranges.len(), 2); + assert!(chunk + .highlight_ranges + .iter() + .all(|range| range.index_count == chunk.index_count && range.instance_start < 2)); + for buffer in [ + &chunk.vertex_buffer, + &chunk.index_buffer, + &chunk.transp_index_buffer, + &chunk.edge_vertex_buffer, + &chunk.wire_vertex_buffer, + &chunk.instance_buffer, + ] { + assert!( + buffer.size() <= budget, + "buffer size {} exceeds {budget}", + buffer.size() + ); + } + } + + let instance_count = gpu_budget::max_elements::(&device) + 1; + let mut repeated = vec![source(1); instance_count]; + for (index, set) in repeated.iter_mut().enumerate() { + set.instance_handle = Some(Handle::new(1000 + index as u64)); + } + let (mesh, total) = mesh_gpu::build_mesh_batch(&device, &queue, &repeated); + assert_eq!(mesh.len(), 2); + assert_eq!(total, instance_count as u64); + assert_eq!(mesh[0].vertex_buffer, mesh[1].vertex_buffer); + assert_eq!(mesh[0].index_buffer, mesh[1].index_buffer); + assert_eq!( + mesh.iter() + .map(|chunk| chunk.instance_count as usize) + .sum::(), + instance_count + ); + for chunk in &mesh { + assert!(chunk.instance_buffer.size() <= budget); + assert_eq!(chunk.highlight_ranges.len(), chunk.instance_count as usize); + assert!(chunk + .highlight_ranges + .iter() + .all(|range| range.instance_start < chunk.instance_count)); + } + + queue.submit([]); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); +} diff --git a/src/scene/pipeline/gpu_upload.rs b/src/scene/pipeline/gpu_upload.rs index 98ae90bcb..a8aca3b26 100644 --- a/src/scene/pipeline/gpu_upload.rs +++ b/src/scene/pipeline/gpu_upload.rs @@ -1,37 +1,8 @@ -//! Buffer creation that survives an out-of-memory device. -//! -//! `wgpu::util::DeviceExt::create_buffer_init` — and any -//! `mapped_at_creation: true` followed by `get_mapped_range_mut()` — maps the -//! new buffer unconditionally. When the allocation fails wgpu hands back an -//! *invalid* buffer, and mapping an invalid buffer panics: -//! -//! ```text -//! thread 'main' panicked at wgpu-29.0.4/src/backend/wgpu_core.rs:2253:18: -//! Error in Buffer::get_mapped_range: Validation Error -//! Caused by: Buffer with 'block_wire.vertices' label is invalid -//! ``` -//! -//! That panic unwinds through iced's main-thread redraw, trips the `wgpu-hal` -//! swapchain assertion in a destructor, and turns into a **non-unwinding -//! abort** — the process dies with the user's unsaved drawing in it. -//! -//! Writing through the queue instead never maps. A failed allocation produces -//! a validation error on the `write_buffer`, which reaches the handler -//! installed by `install_gpu_error_handler` and degrades the frame, which is -//! what that handler was written for: "A bad frame must degrade, not end the -//! session." +//! Upload through the queue, avoiding direct mapping of a failed allocation. use iced::wgpu; -/// Create a buffer and fill it without ever mapping it. -/// -/// `COPY_DST` is added to `usage` because the contents arrive via -/// `Queue::write_buffer`. Callers pass the same usage flags they would have -/// given `create_buffer_init`. -/// -/// Empty `data` still produces a one-element buffer: wgpu rejects zero-sized -/// buffers, and every draw path here already guards its instance/vertex count -/// before issuing a draw, so a placeholder allocation is harmless. +/// Create an unmapped buffer and upload its contents, adding COPY_DST usage. pub fn upload_buffer( device: &wgpu::Device, queue: &wgpu::Queue, diff --git a/src/scene/pipeline/mesh_gpu.rs b/src/scene/pipeline/mesh_gpu.rs index 3a4f84250..f8eb33808 100644 --- a/src/scene/pipeline/mesh_gpu.rs +++ b/src/scene/pipeline/mesh_gpu.rs @@ -331,6 +331,7 @@ impl MeshInstanceGpu { // so no per-mesh state is needed between draws. Built once per geometry epoch — // selection/hover no longer rebuild it (that tint is dropped in the batch path). +#[derive(Clone)] pub struct MeshBatchChunk { pub vertex_buffer: wgpu::Buffer, pub compact_vertices: bool, @@ -1166,14 +1167,14 @@ fn material_has_textures( }) } -fn build_instanced_chunk( +fn build_instanced_chunks( device: &wgpu::Device, queue: &wgpu::Queue, stubs: &MeshBatchStubs, buffers: &mut InstancedBufferCache, parts: &[MeshBatchPart<'_>], profile_enabled: bool, -) -> Option<(MeshBatchChunk, u64, InstancedBuildProfile)> { +) -> Option<(Vec, u64, InstancedBuildProfile)> { let started = profile_enabled.then(iced::time::Instant::now); let first = parts.first()?; let source = first.set.instance_source.as_ref()?; @@ -1196,7 +1197,34 @@ fn build_instanced_chunk( material: material_identity, compact: compact_vertices, }; - let shared_vertex_buffer = buffers.vertices.get(&vertex_key).cloned(); + let budget = super::gpu_budget::buffer_budget(device); + let needs_wire_vertices = first.include_edges && source.edge_verts.is_empty(); + let split_geometry = mesh + .verts + .len() + .saturating_mul(std::mem::size_of::()) + > budget + || first + .indices + .len() + .saturating_mul(std::mem::size_of::()) + > budget + || (needs_wire_vertices + && first + .indices + .len() + .saturating_mul(2 * std::mem::size_of::()) + > budget) + || (first.include_edges + && source + .edge_verts + .len() + .saturating_mul(std::mem::size_of::()) + > budget); + let shared_vertex_buffer = (!split_geometry) + .then(|| buffers.vertices.get(&vertex_key).cloned()) + .flatten(); + let has_normals = mesh.normals.len() == mesh.verts.len(); let bounds = mesh_bounds(mesh); let vertex = |index: usize| { @@ -1229,21 +1257,22 @@ fn build_instanced_chunk( uv_normal: uvs[6], } }; - let needs_wire_vertices = first.include_edges && source.edge_verts.is_empty(); - let verts: Vec<_> = if shared_vertex_buffer.is_none() || needs_wire_vertices { - (0..mesh.verts.len()).map(vertex).collect() - } else { - Vec::new() - }; + let verts: Vec<_> = + if !split_geometry && (shared_vertex_buffer.is_none() || needs_wire_vertices) { + (0..mesh.verts.len()).map(vertex).collect() + } else { + Vec::new() + }; let vertices_at = profile_enabled.then(iced::time::Instant::now); let edge_color = first .set .visual_style .as_ref() - .map_or(first.display_color, |style| style.edge_color(first.display_color)); + .map_or(first.display_color, |style| { + style.edge_color(first.display_color) + }); let edge_key = (source_handle, edge_color.map(f32::to_bits)); - let shared_edge_buffer = first - .include_edges + let shared_edge_buffer = (first.include_edges && !split_geometry) .then(|| buffers.edges.get(&edge_key).cloned()) .flatten(); let edge_verts: Vec<_> = if first.include_edges && shared_edge_buffer.is_none() { @@ -1263,7 +1292,7 @@ fn build_instanced_chunk( }; let has_feature_edges = first.include_edges && !source.edge_verts.is_empty(); let mut wire_indices = Vec::new(); - if first.include_edges && !has_feature_edges { + if first.include_edges && !has_feature_edges && !split_geometry { wire_indices.reserve(first.indices.len() * 2); for triangle in first.indices.chunks_exact(3) { wire_indices.extend_from_slice(&[ @@ -1330,7 +1359,80 @@ fn build_instanced_chunk( } else { 0 }; - let chunk = make_chunk( + // Upload each source chunk once, then share it across all instance chunks. + let mut geometry = Vec::new(); + if split_geometry { + let max_triangles = (super::gpu_budget::max_elements::(device) / 3) + .min(super::gpu_budget::max_elements::(device) / 3) + .min(super::gpu_budget::max_elements::(device) / 6) + .max(1); + if first.include_faces || needs_wire_vertices { + for triangles in first.indices.chunks(max_triangles * 3) { + let vertices: Vec<_> = triangles + .iter() + .map(|index| vertex(*index as usize)) + .collect(); + let indices: Vec<_> = (0..vertices.len() as u32).collect(); + let wire_indices: Vec<_> = if needs_wire_vertices { + indices + .chunks_exact(3) + .flat_map(|t| [t[0], t[1], t[1], t[2], t[2], t[0]]) + .collect() + } else { + Vec::new() + }; + geometry.push(make_chunk( + device, + queue, + stubs, + &vertices, + if first.include_faces && !transparent { + &indices + } else { + &[] + }, + if first.include_faces && transparent { + &indices + } else { + &[] + }, + &wire_indices, + &[], + &[], + &[], + &handles, + Some(bounds), + material, + color, + None, + None, + )); + } + } + for edges in edge_verts.chunks(super::gpu_budget::max_elements_grouped::( + device, 2, + )) { + geometry.push(make_chunk( + device, + queue, + stubs, + &[], + &[], + &[], + &[], + edges, + &[], + &[], + &handles, + Some(bounds), + material, + color, + None, + None, + )); + } + } else { + let mut chunk = make_chunk( device, queue, stubs, @@ -1339,8 +1441,8 @@ fn build_instanced_chunk( transparent_indices, &wire_indices, &edge_verts, - &highlights, - &instances, + &[], + &[], &handles, Some(bounds), material, @@ -1348,19 +1450,60 @@ fn build_instanced_chunk( shared_vertex_buffer, shared_edge_buffer, ); - buffers - .vertices - .entry(vertex_key) - .or_insert_with(|| chunk.vertex_buffer.clone()); - if first.include_edges { + if first.include_edges { + // Cached buffers still carry the complete source edge count. + chunk.edge_vertex_count = source.edge_verts.len() as u32; + buffers + .edges + .entry(edge_key) + .or_insert_with(|| chunk.edge_vertex_buffer.clone()); + } buffers - .edges - .entry(edge_key) - .or_insert_with(|| chunk.edge_vertex_buffer.clone()); + .vertices + .entry(vertex_key) + .or_insert_with(|| chunk.vertex_buffer.clone()); + geometry.push(chunk); + } + let mut chunks = Vec::new(); + let max_instances = super::gpu_budget::max_elements::(device); + for (group, instance_data) in instances.chunks(max_instances).enumerate() { + let start = group * max_instances; + let end = start + instance_data.len(); + let instance_buffer = super::gpu_upload::upload_buffer( + device, + queue, + "mesh.batch.instances", + instance_data, + wgpu::BufferUsages::VERTEX, + ); + let group_handles: rustc_hash::FxHashSet<_> = parts[start..end] + .iter() + .filter_map(|part| part.entity_handle) + .collect(); + for source_chunk in &geometry { + let mut chunk = source_chunk.clone(); + chunk.instance_buffer = instance_buffer.clone(); + chunk.instance_count = instance_data.len() as u32; + chunk.handles = group_handles.clone(); + let index_count = chunk.index_count + chunk.transp_index_count; + chunk.highlight_ranges = highlights + .iter() + .filter(|range| { + (start..end).contains(&(range.instance_start as usize)) && index_count > 0 + }) + .map(|range| MeshBatchRange { + instance_start: range.instance_start - start as u32, + index_count, + ..*range + }) + .collect(); + chunks.push(chunk); + } } let uploaded_at = profile_enabled.then(iced::time::Instant::now); + let chunk_count = chunks.len(); Some(( - chunk, + chunks, triangles, InstancedBuildProfile { vertices: started @@ -1379,10 +1522,14 @@ fn build_instanced_chunk( .zip(uploaded_at) .map(|(start, end)| end.duration_since(start)) .unwrap_or_default(), - vertex_count: verts.len(), + vertex_count: if split_geometry && (first.include_faces || needs_wire_vertices) { + first.indices.len() + } else { + verts.len() + }, edge_count: edge_verts.len(), index_count: first.indices.len(), - chunk_count: 1, + chunk_count, }, )) } @@ -1802,34 +1949,6 @@ pub fn build_mesh_batch_filtered( for part in ordered { let eligible = part.set.instance_transform.is_some() && part.set.instance_source.is_some() - && part - .mesh - .verts - .len() - .saturating_mul(std::mem::size_of::()) - <= budget - && part.indices.len().saturating_mul(std::mem::size_of::()) - <= budget - && part - .set - .instance_source - .as_ref() - .is_some_and(|source| { - let feature_edges_fit = source - .edge_verts - .len() - .saturating_mul(std::mem::size_of::()) - <= budget; - let triangle_edges_fit = !part.include_edges - || !source.edge_verts.is_empty() - || part - .indices - .len() - .saturating_mul(2) - .saturating_mul(std::mem::size_of::()) - <= budget; - feature_edges_fit && triangle_edges_fit - }) && part .indices .iter() @@ -2264,7 +2383,7 @@ pub fn build_mesh_batch_filtered( let mut instanced_profile = InstancedBuildProfile::default(); let mut instanced_buffers = InstancedBufferCache::default(); for parts in instance_groups.values() { - if let Some((chunk, triangles, profile)) = build_instanced_chunk( + if let Some((instanced, triangles, profile)) = build_instanced_chunks( device, queue, &stubs, @@ -2273,7 +2392,7 @@ pub fn build_mesh_batch_filtered( perf_started.is_some(), ) { total_tris += triangles; - chunks.push(chunk); + chunks.extend(instanced); instanced_profile.vertices += profile.vertices; instanced_profile.edges += profile.edges; instanced_profile.instances += profile.instances; diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index b3ade3daf..8e983d497 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +mod gpu_tests; mod device_capabilities; pub mod face3d_gpu; pub mod gpu_budget; @@ -155,24 +157,21 @@ pub struct Pipeline { /// GPU glyph atlas (texture + sampler + bind group). Rebuilt when the shared /// CPU atlas grows (new glyphs baked). `None` until the first text upload. text_atlas_gpu: Option, - /// All glyph-quad vertices for the frame, one buffer, `None` when empty. - text_vbuf: Option, - text_vcount: u32, + /// Glyph-quad vertices for the frame, split at whole glyph boundaries. + text_gpu: Vec, block_text_gpu: Vec, block_text_highlight_gpu: Vec, /// Tinted glyph quads of just the selected / hovered text, drawn over the /// base text pass so a selection / rollover recolours the glyphs (the text /// analogue of the selected-wire xray overlay). Rebuilt on selection change. - text_highlight_vbuf: Option, - text_highlight_vcount: u32, + text_highlight_gpu: Vec, /// Live grip-drag / command-preview SDF glyph quads. The base text buffer /// only re-uploads on a geometry-epoch change, but a grip drag hides the /// dragged entity from the base wire set and shows it as a per-frame /// overlay — so its glyphs ride here, re-uploaded every frame like /// `gpu_preview_wires`, or the dragged text vanishes until release re-tesses /// it back into the base set (issue #316). - text_preview_vbuf: Option, - text_preview_vcount: u32, + text_preview_gpu: Vec, /// Per-frame silhouette line lists from the kernel mesh and current view. silhouette_chunks: Vec, silhouette_source_key: (usize, usize, u64), @@ -2222,14 +2221,11 @@ impl Pipeline { block_text_highlight_pipeline, text_atlas_bgl, text_atlas_gpu: None, - text_vbuf: None, - text_vcount: 0, + text_gpu: Vec::new(), block_text_gpu: Vec::new(), block_text_highlight_gpu: Vec::new(), - text_highlight_vbuf: None, - text_highlight_vcount: 0, - text_preview_vbuf: None, - text_preview_vcount: 0, + text_highlight_gpu: Vec::new(), + text_preview_gpu: Vec::new(), silhouette_chunks: Vec::new(), silhouette_source_key: (usize::MAX, usize::MAX, u64::MAX), silhouette_source_groups: Vec::new(), @@ -2574,8 +2570,7 @@ impl Pipeline { ) { let perf_started = crate::perf::enabled().then(iced::time::Instant::now); if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() { - self.text_highlight_vbuf = None; - self.text_highlight_vcount = 0; + self.text_highlight_gpu.clear(); self.block_text_highlight_gpu.clear(); return; } @@ -2644,8 +2639,7 @@ impl Pipeline { } } } - self.text_highlight_vcount = out.len() as u32; - self.text_highlight_vbuf = text_gpu::upload_vertices(device, queue, &out); + self.text_highlight_gpu = text_gpu::upload_vertices(device, queue, &out); let mut block_gpu = if let Some(tint) = selected_tint { text_gpu::upload_block_vertex_refs( device, @@ -2706,8 +2700,7 @@ impl Pipeline { verts: &[text_gpu::TextVertex], ) { if verts.is_empty() { - self.text_preview_vbuf = None; - self.text_preview_vcount = 0; + self.text_preview_gpu.clear(); return; } // Ensure the glyph atlas exists / is current even when the base text @@ -2723,8 +2716,7 @@ impl Pipeline { atlas.clear_dirty(); } } - self.text_preview_vbuf = text_gpu::upload_vertices(device, queue, verts); - self.text_preview_vcount = verts.len() as u32; + self.text_preview_gpu = text_gpu::upload_vertices(device, queue, verts); } // The math lives outside the GPU method so it can be unit-tested; see the @@ -3426,8 +3418,7 @@ impl Pipeline { atlas.clear_dirty(); } } - self.text_vbuf = text_gpu::upload_vertices(device, queue, verts); - self.text_vcount = verts.len() as u32; + self.text_gpu = text_gpu::upload_vertices(device, queue, verts); self.block_text_gpu = text_gpu::upload_block_vertices(device, queue, wires, depth_map); if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; @@ -4294,10 +4285,10 @@ impl Pipeline { // Selection / rollover text is drawn later with the selected-wire xray // overlay, after wipeouts, so normal text cannot hide its own tint. if let Some(atlas) = &self.text_atlas_gpu { - let have_base = self.text_vbuf.is_some() && self.text_vcount > 0; + let have_base = !self.text_gpu.is_empty(); let have_blocks = !self.block_text_gpu.is_empty(); let have_preview = - self.text_preview_vbuf.is_some() && self.text_preview_vcount > 0; + !self.text_preview_gpu.is_empty(); if have_base || have_blocks || have_preview { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("text.render_pass"), @@ -4327,11 +4318,9 @@ impl Pipeline { pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); pass.set_bind_group(1, &atlas.bind_group, &[]); - if let Some(vbuf) = &self.text_vbuf { - if self.text_vcount > 0 { - pass.set_vertex_buffer(0, vbuf.slice(..)); - pass.draw(0..self.text_vcount, 0..1); - } + for text in &self.text_gpu { + pass.set_vertex_buffer(0, text.vertex_buffer.slice(..)); + pass.draw(0..text.vertex_count, 0..1); } if have_blocks { pass.set_pipeline(&self.block_text_pipeline); @@ -4342,11 +4331,11 @@ impl Pipeline { } } // Grip-drag / command-preview glyphs, drawn over the base text. - if let Some(pbuf) = &self.text_preview_vbuf { - if self.text_preview_vcount > 0 { - pass.set_pipeline(&self.text_pipeline); - pass.set_vertex_buffer(0, pbuf.slice(..)); - pass.draw(0..self.text_preview_vcount, 0..1); + if have_preview { + pass.set_pipeline(&self.text_pipeline); + for text in &self.text_preview_gpu { + pass.set_vertex_buffer(0, text.vertex_buffer.slice(..)); + pass.draw(0..text.vertex_count, 0..1); } } } @@ -4396,7 +4385,7 @@ impl Pipeline { // Redraws selected wires and text with depth_compare=Always so both // appear on top of all other geometry at full brightness. let have_text_highlight = - (self.text_highlight_vbuf.is_some() && self.text_highlight_vcount > 0) + (!self.text_highlight_gpu.is_empty()) || !self.block_text_highlight_gpu.is_empty(); if !self.gpu_selected_wires.is_empty() || !self.gpu_selected_block_wires.is_empty() @@ -4463,14 +4452,14 @@ impl Pipeline { pass.draw(0..wire.vertex_count, 0..wire.instance_count); } } - if let (Some(atlas), Some(hlbuf)) = - (&self.text_atlas_gpu, &self.text_highlight_vbuf) - { - if self.text_highlight_vcount > 0 { + if let Some(atlas) = &self.text_atlas_gpu { + if !self.text_highlight_gpu.is_empty() { pass.set_pipeline(&self.text_highlight_pipeline); pass.set_bind_group(1, &atlas.bind_group, &[]); - pass.set_vertex_buffer(0, hlbuf.slice(..)); - pass.draw(0..self.text_highlight_vcount, 0..1); + for text in &self.text_highlight_gpu { + pass.set_vertex_buffer(0, text.vertex_buffer.slice(..)); + pass.draw(0..text.vertex_count, 0..1); + } } } if let Some(atlas) = &self.text_atlas_gpu { diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 7ad16f053..cb3e1e482 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -1,17 +1,4 @@ -//! GPU buffers for SDF text quads (Phase 2b of the text-shader initiative). -//! -//! Unlike images (one texture per entity), all glyphs share ONE atlas texture -//! and every glyph is a quad in a single instance-less vertex buffer, so the -//! whole layout draws in one call. Group 1 is just `{ atlas texture, sampler }`; -//! per-glyph colour and draw-order live in the vertices. -//! -//! Vertex positions use the same double-single relative-to-eye encoding as -//! wires/hatches/images so text stays precise at large drawing coordinates. - -// Not yet driven by the render loop — that hook-up (Pipeline fields, per-frame -// upload, a render pass, and suppressing the old stroke text) is the final -// integration step, done with the app running to verify pixels. -#![allow(dead_code)] +//! Chunked SDF text quads sharing one glyph atlas. use iced::wgpu; @@ -91,6 +78,11 @@ impl BlockTextInstance { } } +pub struct TextGpu { + pub vertex_buffer: wgpu::Buffer, + pub vertex_count: u32, +} + pub struct BlockTextGpu { pub vertex_buffer: wgpu::Buffer, pub instance_buffer: wgpu::Buffer, @@ -517,9 +509,7 @@ pub fn upload_block_vertex_refs( } else { source.text_verts.as_slice() }; - let Some(vertex_buffer) = upload_vertices(device, queue, vertices) else { - continue; - }; + let vertex_chunks = upload_vertices(device, queue, vertices); let instances: Vec = group .iter() .filter_map(|wire| { @@ -552,33 +542,40 @@ pub fn upload_block_vertex_refs( chunk, wgpu::BufferUsages::VERTEX, ); - out.push(BlockTextGpu { - vertex_buffer: vertex_buffer.clone(), - instance_buffer, - vertex_count: vertices.len() as u32, - instance_count: chunk.len() as u32, - }); + for text in &vertex_chunks { + out.push(BlockTextGpu { + vertex_buffer: text.vertex_buffer.clone(), + instance_buffer: instance_buffer.clone(), + vertex_count: text.vertex_count, + instance_count: chunk.len() as u32, + }); + } } } out } -/// Upload a finished vertex list; `None` if empty. +/// Upload whole glyph quads within the per-buffer budget. pub fn upload_vertices( device: &wgpu::Device, queue: &wgpu::Queue, verts: &[TextVertex], -) -> Option { - if verts.is_empty() { - return None; - } - Some(super::gpu_upload::upload_buffer( - device, - queue, - "text.vbuf", - verts, - wgpu::BufferUsages::VERTEX, - )) +) -> Vec { + verts + .chunks(super::gpu_budget::max_elements_grouped::( + device, 6, + )) + .map(|chunk| TextGpu { + vertex_buffer: super::gpu_upload::upload_buffer( + device, + queue, + "text.vbuf", + chunk, + wgpu::BufferUsages::VERTEX, + ), + vertex_count: chunk.len() as u32, + }) + .collect() } // ── Tests ───────────────────────────────────────────────────────────────── diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 8f15c5fba..cf9707c58 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -50,13 +50,6 @@ const HEADROOM_NUM: u64 = 3; const HEADROOM_DEN: u64 = 2; const MIN_INST_CAP: u64 = 4096; const MIN_CONST_CAP: u64 = 1024; -/// wgpu caps a single buffer at 256 MB. An arena keeps ONE instance buffer per -/// batch, so a batch whose instances exceed this can't be an arena — build -/// returns None and the caller falls back to the chunked batched path. The -/// buffer (with headroom) is also clamped here so it never exceeds the limit. -const MAX_INSTANCES: u64 = 268_435_456 / std::mem::size_of::() as u64; -const MAX_CONSTS: u64 = 268_435_456 / std::mem::size_of::() as u64; - struct Slab { inst_off: u32, inst_len: u32, @@ -320,6 +313,8 @@ impl WireArena { const_bgl: &wgpu::BindGroupLayout, mesh_edge: bool, ) -> Option { + let instance_limit = super::gpu_budget::max_elements::(device) as u64; + let constant_limit = super::gpu_budget::max_storage_elements::(device) as u64; let ranges = handle_ranges(wires)?; let perf = crate::perf::enabled(); let total_started = iced::time::Instant::now(); @@ -327,11 +322,8 @@ impl WireArena { // Reject an oversized batch before parallel emission allocates hundreds // of megabytes. `points.len() - 1` is an upper bound because NaN-break // segments are skipped by emit_wire_native. - let max_instances: usize = wires - .iter() - .map(|w| w.points.len().saturating_sub(1)) - .sum(); - if max_instances as u64 > MAX_INSTANCES || wires.len() as u64 + 1 > MAX_CONSTS { + let max_instances: usize = wires.iter().map(|w| w.points.len().saturating_sub(1)).sum(); + if max_instances as u64 > instance_limit || wires.len() as u64 + 1 > constant_limit { return None; } @@ -411,7 +403,7 @@ impl WireArena { let inst_count: usize = packed.iter().map(|slab| slab.instances.len()).sum(); let const_count: usize = 1 + packed.iter().map(|slab| slab.consts.len()).sum::(); - if inst_count as u64 > MAX_INSTANCES || const_count as u64 > MAX_CONSTS { + if inst_count as u64 > instance_limit || const_count as u64 > constant_limit { return None; } @@ -444,15 +436,15 @@ impl WireArena { let const_tail = consts_cpu.len() as u32; // A batch bigger than one buffer can't be an arena — let the caller chunk // it via the batched path. - if inst_tail as u64 > MAX_INSTANCES || const_tail as u64 > MAX_CONSTS { + if inst_tail as u64 > instance_limit || const_tail as u64 > constant_limit { return None; } let inst_cap = ((inst_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) .max(MIN_INST_CAP) - .min(MAX_INSTANCES)) as u32; + .min(instance_limit)) as u32; let const_cap = ((const_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) .max(MIN_CONST_CAP) - .min(MAX_CONSTS)) as u32; + .min(constant_limit)) as u32; let upload_started = iced::time::Instant::now(); let inst_buf = alloc_inst_initialized(device, queue, inst_cap as u64, &instances); let const_buf = alloc_const_initialized(device, queue, const_cap as u64, &consts_cpu); @@ -897,9 +889,6 @@ struct PreparedPackedPatchRun { order_sensitive: bool, } -const MAX_PACKED_INSTANCES: u64 = - 268_435_456 / std::mem::size_of::() as u64; - fn blank_packed_instance() -> PackedWireInstance { let mut blank = ::zeroed(); blank.pattern_length = -1.0; @@ -982,12 +971,13 @@ impl PackedWireArena { depth_map: &FxHashMap, mesh_edge: bool, ) -> Option { + let packed_limit = super::gpu_budget::max_elements::(device) as u64; let ranges = handle_ranges(wires)?; let max_instances: usize = wires .iter() .map(|wire| wire.points.len().saturating_sub(1)) .sum(); - if max_instances as u64 > MAX_PACKED_INSTANCES { + if max_instances as u64 > packed_limit { return None; } @@ -1031,7 +1021,7 @@ impl PackedWireArena { .collect(); let inst_count: usize = packed.iter().map(|slab| slab.instances.len()).sum(); - if inst_count as u64 > MAX_PACKED_INSTANCES { + if inst_count as u64 > packed_limit { return None; } let mut instances = Vec::with_capacity(inst_count); @@ -1057,7 +1047,7 @@ impl PackedWireArena { let inst_tail = instances.len() as u32; let inst_cap = ((inst_tail as u64 * HEADROOM_NUM / HEADROOM_DEN) .max(MIN_INST_CAP) - .min(MAX_PACKED_INSTANCES)) as u32; + .min(packed_limit)) as u32; let inst_buf = alloc_packed_initialized(device, queue, inst_cap as u64, &instances); Some(Self { diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 215854b77..9d65d44a7 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -276,9 +276,7 @@ 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. + /// Per-wire constants for this chunk (group 1); packed instances carry them inline. pub const_bind_group: Option>, } @@ -829,10 +827,7 @@ impl BlockWireGpu { } } -/// Build the shared per-wire `WireConst` storage buffer and its bind group -/// (storage path). All chunks from one build reference the same -/// buffer via their global `wire_id`, so a single bind group is cloned into -/// each chunk. +/// Upload one constant chunk; its instances use chunk-local wire IDs. fn build_const_bind_group( device: &wgpu::Device, queue: &wgpu::Queue, @@ -861,6 +856,48 @@ fn build_const_bind_group( } impl WireGpu { + fn upload_native( + device: &wgpu::Device, + queue: &wgpu::Queue, + const_bgl: &wgpu::BindGroupLayout, + mut instances: Vec, + consts: &[WireConst], + mesh_edge: bool, + label: &str, + ) -> Vec { + let max_consts = super::gpu_budget::max_storage_elements::(device); + let max_instances = super::gpu_budget::max_elements::(device); + let mut out = Vec::new(); + let mut remaining = instances.as_mut_slice(); + for (group, constants) in consts.chunks(max_consts).enumerate() { + let base = group * max_consts; + let count = + remaining.partition_point(|item| (item.wire_id as usize) < base + constants.len()); + let (items, rest) = remaining.split_at_mut(count); + remaining = rest; + if items.is_empty() { + continue; + } + for item in items.iter_mut() { + item.wire_id -= base as u32; + } + debug_assert!(items + .iter() + .all(|item| (item.wire_id as usize) < constants.len())); + let bind_group = build_const_bind_group(device, queue, const_bgl, constants); + for chunk in items.chunks(max_instances) { + out.push(Self { + instance_buffer: instance_buffer(device, queue, label, chunk), + first_instance: 0, + instance_count: chunk.len() as u32, + is_3d_mesh_edge: mesh_edge, + const_bind_group: Some(bind_group.clone()), + }); + } + } + out + } + /// Build a small selection/hover overlay from borrowed resident wires while /// overriding their colour. Avoids deep-cloning every point/text/fill array /// of a large selected polyline or block before packing the overlay. @@ -873,8 +910,6 @@ impl WireGpu { const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { if let Some(const_bgl) = const_bgl { - const MAX_INSTANCES: usize = - 268_435_456 / std::mem::size_of::(); use crate::par::prelude::*; let per: Vec<(Vec, WireConst)> = wires .par_iter() @@ -893,27 +928,19 @@ impl WireGpu { if instances.is_empty() { return Vec::new(); } - let bind_group = build_const_bind_group(device, queue, const_bgl, &consts); - return instances - .chunks(MAX_INSTANCES) - .map(|chunk| Self { - instance_buffer: instance_buffer( - device, - queue, - "wire.highlight.ibuf", - chunk, - ), - first_instance: 0, - instance_count: chunk.len() as u32, - is_3d_mesh_edge: false, - const_bind_group: Some(bind_group.clone()), - }) - .collect(); + return Self::upload_native( + device, + queue, + const_bgl, + instances, + &consts, + false, + "wire.highlight.ibuf", + ); } let _ = const_bgl; - const MAX_PACKED_INSTANCES: usize = - 268_435_456 / std::mem::size_of::(); + let max_packed_instances = super::gpu_budget::max_elements::(device); let per: Vec> = wires .iter() .map(|wire| { @@ -925,7 +952,7 @@ impl WireGpu { instances.append(&mut items); } instances - .chunks(MAX_PACKED_INSTANCES) + .chunks(max_packed_instances) .map(|chunk| Self { instance_buffer: instance_buffer( device, @@ -941,10 +968,7 @@ impl WireGpu { .collect() } - /// Equivalent of [`from_run`] for an already partitioned set of borrowed - /// wires. Used when one arena partition exceeds the 256 MB buffer limit: - /// the compatible partition stays patchable while only the oversized side - /// uses chunked resident buffers. + /// Upload borrowed wires when a partition exceeds the arena budget. pub fn from_run_refs( device: &wgpu::Device, queue: &wgpu::Queue, @@ -954,8 +978,7 @@ impl WireGpu { const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { let Some(const_bgl) = const_bgl else { - const MAX_INSTANCES: usize = - 268_435_456 / std::mem::size_of::(); + let max_instances = super::gpu_budget::max_elements::(device); use crate::par::prelude::*; let per: Vec> = wires .par_iter() @@ -974,7 +997,7 @@ impl WireGpu { instances.append(&mut items); } return instances - .chunks(MAX_INSTANCES) + .chunks(max_instances) .map(|chunk| Self { instance_buffer: instance_buffer( device, @@ -989,8 +1012,6 @@ impl WireGpu { }) .collect(); }; - const MAX_INSTANCES: usize = - 268_435_456 / std::mem::size_of::(); use crate::par::prelude::*; let per: Vec<(Vec, WireConst)> = wires .par_iter() @@ -1014,23 +1035,18 @@ impl WireGpu { if instances.is_empty() { return Vec::new(); } - let bind_group = build_const_bind_group(device, queue, const_bgl, &consts); - instances - .chunks(MAX_INSTANCES) - .map(|chunk| Self { - instance_buffer: instance_buffer(device, queue, "wire.run.hybrid.ibuf", chunk), - first_instance: 0, - instance_count: chunk.len() as u32, - is_3d_mesh_edge: mesh_edge, - const_bind_group: Some(bind_group.clone()), - }) - .collect() + Self::upload_native( + device, + queue, + const_bgl, + instances, + &consts, + mesh_edge, + "wire.run.hybrid.ibuf", + ) } - /// Merge a run of WireModels that share scissor + mesh-edge state into one - /// (or, past the 256 MB GPU limit, a few) instance buffer(s), then stamp - /// the shared `scissor` / `mesh_edge` onto each so the draw loop treats the - /// whole run as a single batch. + /// Upload a run of wires in submission order, within the buffer budget. /// /// Unlike [`from_batch`], instance order is **guaranteed** to follow wire /// order (parallel `collect` is index-ordered; the flatten is sequential). @@ -1046,12 +1062,8 @@ impl WireGpu { const_bgl: Option<&wgpu::BindGroupLayout>, ) -> Vec { 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 - // submission order for depth-biased / transparent overlap). + // Indexed collection preserves transparent and coincident wire order. let per: Vec<(Vec, WireConst)> = wires .par_iter() .enumerate() @@ -1074,25 +1086,19 @@ impl WireGpu { if instances.is_empty() { return vec![]; } - let bg = build_const_bind_group(device, queue, const_bgl, &consts); - return instances - .chunks(MAX_INSTANCES) - .map(|chunk| { - let buf = instance_buffer(device, queue, "wire.run.ibuf", chunk); - Self { - instance_buffer: buf, - first_instance: 0, - instance_count: chunk.len() as u32, - is_3d_mesh_edge: mesh_edge, - const_bind_group: Some(bg.clone()), - } - }) - .collect(); + return Self::upload_native( + device, + queue, + const_bgl, + instances, + &consts, + mesh_edge, + "wire.run.ibuf", + ); } let _ = const_bgl; - const MAX_PACKED_INSTANCES: usize = - 268_435_456 / std::mem::size_of::(); + let max_packed_instances = super::gpu_budget::max_elements::(device); let per: Vec> = wires .iter() .map(|w| { @@ -1109,7 +1115,7 @@ impl WireGpu { return vec![]; } instances - .chunks(MAX_PACKED_INSTANCES) + .chunks(max_packed_instances) .map(|chunk| { let buf = instance_buffer(device, queue, "wire.run.compat.ibuf", chunk); Self { @@ -1123,9 +1129,7 @@ impl WireGpu { .collect() } - /// Merge multiple WireModels into GPU instance buffers, chunked to fit the - /// 256 MB GPU limit. Each wire keeps its own color and pattern — they live - /// per-instance. + /// Upload wires in chunks that retain each wire’s color and pattern. pub fn from_batch( device: &wgpu::Device, queue: &wgpu::Queue, @@ -1138,9 +1142,6 @@ impl WireGpu { return vec![]; } 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 @@ -1162,27 +1163,19 @@ impl WireGpu { if instances.is_empty() { return vec![]; } - let bg = build_const_bind_group(device, queue, const_bgl, &consts); - return instances - .chunks(MAX_INSTANCES) - .enumerate() - .map(|(i, chunk)| { - let label = format!("wire.batch.ibuf.{i}"); - let instance_buffer = instance_buffer(device, queue, &label, chunk); - Self { - instance_buffer, - first_instance: 0, - instance_count: chunk.len() as u32, - is_3d_mesh_edge: false, - const_bind_group: Some(bg.clone()), - } - }) - .collect(); + return Self::upload_native( + device, + queue, + const_bgl, + instances, + &consts, + false, + "wire.batch.ibuf", + ); } let _ = const_bgl; - const MAX_PACKED_INSTANCES: usize = - 268_435_456 / std::mem::size_of::(); + let max_packed_instances = super::gpu_budget::max_elements::(device); let instances: Vec = wires .iter() .flat_map(|w| emit_wire_packed(w, w.color, wire_draw_depth(w, depth_map))) @@ -1191,7 +1184,7 @@ impl WireGpu { return vec![]; } instances - .chunks(MAX_PACKED_INSTANCES) + .chunks(max_packed_instances) .enumerate() .map(|(i, chunk)| { let label = format!("wire.batch.compat.ibuf.{i}"); diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 8f83f4ab8..fb739ce64 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -360,22 +360,7 @@ impl Scene { self.selected.len() } - /// Returns the sorted set of entity-type names present in the active - /// layout. Used to populate the Quick Select "Object type" dropdown - /// with only the types that actually exist in the drawing. - /// Distinct entity type names in the current layout, for the - /// selection-filter menu. - /// - /// The status bar rebuilds on every frame, so this used to run once per - /// mouse move over the canvas: a full clone of the layout's handle list - /// (1.16 M handles, ~9 MB, on the reproducer), then a lookup, a `String` - /// allocation and a `BTreeSet` insert for each one — about 107 ms of a - /// 110 ms frame, to produce roughly twenty names. - /// - /// The answer only changes when entities are added or removed, so it is - /// cached against `geometry_epoch` and the layout block. The scan itself - /// now borrows the handle list instead of cloning it and collects - /// `&str`, allocating only for the names it actually returns. + /// Sorted entity types in the current layout, cached until geometry or layout changes. pub fn entity_type_names_in_layout(&self) -> std::sync::Arc> { use crate::entities::traits::entity_type_name; let block = self.current_layout_block_handle(); @@ -969,6 +954,46 @@ impl Scene { mod tests { use super::*; + #[test] + fn layout_type_cache_reuses_and_invalidates_on_edits_undo_and_layout() { + use acadrust::entities::{Circle, EntityType, Line}; + use acadrust::types::Vector3; + use std::sync::Arc; + + let mut scene = Scene::new(); + let empty = scene.entity_type_names_in_layout(); + assert!(empty.is_empty()); + assert!(Arc::ptr_eq(&empty, &scene.entity_type_names_in_layout())); + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + let lines = scene.entity_type_names_in_layout(); + assert_eq!(lines.as_slice(), ["Line"]); + assert!(Arc::ptr_eq(&lines, &scene.entity_type_names_in_layout())); + let circle = scene.add_entity(EntityType::Circle(Circle::new())); + let before = scene.document.get_entity_arc(circle); + assert_eq!( + scene.entity_type_names_in_layout().as_slice(), + ["Circle", "Line"] + ); + scene.erase_entities(&[circle]); + assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Line"]); + let changes = scene.apply_entity_delta(&[(circle, before, None)], true); + scene.bump_entities(&changes); + assert_eq!( + scene.entity_type_names_in_layout().as_slice(), + ["Circle", "Line"] + ); + scene.set_current_layout("Layout1".to_owned()); + assert!(scene.entity_type_names_in_layout().is_empty()); + scene.set_current_layout("Model".to_owned()); + assert_eq!( + scene.entity_type_names_in_layout().as_slice(), + ["Circle", "Line"] + ); + } + #[test] fn selection_fingerprint_tracks_final_set_only() { let mut scene = Scene::default();