Bound GPU uploads and complete Greek and performance integration

This commit is contained in:
Hakan Seven committed 2026-09-04 20:26:29 +03:00
1 parent 89624f64fc
commit 9d991d7feb
14 files changed
+678 -380

No files matched your search

+19
View File
@@ -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
View File
@@ -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\"");
}
}
+2 -2
View File
@@ -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 {
+5
View File
@@ -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
View File
@@ -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,
+11 -23
View File
@@ -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)
}
+177
View File
@@ -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"
);
}
+2 -31
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+32 -35
View File
@@ -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 ─────────────────────────────────────────────────────────────────
+12 -22
View File
@@ -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 {
+92 -99
View File
@@ -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
View File
@@ -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();