From 39449ec6849c98fb476a1b6f8f172c9608a4e66f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 25 Jul 2026 09:05:52 +0300 Subject: [PATCH] perf: speed large drawing workflows --- Cargo.lock | 3 +- Cargo.toml | 4 +- src/app/document.rs | 4 + src/app/mod.rs | 11 + src/app/update/file.rs | 67 +++--- src/io/thumbnail.rs | 217 +++++++++++++++++-- src/scene/pick/interaction_index.rs | 316 +++++++++++++++++++--------- src/scene/pipeline/mod.rs | 14 ++ src/scene/pipeline/wire_gpu.rs | 50 +++++ src/scene/view/render.rs | 209 +++++++++++++----- 10 files changed, 700 insertions(+), 195 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3dbce8c4..a8358e52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,7 +74,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.0" -source = "git+https://github.com/OpenAEC-Foundation/acadifc?rev=4c1e5d51ff2c451a031fb5bedd8a7d4ef43e0f56#4c1e5d51ff2c451a031fb5bedd8a7d4ef43e0f56" +source = "git+https://github.com/OpenAEC-Foundation/acadifc?rev=545e6a6c0194ac18f22e5c26e428418d40408b0a#545e6a6c0194ac18f22e5c26e428418d40408b0a" dependencies = [ "ahash 0.8.12", "anyhow", @@ -131,6 +131,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] diff --git a/Cargo.toml b/Cargo.toml index 9da6beb4..f532790e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -89,8 +89,8 @@ lyon_tessellation = "1.0.20" windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] } [patch.crates-io] -# Track the verified parallel DWG builder and table-control round-trip fix. -acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", rev = "4c1e5d51ff2c451a031fb5bedd8a7d4ef43e0f56" } +# Track the verified DWG read/write performance pass. +acadrust = { git = "https://github.com/OpenAEC-Foundation/acadifc", rev = "545e6a6c0194ac18f22e5c26e428418d40408b0a" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # Native enables the plugin host runtime (out-of-process plugins). diff --git a/src/app/document.rs b/src/app/document.rs index bfb29d9d..ce11c45e 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -226,6 +226,9 @@ pub(super) struct DocumentTab { pub(super) active_mleader_style: String, /// Last camera_generation value written back to the document. pub(super) last_synced_camera_gen: u64, + /// Render-state key of `scene.document.preview`. Matching saves reuse the + /// encoded DWG thumbnail instead of rescanning every resident wire. + pub(super) thumbnail_cache_key: Option, /// Sentinel "Welcome / Start" tab. Always at index 0 when present. /// Cannot be closed; the viewport area renders a welcome page instead /// of the model-space shader. The scene is still constructed so the @@ -460,6 +463,7 @@ impl DocumentTab { block_edit: None, active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, + thumbnail_cache_key: None, is_start: false, pan_mode: false, plugin_state: HashMap::new(), diff --git a/src/app/mod.rs b/src/app/mod.rs index c3ea1f92..c06cddeb 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -865,6 +865,15 @@ pub(super) enum SaveContinuation { Quit, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ThumbnailCacheKey { + epoch: u64, + camera_generation: u64, + bg_color: [u32; 4], + png: bool, + viewport: [u32; 2], +} + #[derive(Debug, Clone)] pub struct SaveOutcome { job_id: u64, @@ -877,6 +886,8 @@ pub struct SaveOutcome { set_current_path: bool, purpose: SavePurpose, continuation: SaveContinuation, + thumbnail_key: Option, + refreshed_preview: Option>, result: Result<(), String>, } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 02d27f6f..51c79056 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -750,24 +750,6 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::none() } - /// Rasterize the current drawing into the document's DWG preview image, so - /// a saved file shows a thumbnail in file browsers and other CAD apps. - /// A degenerate/empty drawing clears any stale preview. - /// `target` is the version the file is about to be written as — a PNG - /// preview (few KB vs a ~180 KB DIB) is only valid from R2013 (AC1027) on. - pub(super) fn stamp_thumbnail(&mut self, i: usize, target: acadrust::DxfVersion) { - let png = target >= acadrust::DxfVersion::AC1027; - // Frame the thumbnail exactly as the model pane shows it now (current - // pan/zoom/rotation, visible region only), so pass the pane pixel size. - self.tabs[i].scene.document.preview = - crate::io::thumbnail::from_scene(&self.tabs[i].scene, png, self.vp_size); - // The on-disk thumbnail will change — drop the cached Start-page handle - // so it re-reads the updated file on the next refresh. - if let Some(p) = self.tabs[i].current_path.clone() { - self.recent_thumbs.remove(&p); - } - } - #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn prepare_native_save(&mut self, i: usize) { self.sync_vport_display(i); @@ -800,17 +782,29 @@ pub(super) fn on_open_file(&mut self) -> Task { let epoch = self.tabs[i].scene.geometry_epoch; let revision = self.tabs[i].edit_revision; let camera_generation = self.tabs[i].scene.camera_generation; - let thumbnail = if purpose == crate::app::SavePurpose::Autosave { + let thumbnail_key = (purpose != crate::app::SavePurpose::Autosave).then(|| { + let scene = &self.tabs[i].scene; + crate::app::ThumbnailCacheKey { + epoch, + camera_generation, + bg_color: scene.bg_color.map(f32::to_bits), + png: version >= acadrust::DxfVersion::AC1027, + viewport: [self.vp_size.0.to_bits(), self.vp_size.1.to_bits()], + } + }); + let thumbnail = if thumbnail_key == self.tabs[i].thumbnail_cache_key { None } else { - let scene = &self.tabs[i].scene; - Some(( - scene.entity_wires(), - scene.camera.borrow().clone(), - scene.bg_color, - version >= acadrust::DxfVersion::AC1027, - self.vp_size, - )) + thumbnail_key.map(|key| { + let scene = &self.tabs[i].scene; + ( + scene.entity_wires(), + scene.camera.borrow().clone(), + scene.bg_color, + key.png, + self.vp_size, + ) + }) }; let clone_started = std::time::Instant::now(); let mut snapshot = self.tabs[i].scene.document.clone(); @@ -833,7 +827,8 @@ pub(super) fn on_open_file(&mut self) -> Task { Task::perform( async move { - let result = std::thread::spawn(move || { + let (result, refreshed_preview) = std::thread::spawn(move || { + let mut refreshed_preview = None; if let Some((wires, camera, bg_color, png, viewport)) = thumbnail { let started = std::time::Instant::now(); snapshot.preview = crate::io::thumbnail::from_snapshot( @@ -850,16 +845,18 @@ pub(super) fn on_open_file(&mut self) -> Task { wires.len(), ); } + refreshed_preview = Some(snapshot.preview.clone()); } - crate::io::save_owned_as_version_atomic( + let result = crate::io::save_owned_as_version_atomic( snapshot, &worker_path, version, backup, - ) + ); + (result, refreshed_preview) }) .join() - .unwrap_or_else(|_| Err("save worker panicked".to_string())); + .unwrap_or_else(|_| (Err("save worker panicked".to_string()), None)); crate::app::SaveOutcome { job_id, tab_id, @@ -871,6 +868,8 @@ pub(super) fn on_open_file(&mut self) -> Task { set_current_path, purpose, continuation, + thumbnail_key, + refreshed_preview, result, } }, @@ -921,6 +920,12 @@ pub(super) fn on_open_file(&mut self) -> Task { let snapshot_is_current = self.tabs[i].scene.geometry_epoch == outcome.epoch && self.tabs[i].edit_revision == outcome.revision && self.tabs[i].scene.camera_generation == outcome.camera_generation; + if snapshot_is_current && outcome.purpose != crate::app::SavePurpose::Autosave { + if let Some(preview) = outcome.refreshed_preview { + self.tabs[i].scene.document.preview = preview; + } + self.tabs[i].thumbnail_cache_key = outcome.thumbnail_key; + } let mut tasks = Vec::new(); match outcome.purpose { crate::app::SavePurpose::Autosave => { diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index 2868ec9d..1d911588 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -1,6 +1,6 @@ //! DWG preview thumbnails. //! -//! - [`from_scene`] rasterizes the drawing exactly as it is framed on screen — +//! - [`from_snapshot`] rasterizes the drawing exactly as it is framed on screen — //! the live camera's pan / zoom / rotation, only the currently visible region, //! not the whole extent — into a small [`acadrust::Preview`] embedded on save //! so OCS drawings show a thumbnail in file browsers and other CAD apps. @@ -14,7 +14,7 @@ use iced::Rectangle; use image::{ImageFormat, Rgb, RgbImage}; use std::io::Cursor; -use crate::scene::{Scene, WireModel}; +use crate::scene::WireModel; use crate::scene::view::camera::Camera; /// Longest edge of the generated thumbnail, in pixels. @@ -30,13 +30,8 @@ const MAX_DIM: u32 = 256; /// one colour, so a **PNG** collapses to a few KB where the uncompressed /// **BMP/DIB** stays ~180 KB at 256². PNG previews are only valid from R2013 /// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB. -pub fn from_scene(scene: &Scene, png: bool, viewport: (f32, f32)) -> Option { - let wires = scene.entity_wires(); - let camera = scene.camera.borrow(); - from_snapshot(&wires, &camera, scene.bg_color, png, viewport) -} - -/// Build a preview from immutable render inputs. Native background saves retain +/// +/// Native background saves retain /// the resident wire `Arc` and a small camera copy, then run the point scan and /// image encoding on the save worker instead of blocking the UI thread. pub fn from_snapshot( @@ -64,10 +59,17 @@ pub fn from_snapshot( width: cw as f32, height: ch as f32, }; - rasterize(wires, cw, ch, bg_color, png, |x, y, z| { - camera.project(glam::DVec3::new(x, y, z), bounds) - .map(|s| (s.x.round() as i32, s.y.round() as i32)) - }) + #[cfg(not(target_arch = "wasm32"))] + { + rasterize_snapshot_parallel(wires, camera, bounds, cw, ch, bg_color, png) + } + #[cfg(target_arch = "wasm32")] + { + rasterize(wires, cw, ch, bg_color, png, |x, y, z| { + camera.project(glam::DVec3::new(x, y, z), bounds) + .map(|s| (s.x.round() as i32, s.y.round() as i32)) + }) + } } /// Canvas dimensions for an aspect ratio, longest edge = [`MAX_DIM`]. @@ -79,6 +81,145 @@ fn canvas_dims(aspect: f64) -> (u32, u32) { } } +#[cfg(not(target_arch = "wasm32"))] +fn rasterize_snapshot_parallel( + wires: &[WireModel], + camera: &Camera, + bounds: Rectangle, + cw: u32, + ch: u32, + bg: [f32; 4], + png: bool, +) -> Option { + use crate::par::prelude::*; + + let total_segments: usize = wires + .iter() + .map(|wire| wire.points.len().saturating_sub(1)) + .sum(); + if total_segments < 100_000 { + return rasterize(wires, cw, ch, bg, png, |x, y, z| { + camera.project(glam::DVec3::new(x, y, z), bounds) + .map(|point| (point.x.round() as i32, point.y.round() as i32)) + }); + } + + let task_count = (rayon::current_num_threads().max(1) * 2).min(total_segments); + let target_work = total_segments.div_ceil(task_count).max(1); + let mut tasks: Vec> = Vec::with_capacity(task_count); + let mut task = Vec::new(); + let mut task_work = 0usize; + for (wire_index, wire) in wires.iter().enumerate() { + let segment_count = wire.points.len().saturating_sub(1); + let mut start = 0usize; + while start < segment_count { + let take = (target_work - task_work).min(segment_count - start); + task.push((wire_index, start, start + take)); + task_work += take; + start += take; + if task_work == target_work { + tasks.push(std::mem::take(&mut task)); + task_work = 0; + } + } + } + if !task.is_empty() { + tasks.push(task); + } + + let layers: Vec> = tasks + .par_iter() + .map(|task| { + let mut pixels = vec![u32::MAX; (cw * ch) as usize]; + for &(wire_index, start, end) in task { + let wire = &wires[wire_index]; + let color = to_rgb(wire.color); + let packed = + color[0] as u32 | (color[1] as u32) << 8 | (color[2] as u32) << 16; + let mut previous = projected_wire_point(wire, start, camera, bounds); + for index in start + 1..=end { + let current = projected_wire_point(wire, index, camera, bounds); + if let (Some(a), Some(b)) = (previous, current) { + draw_line_layer(&mut pixels, cw, ch, a, b, packed); + } + previous = current; + } + } + pixels + }) + .collect(); + + let mut image = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg))); + for layer in layers { + for (pixel, packed) in image.pixels_mut().zip(layer) { + if packed != u32::MAX { + *pixel = Rgb([ + packed as u8, + (packed >> 8) as u8, + (packed >> 16) as u8, + ]); + } + } + } + encode(image, png) +} + +#[cfg(not(target_arch = "wasm32"))] +fn projected_wire_point( + wire: &WireModel, + index: usize, + camera: &Camera, + bounds: Rectangle, +) -> Option<(i32, i32)> { + let point = wire.points.get(index)?; + if !point[0].is_finite() || !point[1].is_finite() { + return None; + } + let (x, y, z) = abs_xyz(wire, index, point); + camera + .project(glam::DVec3::new(x, y, z), bounds) + .map(|screen| (screen.x.round() as i32, screen.y.round() as i32)) +} + +#[cfg(not(target_arch = "wasm32"))] +fn draw_line_layer( + pixels: &mut [u32], + width: u32, + height: u32, + (x0, y0): (i32, i32), + (x1, y1): (i32, i32), + color: u32, +) { + let (width, height) = (width as i32, height as i32); + let Some(((mut x0, mut y0), (x1, y1))) = + clip_line_to_rect((x0, y0), (x1, y1), width, height) + else { + return; + }; + let dx = (x1 - x0).abs(); + let dy = -(y1 - y0).abs(); + let sx = if x0 < x1 { 1 } else { -1 }; + let sy = if y0 < y1 { 1 } else { -1 }; + let mut error = dx + dy; + loop { + if x0 >= 0 && x0 < width && y0 >= 0 && y0 < height { + pixels[y0 as usize * width as usize + x0 as usize] = color; + } + if x0 == x1 && y0 == y1 { + break; + } + let twice = 2 * error; + if twice >= dy { + error += dy; + x0 += sx; + } + if twice <= dx { + error += dx; + y0 += sy; + } + } +} + /// Rasterize `wires` onto a `bg`-filled `cw`×`ch` canvas, placing each vertex /// with `project` (world XYZ → canvas pixel, `None` = not projectable), and /// encode the result. A `None` from `project` breaks the polyline run, as does a @@ -349,7 +490,11 @@ mod tests { /// Bresenham line, clipped to the image bounds. fn draw_line(img: &mut RgbImage, (x0, y0): (i32, i32), (x1, y1): (i32, i32), col: Rgb) { let (w, h) = (img.width() as i32, img.height() as i32); - let (mut x0, mut y0) = (x0, y0); + let Some(((mut x0, mut y0), (x1, y1))) = + clip_line_to_rect((x0, y0), (x1, y1), w, h) + else { + return; + }; let dx = (x1 - x0).abs(); let dy = -(y1 - y0).abs(); let sx = if x0 < x1 { 1 } else { -1 }; @@ -373,3 +518,47 @@ fn draw_line(img: &mut RgbImage, (x0, y0): (i32, i32), (x1, y1): (i32, i32), col } } } + +fn clip_line_to_rect( + (x0, y0): (i32, i32), + (x1, y1): (i32, i32), + width: i32, + height: i32, +) -> Option<((i32, i32), (i32, i32))> { + if width <= 0 || height <= 0 { + return None; + } + let (x0, y0, x1, y1) = (x0 as f64, y0 as f64, x1 as f64, y1 as f64); + let (dx, dy) = (x1 - x0, y1 - y0); + let mut enter = 0.0f64; + let mut leave = 1.0f64; + for (p, q) in [ + (-dx, x0), + (dx, width.saturating_sub(1) as f64 - x0), + (-dy, y0), + (dy, height.saturating_sub(1) as f64 - y0), + ] { + if p == 0.0 { + if q < 0.0 { + return None; + } + continue; + } + let ratio = q / p; + if p < 0.0 { + enter = enter.max(ratio); + } else { + leave = leave.min(ratio); + } + if enter > leave { + return None; + } + } + let point = |t: f64| { + ( + (x0 + t * dx).round().clamp(0.0, width.saturating_sub(1) as f64) as i32, + (y0 + t * dy).round().clamp(0.0, height.saturating_sub(1) as f64) as i32, + ) + }; + Some((point(enter), point(leave))) +} diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index e60c33f9..89c739b9 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -278,7 +278,7 @@ struct SpatialGrid { } impl SpatialGrid { - fn build(entries: &[Entry3]) -> Self { + fn build(entries: &[Entry3]) -> Self { if entries.is_empty() { return Self { min: [0.0; 2], @@ -314,57 +314,137 @@ impl SpatialGrid { let cols = (((ext_x / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32; let rows = (((ext_y / cell).ceil() as u64) + 1).clamp(1, MAX_AXIS_CELLS as u64) as u32; let cell_count = cols as usize * rows as usize; - let mut counts = vec![0u32; cell_count]; - let mut oversized = Vec::new(); let col_of = |x: f64| (((x - min[0]) / cell).floor()).clamp(0.0, (cols - 1) as f64) as u32; let row_of = |y: f64| (((y - min[1]) / cell).floor()).clamp(0.0, (rows - 1) as f64) as u32; - for (idx, entry) in entries.iter().enumerate() { - let aabb = entry_aabb2(entry); - let c0 = col_of(aabb[0]); - let c1 = col_of(aabb[2]); - let r0 = row_of(aabb[1]); - let r1 = row_of(aabb[3]); - let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; - if span > MAX_SPAN_CELLS { - oversized.push(idx as u32); - continue; - } - for row in r0..=r1 { - let base = row as usize * cols as usize; - for col in c0..=c1 { - counts[base + col as usize] += 1; + #[cfg(not(target_arch = "wasm32"))] + let (counts, oversized) = { + use crate::par::prelude::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + let counts: Vec = + (0..cell_count).map(|_| AtomicU32::new(0)).collect(); + let oversized: Vec = entries + .par_iter() + .enumerate() + .filter_map(|(idx, entry)| { + let aabb = entry_aabb2(entry); + let c0 = col_of(aabb[0]); + let c1 = col_of(aabb[2]); + let r0 = row_of(aabb[1]); + let r1 = row_of(aabb[3]); + let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; + if span > MAX_SPAN_CELLS { + return Some(idx as u32); + } + for row in r0..=r1 { + let base = row as usize * cols as usize; + for col in c0..=c1 { + counts[base + col as usize].fetch_add(1, Ordering::Relaxed); + } + } + None + }) + .collect(); + ( + counts + .into_iter() + .map(AtomicU32::into_inner) + .collect::>(), + oversized, + ) + }; + #[cfg(target_arch = "wasm32")] + let (counts, oversized) = { + let mut counts = vec![0u32; cell_count]; + let mut oversized = Vec::new(); + for (idx, entry) in entries.iter().enumerate() { + let aabb = entry_aabb2(entry); + let c0 = col_of(aabb[0]); + let c1 = col_of(aabb[2]); + let r0 = row_of(aabb[1]); + let r1 = row_of(aabb[3]); + let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; + if span > MAX_SPAN_CELLS { + oversized.push(idx as u32); + continue; + } + for row in r0..=r1 { + let base = row as usize * cols as usize; + for col in c0..=c1 { + counts[base + col as usize] += 1; + } } } - } + (counts, oversized) + }; let mut cell_offsets = Vec::with_capacity(cell_count + 1); cell_offsets.push(0); for count in counts { cell_offsets.push(cell_offsets.last().copied().unwrap_or(0) + count); } - let mut cell_entries = vec![0u32; *cell_offsets.last().unwrap_or(&0) as usize]; - let mut cursors = cell_offsets[..cell_count].to_vec(); - for (idx, entry) in entries.iter().enumerate() { - let aabb = entry_aabb2(entry); - let c0 = col_of(aabb[0]); - let c1 = col_of(aabb[2]); - let r0 = row_of(aabb[1]); - let r1 = row_of(aabb[3]); - let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; - if span > MAX_SPAN_CELLS { - continue; - } - for row in r0..=r1 { - let base = row as usize * cols as usize; - for col in c0..=c1 { - let cell_idx = base + col as usize; - let cursor = &mut cursors[cell_idx]; - cell_entries[*cursor as usize] = idx as u32; - *cursor += 1; + + #[cfg(not(target_arch = "wasm32"))] + let cell_entries = { + use crate::par::prelude::*; + use std::sync::atomic::{AtomicU32, Ordering}; + + let cursors: Vec = cell_offsets[..cell_count] + .iter() + .copied() + .map(AtomicU32::new) + .collect(); + let slots: Vec = (0..*cell_offsets.last().unwrap_or(&0) as usize) + .map(|_| AtomicU32::new(0)) + .collect(); + entries.par_iter().enumerate().for_each(|(idx, entry)| { + let aabb = entry_aabb2(entry); + let c0 = col_of(aabb[0]); + let c1 = col_of(aabb[2]); + let r0 = row_of(aabb[1]); + let r1 = row_of(aabb[3]); + let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; + if span > MAX_SPAN_CELLS { + return; + } + for row in r0..=r1 { + let base = row as usize * cols as usize; + for col in c0..=c1 { + let cell_idx = base + col as usize; + let cursor = cursors[cell_idx].fetch_add(1, Ordering::Relaxed); + slots[cursor as usize].store(idx as u32, Ordering::Relaxed); + } + } + }); + slots.into_iter().map(AtomicU32::into_inner).collect() + }; + #[cfg(target_arch = "wasm32")] + let cell_entries = { + let mut cell_entries = vec![0u32; *cell_offsets.last().unwrap_or(&0) as usize]; + let mut cursors = cell_offsets[..cell_count].to_vec(); + for (idx, entry) in entries.iter().enumerate() { + let aabb = entry_aabb2(entry); + let c0 = col_of(aabb[0]); + let c1 = col_of(aabb[2]); + let r0 = row_of(aabb[1]); + let r1 = row_of(aabb[3]); + let span = (c1 - c0 + 1) as u64 * (r1 - r0 + 1) as u64; + if span > MAX_SPAN_CELLS { + continue; + } + for row in r0..=r1 { + let base = row as usize * cols as usize; + for col in c0..=c1 { + let cell_idx = base + col as usize; + let cursor = &mut cursors[cell_idx]; + cell_entries[*cursor as usize] = idx as u32; + *cursor += 1; + } } } - } + cell_entries + }; let oversized = SpatialBvh2::build(&entries, oversized); Self { @@ -426,7 +506,7 @@ struct SpatialSet { xyz: std::sync::OnceLock, } -impl SpatialSet { +impl SpatialSet { fn build(entries: Vec>) -> Self { let xy = SpatialGrid::build(&entries); Self { @@ -601,6 +681,19 @@ fn collect_wire_index_entries(wire_idx: u32, wire: &WireModel) -> WireIndexEntri entries } +fn flatten_entry_parts(mut parts: Vec>) -> Vec { + let Some((largest, _)) = parts.iter().enumerate().max_by_key(|(_, part)| part.len()) else { + return Vec::new(); + }; + let mut output = parts.swap_remove(largest); + let remaining: usize = parts.iter().map(Vec::len).sum(); + output.reserve(remaining); + for mut part in parts { + output.append(&mut part); + } + output +} + impl InteractionHandleIndex { pub fn build(entries: impl IntoIterator) -> Self { Self { @@ -649,6 +742,10 @@ impl InteractionIndex { } pub fn build(wires: &[WireModel]) -> Self { + #[cfg(not(target_arch = "wasm32"))] + let perf = std::env::var_os("OCS_PERF").is_some(); + #[cfg(not(target_arch = "wasm32"))] + let build_started = std::time::Instant::now(); let wire_handles: Vec> = wires .iter() .map(|wire| wire.name.parse::().ok()) @@ -665,14 +762,11 @@ impl InteractionIndex { Some(current) }) .collect(); + #[cfg(not(target_arch = "wasm32"))] + let handles_elapsed = build_started.elapsed(); + #[cfg(not(target_arch = "wasm32"))] + let collect_started = std::time::Instant::now(); let mut wire_entries = Vec::with_capacity(wires.len()); - let mut segment_entries = Vec::new(); - let mut snap_point_entries = Vec::new(); - let mut key_vertex_entries = Vec::new(); - let mut key_segment_entries = Vec::new(); - let mut fill_triangle_entries = Vec::new(); - let mut pick_triangle_entries = Vec::new(); - let mut glyph_entries = Vec::new(); let mut unbounded_wires = Vec::new(); let mut max_line_half_width_px = 0.0f32; @@ -691,46 +785,18 @@ impl InteractionIndex { .enumerate() .map(|(index, wire)| collect_wire_index_entries(index as u32, wire)) .collect(); - - // Every per-wire worker knows its exact output sizes. Reserve the flat - // arrays once before draining them so a dense block drawing does not - // repeatedly copy already-flattened entries while the Vecs grow. - wire_entries.reserve(per_wire.iter().filter(|entries| entries.wire.is_some()).count()); - unbounded_wires.reserve(per_wire.iter().filter(|entries| entries.unbounded).count()); - segment_entries.reserve(per_wire.iter().map(|entries| entries.segments.len()).sum()); - snap_point_entries.reserve( - per_wire - .iter() - .map(|entries| entries.snap_points.len()) - .sum(), - ); - key_vertex_entries.reserve( - per_wire - .iter() - .map(|entries| entries.key_vertices.len()) - .sum(), - ); - key_segment_entries.reserve( - per_wire - .iter() - .map(|entries| entries.key_segments.len()) - .sum(), - ); - fill_triangle_entries.reserve( - per_wire - .iter() - .map(|entries| entries.fill_triangles.len()) - .sum(), - ); - pick_triangle_entries.reserve( - per_wire - .iter() - .map(|entries| entries.pick_triangles.len()) - .sum(), - ); - glyph_entries.reserve(per_wire.iter().map(|entries| entries.glyphs.len()).sum()); - - for (wire_idx, mut entries) in per_wire.into_iter().enumerate() { + #[cfg(not(target_arch = "wasm32"))] + let collect_elapsed = collect_started.elapsed(); + #[cfg(not(target_arch = "wasm32"))] + let flatten_started = std::time::Instant::now(); + let mut segment_parts = Vec::with_capacity(per_wire.len()); + let mut snap_point_parts = Vec::with_capacity(per_wire.len()); + let mut key_vertex_parts = Vec::with_capacity(per_wire.len()); + let mut key_segment_parts = Vec::with_capacity(per_wire.len()); + let mut fill_triangle_parts = Vec::with_capacity(per_wire.len()); + let mut pick_triangle_parts = Vec::with_capacity(per_wire.len()); + let mut glyph_parts = Vec::with_capacity(per_wire.len()); + for (wire_idx, entries) in per_wire.into_iter().enumerate() { max_line_half_width_px = max_line_half_width_px.max(entries.max_line_half_width_px); if let Some(entry) = entries.wire { @@ -739,14 +805,65 @@ impl InteractionIndex { if entries.unbounded { unbounded_wires.push(wire_idx as u32); } - segment_entries.append(&mut entries.segments); - snap_point_entries.append(&mut entries.snap_points); - key_vertex_entries.append(&mut entries.key_vertices); - key_segment_entries.append(&mut entries.key_segments); - fill_triangle_entries.append(&mut entries.fill_triangles); - pick_triangle_entries.append(&mut entries.pick_triangles); - glyph_entries.append(&mut entries.glyphs); + segment_parts.push(entries.segments); + snap_point_parts.push(entries.snap_points); + key_vertex_parts.push(entries.key_vertices); + key_segment_parts.push(entries.key_segments); + fill_triangle_parts.push(entries.fill_triangles); + pick_triangle_parts.push(entries.pick_triangles); + glyph_parts.push(entries.glyphs); } + #[cfg(not(target_arch = "wasm32"))] + let ( + ((segment_entries, snap_point_entries), (key_vertex_entries, key_segment_entries)), + ((fill_triangle_entries, pick_triangle_entries), glyph_entries), + ) = rayon::join( + || { + rayon::join( + || { + rayon::join( + || flatten_entry_parts(segment_parts), + || flatten_entry_parts(snap_point_parts), + ) + }, + || { + rayon::join( + || flatten_entry_parts(key_vertex_parts), + || flatten_entry_parts(key_segment_parts), + ) + }, + ) + }, + || { + let (fill, pick) = rayon::join( + || flatten_entry_parts(fill_triangle_parts), + || flatten_entry_parts(pick_triangle_parts), + ); + ((fill, pick), flatten_entry_parts(glyph_parts)) + }, + ); + #[cfg(target_arch = "wasm32")] + let ( + segment_entries, + snap_point_entries, + key_vertex_entries, + key_segment_entries, + fill_triangle_entries, + pick_triangle_entries, + glyph_entries, + ) = ( + flatten_entry_parts(segment_parts), + flatten_entry_parts(snap_point_parts), + flatten_entry_parts(key_vertex_parts), + flatten_entry_parts(key_segment_parts), + flatten_entry_parts(fill_triangle_parts), + flatten_entry_parts(pick_triangle_parts), + flatten_entry_parts(glyph_parts), + ); + #[cfg(not(target_arch = "wasm32"))] + let flatten_elapsed = flatten_started.elapsed(); + #[cfg(not(target_arch = "wasm32"))] + let spatial_started = std::time::Instant::now(); #[cfg(not(target_arch = "wasm32"))] let ( @@ -806,6 +923,17 @@ impl InteractionIndex { SpatialSet::build(pick_triangle_entries), SpatialSet::build(glyph_entries), ); + #[cfg(not(target_arch = "wasm32"))] + if perf { + eprintln!( + "[perf] interaction-index-detail total={:.1}ms handles={:.1} collect={:.1} flatten={:.1} spatial={:.1}", + build_started.elapsed().as_secs_f64() * 1000.0, + handles_elapsed.as_secs_f64() * 1000.0, + collect_elapsed.as_secs_f64() * 1000.0, + flatten_elapsed.as_secs_f64() * 1000.0, + spatial_started.elapsed().as_secs_f64() * 1000.0, + ); + } Self { wires, diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index bd8f44e8..0660f39c 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -163,6 +163,14 @@ pub struct Pipeline { /// both patch incrementally. Shares `wire_arena_id`. #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_mesh: Option, + /// Chunked resident buffers for whichever arena partition exceeded one + /// GPU buffer. `Some(false)` = regular wires, `Some(true)` = mesh edges. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) wire_arena_fallback: std::sync::Arc>, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) wire_arena_fallback_kind: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet, /// The Model content id both arenas currently mirror (`u64::MAX` = none). #[cfg(not(target_arch = "wasm32"))] pub(crate) wire_arena_id: u64, @@ -1455,6 +1463,12 @@ impl Pipeline { #[cfg(not(target_arch = "wasm32"))] wire_arena_mesh: None, #[cfg(not(target_arch = "wasm32"))] + wire_arena_fallback: std::sync::Arc::new(Vec::new()), + #[cfg(not(target_arch = "wasm32"))] + wire_arena_fallback_kind: None, + #[cfg(not(target_arch = "wasm32"))] + wire_arena_fallback_handles: rustc_hash::FxHashSet::default(), + #[cfg(not(target_arch = "wasm32"))] wire_arena_id: u64::MAX, #[cfg(not(target_arch = "wasm32"))] wire_cull_key: (u64::MAX, u64::MAX, 0, 0), diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index 9f17f251..d571e880 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -282,6 +282,7 @@ impl WirePipelineMode { // ── GPU handle ──────────────────────────────────────────────────────────── +#[derive(Clone)] pub struct WireGpu { pub instance_buffer: wgpu::Buffer, /// First instance in a shared arena buffer. Standalone buffers start at 0. @@ -609,6 +610,55 @@ fn build_const_bind_group( } impl WireGpu { + /// Native-only 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. + #[cfg(not(target_arch = "wasm32"))] + pub fn from_run_refs( + device: &wgpu::Device, + wires: &[&WireModel], + depth_map: &rustc_hash::FxHashMap, + mesh_edge: bool, + const_bgl: &wgpu::BindGroupLayout, + ) -> Vec { + const MAX_INSTANCES: usize = + 268_435_456 / std::mem::size_of::(); + use crate::par::prelude::*; + let per: Vec<(Vec, WireConst)> = wires + .par_iter() + .enumerate() + .map(|(idx, &wire)| { + let depth = if mesh_edge { + 0.0 + } else { + wire_draw_depth(wire, depth_map) + }; + emit_wire_native(wire, idx as u32, wire.color, depth) + }) + .collect(); + let mut instances: Vec = + Vec::with_capacity(per.iter().map(|(items, _)| items.len()).sum()); + let mut consts = Vec::with_capacity(per.len()); + for (mut items, constant) in per { + instances.append(&mut items); + consts.push(constant); + } + if instances.is_empty() { + return Vec::new(); + } + let bind_group = build_const_bind_group(device, const_bgl, &consts); + instances + .chunks(MAX_INSTANCES) + .map(|chunk| Self { + instance_buffer: instance_buffer_mapped(device, "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() + } /// 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 diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 3abf5215..f3961ebd 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -499,28 +499,48 @@ impl shader::Primitive for Primitive { ); } } + let fallback_touched = |mesh_edge: bool| { + patch.map_or(true, |patch| { + patch.changes.iter().any(|(handle, _)| { + inner.wire_arena_fallback_handles.contains(handle) + || if mesh_edge { + mesh_changed.get(handle).is_some_and(|run| !run.is_empty()) + } else { + regular_changed + .get(handle) + .is_some_and(|run| !run.is_empty()) + } + }) + }) + }; let reg_ok = base_ok - && inner.wire_arena.as_mut().map_or(false, |a| { + && if let Some(arena) = inner.wire_arena.as_mut() { let patch = patch.unwrap(); - a.patch( + arena.patch( queue, &patch.changes, ®ular_changed, patch.new_handles_are_suffix, &vp.draw_depths, ) - }); + } else { + inner.wire_arena_fallback_kind == Some(false) + && !fallback_touched(false) + }; let mesh_ok = base_ok - && inner.wire_arena_mesh.as_mut().map_or(false, |a| { + && if let Some(arena) = inner.wire_arena_mesh.as_mut() { let patch = patch.unwrap(); - a.patch( + arena.patch( queue, &patch.changes, &mesh_changed, patch.new_handles_are_suffix, &vp.draw_depths, ) - }); + } else { + inner.wire_arena_fallback_kind == Some(true) + && !fallback_touched(true) + }; if !reg_ok || !mesh_ok { // Initial upload or a patch that outgrew arena capacity: // only then pay the full regular/mesh split. @@ -529,14 +549,18 @@ impl shader::Primitive for Primitive { .filter(|w| !w.fill_tris.is_empty() && !w.fill_tris_low.is_empty()) .filter_map(|w| w.name.parse::().ok()) .collect(); + let regular: Vec<&crate::scene::WireModel> = vp_wires + .iter() + .filter(|w| { + !w.points.is_empty() + && !wire_arena::is_mesh_edge(w, &mesh_names) + }) + .collect(); + let mesh: Vec<&crate::scene::WireModel> = vp_wires + .iter() + .filter(|w| wire_arena::is_mesh_edge(w, &mesh_names)) + .collect(); if !reg_ok { - let regular: Vec<&crate::scene::WireModel> = vp_wires - .iter() - .filter(|w| { - !w.points.is_empty() - && !wire_arena::is_mesh_edge(w, &mesh_names) - }) - .collect(); inner.wire_arena = WireArena::build( device, queue, @@ -545,12 +569,34 @@ impl shader::Primitive for Primitive { bgl, false, ); + if inner.wire_arena.is_none() && !regular.is_empty() { + inner.wire_arena_fallback = std::sync::Arc::new( + crate::scene::pipeline::WireGpu::from_run_refs( + device, + ®ular, + &vp.draw_depths, + false, + bgl, + ), + ); + inner.wire_arena_fallback_kind = Some(false); + inner.wire_arena_fallback_handles = regular + .iter() + .filter_map(|wire| { + wire.name + .parse::() + .ok() + .map(acadrust::Handle::new) + }) + .collect(); + } else if inner.wire_arena_fallback_kind == Some(false) { + inner.wire_arena_fallback = + std::sync::Arc::new(Vec::new()); + inner.wire_arena_fallback_kind = None; + inner.wire_arena_fallback_handles.clear(); + } } if !mesh_ok { - let mesh: Vec<&crate::scene::WireModel> = vp_wires - .iter() - .filter(|w| wire_arena::is_mesh_edge(w, &mesh_names)) - .collect(); inner.wire_arena_mesh = WireArena::build( device, queue, @@ -559,15 +605,58 @@ impl shader::Primitive for Primitive { bgl, true, ); + if inner.wire_arena_mesh.is_none() && !mesh.is_empty() { + inner.wire_arena_fallback = std::sync::Arc::new( + crate::scene::pipeline::WireGpu::from_run_refs( + device, + &mesh, + &vp.draw_depths, + true, + bgl, + ), + ); + inner.wire_arena_fallback_kind = Some(true); + inner.wire_arena_fallback_handles = mesh + .iter() + .filter_map(|wire| { + wire.name + .parse::() + .ok() + .map(acadrust::Handle::new) + }) + .collect(); + } else if inner.wire_arena_fallback_kind == Some(true) { + inner.wire_arena_fallback = + std::sync::Arc::new(Vec::new()); + inner.wire_arena_fallback_kind = None; + inner.wire_arena_fallback_handles.clear(); + } } } _patched = reg_ok && mesh_ok; - if let (Some(reg), Some(me)) = - (inner.wire_arena.as_ref(), inner.wire_arena_mesh.as_ref()) + let regular_ready = inner.wire_arena.is_some() + || inner.wire_arena_fallback_kind == Some(false); + let mesh_ready = inner.wire_arena_mesh.is_some() + || inner.wire_arena_fallback_kind == Some(true); + if regular_ready + && mesh_ready + && (inner.wire_arena.is_some() || inner.wire_arena_mesh.is_some()) { - let mut gpus = reg.wire_gpus(); - gpus.extend(me.wire_gpus()); + let mut gpus = if inner.wire_arena_fallback_kind == Some(false) { + inner.wire_arena_fallback.as_ref().clone() + } else { + inner + .wire_arena + .as_ref() + .map(WireArena::wire_gpus) + .unwrap_or_default() + }; + if inner.wire_arena_fallback_kind == Some(true) { + gpus.extend(inner.wire_arena_fallback.iter().cloned()); + } else if let Some(arena) = inner.wire_arena_mesh.as_ref() { + gpus.extend(arena.wire_gpus()); + } inner.gpu_wires = std::sync::Arc::new(gpus); if _patched { wire_arena::patch_handle_index( @@ -583,6 +672,9 @@ impl shader::Primitive for Primitive { } else { inner.wire_arena = None; inner.wire_arena_mesh = None; + inner.wire_arena_fallback = std::sync::Arc::new(Vec::new()); + inner.wire_arena_fallback_kind = None; + inner.wire_arena_fallback_handles.clear(); inner.wire_arena_id = u64::MAX; } } @@ -594,28 +686,31 @@ impl shader::Primitive for Primitive { // `.cloned()` releases the immutable cache borrow before the // miss branch takes a mutable one. if !arena_served { - let cached = pipeline.wire_buffer_cache.get(&vp.wire_content_id).cloned(); - let built = match cached { - Some(entry) => entry, - None => { - let entry = - inner.build_wire_buffers(device, &vp_wires[..], &vp.draw_depths); - pipeline - .wire_buffer_cache - .insert(vp.wire_content_id, entry.clone()); - // Evict entries no slot still holds (only the cache - // references them). An entry drawn by any pane keeps a - // strong count ≥ 2, so this never drops live geometry. - if pipeline.wire_buffer_cache.len() > 16 { + let cached = pipeline + .wire_buffer_cache + .get(&vp.wire_content_id) + .cloned(); + let built = match cached { + Some(entry) => entry, + None => { + let entry = + inner.build_wire_buffers(device, &vp_wires[..], &vp.draw_depths); pipeline .wire_buffer_cache - .retain(|_, (w, _)| std::sync::Arc::strong_count(w) > 1); + .insert(vp.wire_content_id, entry.clone()); + // Evict entries no slot still holds (only the cache + // references them). An entry drawn by any pane keeps a + // strong count ≥ 2, so this never drops live geometry. + if pipeline.wire_buffer_cache.len() > 16 { + pipeline + .wire_buffer_cache + .retain(|_, (w, _)| std::sync::Arc::strong_count(w) > 1); + } + entry } - entry - } - }; - inner.gpu_wires = built.0; - inner.wire_handle_index = built.1; + }; + inner.gpu_wires = built.0; + inner.wire_handle_index = built.1; } // end !arena_served inner.cached_wire_id = vp.wire_content_id; #[cfg(not(target_arch = "wasm32"))] @@ -625,6 +720,8 @@ impl shader::Primitive for Primitive { "shared-fullupload" } else if _patched { "arena-patch" + } else if inner.wire_arena_fallback_kind.is_some() { + "arena-hybrid" } else { "arena-build" }; @@ -724,19 +821,25 @@ impl shader::Primitive for Primitive { if inner.wire_arena_id == vp.wire_content_id && inner.wire_cull_key != cull_key { - let mut visible = inner - .wire_arena - .as_ref() - .map(|arena| { - arena.wire_gpus_visible( - view_rot, - eye, - clip_size.width, - clip_size.height, - ) - }) - .unwrap_or_default(); - if let Some(arena) = inner.wire_arena_mesh.as_ref() { + let mut visible = if inner.wire_arena_fallback_kind == Some(false) { + inner.wire_arena_fallback.as_ref().clone() + } else { + inner + .wire_arena + .as_ref() + .map(|arena| { + arena.wire_gpus_visible( + view_rot, + eye, + clip_size.width, + clip_size.height, + ) + }) + .unwrap_or_default() + }; + if inner.wire_arena_fallback_kind == Some(true) { + visible.extend(inner.wire_arena_fallback.iter().cloned()); + } else if let Some(arena) = inner.wire_arena_mesh.as_ref() { visible.extend(arena.wire_gpus_visible( view_rot, eye,