forked from 1b-agent/opencadstudio-upstream
Bound GPU uploads and complete Greek and performance integration
This commit is contained in:
1 parent
89624f64fc
commit
9d991d7feb
14 files changed
+678
-380
No files matched your search
@@ -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
|
||||
|
||||
+30
@@ -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\"");
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<iced::time::Instant>) -> 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);
|
||||
|
||||
+48
-53
@@ -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<WireModel> {
|
||||
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<WireModel> = 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<Arc<Vec<WireModel>>> = 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<WireModel> = 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<Vec<WireModel>>)> = 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<WireModel> = 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,
|
||||
|
||||
@@ -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<usize> = std::sync::OnceLock::new();
|
||||
*CAP.get_or_init(|| {
|
||||
@@ -35,7 +16,7 @@ fn cap_bytes() -> usize {
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<usize>().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<T>(device: &wgpu::Device, group: usize) -> usize {
|
||||
let group = group.max(1);
|
||||
(max_elements::<T>(device) / group).max(1) * group
|
||||
}
|
||||
|
||||
/// Storage buffers must also fit in one binding.
|
||||
pub fn max_storage_elements<T>(device: &wgpu::Device) -> usize {
|
||||
(buffer_budget(device).min(device.limits().max_storage_buffer_binding_size as usize)
|
||||
/ std::mem::size_of::<T>().max(1))
|
||||
.max(1)
|
||||
}
|
||||
@@ -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::<wire_gpu::WireConst>(&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::<usize>(),
|
||||
count
|
||||
);
|
||||
assert!(chunks
|
||||
.iter()
|
||||
.all(|chunk| chunk.instance_buffer.size() <= budget));
|
||||
}
|
||||
let segments = gpu_budget::max_elements::<wire_gpu::WireInstance>(&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::<usize>(),
|
||||
segments
|
||||
);
|
||||
assert!(chunks
|
||||
.iter()
|
||||
.all(|chunk| chunk.instance_buffer.size() <= budget));
|
||||
|
||||
let vertices = vec![
|
||||
bytemuck::Zeroable::zeroed();
|
||||
gpu_budget::max_elements_grouped::<text_gpu::TextVertex>(&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::<usize>(),
|
||||
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::<mesh_gpu::MeshVertex>(&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::<usize>(),
|
||||
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::<mesh_gpu::MeshInstanceGpu>(&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::<usize>(),
|
||||
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"
|
||||
);
|
||||
}
|
||||
@@ -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<T: bytemuck::Pod>(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
|
||||
+176
-57
@@ -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<MeshBatchChunk>, 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::<MeshVertex>())
|
||||
> budget
|
||||
|| first
|
||||
.indices
|
||||
.len()
|
||||
.saturating_mul(std::mem::size_of::<u32>())
|
||||
> budget
|
||||
|| (needs_wire_vertices
|
||||
&& first
|
||||
.indices
|
||||
.len()
|
||||
.saturating_mul(2 * std::mem::size_of::<MeshEdgeVertex>())
|
||||
> budget)
|
||||
|| (first.include_edges
|
||||
&& source
|
||||
.edge_verts
|
||||
.len()
|
||||
.saturating_mul(std::mem::size_of::<MeshEdgeVertex>())
|
||||
> 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::<MeshVertex>(device) / 3)
|
||||
.min(super::gpu_budget::max_elements::<u32>(device) / 3)
|
||||
.min(super::gpu_budget::max_elements::<MeshEdgeVertex>(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::<MeshEdgeVertex>(
|
||||
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::<MeshInstanceGpu>(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::<MeshVertex>())
|
||||
<= budget
|
||||
&& part.indices.len().saturating_mul(std::mem::size_of::<u32>())
|
||||
<= 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::<MeshEdgeVertex>())
|
||||
<= 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::<MeshEdgeVertex>())
|
||||
<= 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;
|
||||
|
||||
+31
-42
@@ -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<text_gpu::TextAtlasGpu>,
|
||||
/// All glyph-quad vertices for the frame, one buffer, `None` when empty.
|
||||
text_vbuf: Option<wgpu::Buffer>,
|
||||
text_vcount: u32,
|
||||
/// Glyph-quad vertices for the frame, split at whole glyph boundaries.
|
||||
text_gpu: Vec<text_gpu::TextGpu>,
|
||||
block_text_gpu: Vec<text_gpu::BlockTextGpu>,
|
||||
block_text_highlight_gpu: Vec<text_gpu::BlockTextGpu>,
|
||||
/// 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<wgpu::Buffer>,
|
||||
text_highlight_vcount: u32,
|
||||
text_highlight_gpu: Vec<text_gpu::TextGpu>,
|
||||
/// 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<wgpu::Buffer>,
|
||||
text_preview_vcount: u32,
|
||||
text_preview_gpu: Vec<text_gpu::TextGpu>,
|
||||
/// Per-frame silhouette line lists from the kernel mesh and current view.
|
||||
silhouette_chunks: Vec<SilhouetteChunk>,
|
||||
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 {
|
||||
|
||||
@@ -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<BlockTextInstance> = 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<wgpu::Buffer> {
|
||||
if verts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(super::gpu_upload::upload_buffer(
|
||||
device,
|
||||
queue,
|
||||
"text.vbuf",
|
||||
verts,
|
||||
wgpu::BufferUsages::VERTEX,
|
||||
))
|
||||
) -> Vec<TextGpu> {
|
||||
verts
|
||||
.chunks(super::gpu_budget::max_elements_grouped::<TextVertex>(
|
||||
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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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::<WireInstance>() as u64;
|
||||
const MAX_CONSTS: u64 = 268_435_456 / std::mem::size_of::<WireConst>() as u64;
|
||||
|
||||
struct Slab {
|
||||
inst_off: u32,
|
||||
inst_len: u32,
|
||||
@@ -320,6 +313,8 @@ impl WireArena {
|
||||
const_bgl: &wgpu::BindGroupLayout,
|
||||
mesh_edge: bool,
|
||||
) -> Option<Self> {
|
||||
let instance_limit = super::gpu_budget::max_elements::<WireInstance>(device) as u64;
|
||||
let constant_limit = super::gpu_budget::max_storage_elements::<WireConst>(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::<usize>();
|
||||
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::<PackedWireInstance>() as u64;
|
||||
|
||||
fn blank_packed_instance() -> PackedWireInstance {
|
||||
let mut blank = <PackedWireInstance as bytemuck::Zeroable>::zeroed();
|
||||
blank.pattern_length = -1.0;
|
||||
@@ -982,12 +971,13 @@ impl PackedWireArena {
|
||||
depth_map: &FxHashMap<u64, [f32; 2]>,
|
||||
mesh_edge: bool,
|
||||
) -> Option<Self> {
|
||||
let packed_limit = super::gpu_budget::max_elements::<PackedWireInstance>(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 {
|
||||
|
||||
@@ -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<std::sync::Arc<wgpu::BindGroup>>,
|
||||
}
|
||||
|
||||
@@ -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<WireInstance>,
|
||||
consts: &[WireConst],
|
||||
mesh_edge: bool,
|
||||
label: &str,
|
||||
) -> Vec<Self> {
|
||||
let max_consts = super::gpu_budget::max_storage_elements::<WireConst>(device);
|
||||
let max_instances = super::gpu_budget::max_elements::<WireInstance>(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<Self> {
|
||||
if let Some(const_bgl) = const_bgl {
|
||||
const MAX_INSTANCES: usize =
|
||||
268_435_456 / std::mem::size_of::<WireInstance>();
|
||||
use crate::par::prelude::*;
|
||||
let per: Vec<(Vec<WireInstance>, 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::<PackedWireInstance>();
|
||||
let max_packed_instances = super::gpu_budget::max_elements::<PackedWireInstance>(device);
|
||||
let per: Vec<Vec<PackedWireInstance>> = 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<Self> {
|
||||
let Some(const_bgl) = const_bgl else {
|
||||
const MAX_INSTANCES: usize =
|
||||
268_435_456 / std::mem::size_of::<PackedWireInstance>();
|
||||
let max_instances = super::gpu_budget::max_elements::<PackedWireInstance>(device);
|
||||
use crate::par::prelude::*;
|
||||
let per: Vec<Vec<PackedWireInstance>> = 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::<WireInstance>();
|
||||
use crate::par::prelude::*;
|
||||
let per: Vec<(Vec<WireInstance>, 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<Self> {
|
||||
if let Some(const_bgl) = const_bgl {
|
||||
const MAX_INSTANCES: usize =
|
||||
268_435_456 / std::mem::size_of::<WireInstance>();
|
||||
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<WireInstance>, 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::<PackedWireInstance>();
|
||||
let max_packed_instances = super::gpu_budget::max_elements::<PackedWireInstance>(device);
|
||||
let per: Vec<Vec<PackedWireInstance>> = 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::<WireInstance>();
|
||||
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::<PackedWireInstance>();
|
||||
let max_packed_instances = super::gpu_budget::max_elements::<PackedWireInstance>(device);
|
||||
let instances: Vec<PackedWireInstance> = 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}");
|
||||
|
||||
+41
-16
@@ -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<Vec<String>> {
|
||||
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();
|
||||
|
||||
Reference in new issue
Block a user