graphic design tool
git clone https://git.lucas.co/cce-designer.git
perf(wire): share a mesh's topology with its copies, and write the wire vertices in parallel
Every copy of a Detail dropped its topology, so every frame's scene, a
copy of the cached simulation state, built one again for the wireframe.
Copies share it now (Arc); every structural writer already drops it, and
moving points or writing attributes leaves it true. A merge into an empty
detail keeps it, and the solve builds it once for each frame it stores
(counted in the checkpoint budget). scene_edge_verts writes its vertices
a piece of edges a thread. The wire edges at 57k points: 4.9 ms to 0.6.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 19 ++++++++++++++++-
src/detail.rs | 45 ++++++++++++++++++++++++++++++++++------
src/geometry.rs | 11 +++++++++-
src/main.rs | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/render.rs | 36 ++++++++++++++++++++++++--------
5 files changed, 159 insertions(+), 16 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index f95cc3f..5b40e6b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2250,7 +2250,10 @@ need.
history to every fourth frame while holding about 0.4 GB, and a replay
of the cached frames 120–240 re-solved three in four at 690 ms a frame;
counted by size, all 240 are kept at 1.5 GB (1.37 GB resident in a
- shadow session) and the replay runs no step at 34 ms a frame.
+ shadow session) and the replay runs no step at 34 ms a frame. Each
+ checkpoint carries its frame's topology since later the same day (see
+ "A copy of a mesh shares its topology"), which brings the 240 to 1.9 GB:
+ a simulation larger or longer than that one begins to thin.
`checkpoints_are_budgeted_by_what_each_holds`. With no
room the SPACING doubles and stays doubled — what is off the wider
interval goes, and what arrives after arrives that far apart. Not the
@@ -3494,6 +3497,20 @@ markers cost nothing on the GPU, being instanced; frames are paced to
the display, so a frame of 8 ms CPU shows on the next 16.7 ms, and one
over shows on the one after.
+**A copy of a mesh shares its topology** (the same day, `Clone for
+Detail`, an `Arc`): it was dropped by every clone, so every frame's scene
+— a copy of the cached simulation state, merged into an empty detail —
+built a topology again for the wire pass. Every structural writer of a
+`Detail` drops it (`invalidate`), and moving points or writing attributes
+leaves a topology that is still true, so a shared one is never stale.
+`merge` into an empty detail keeps the merged one's, the solve builds the
+topology of each frame it stores (about 1% of a step; counted in the
+checkpoints' budget, `Detail::topology_bytes`), and so a replayed scene's
+edges cost nothing; `scene_edge_verts` writes the wire vertices a piece
+of edges a thread into one buffer. The wire edges at 57k points: 4.9 ms
+a frame to 0.6. `a_copy_shares_the_topology_until_it_is_edited`,
+`the_wire_vertices_are_the_edges_in_order`.
+
**The markers are instanced** (the same day, cce-ui's
`SceneDraw::instances`): every kind — Show Point Markers, Show Vertex
Markers, the selected group's, the marked groups' and the spreadsheet
diff --git a/src/detail.rs b/src/detail.rs
index 6322116..8d3cba1 100644
--- a/src/detail.rs
+++ b/src/detail.rs
@@ -1053,7 +1053,8 @@ pub struct Detail {
prim_start: Vec<u32>,
prims: AttribStore,
detail: AttribStore,
- topo: OnceLock<Topology>,
+ /// Shared by a clone (`Arc`): see [`Clone for Detail`](#impl-Clone-for-Detail).
+ topo: OnceLock<std::sync::Arc<Topology>>,
}
impl Default for Detail {
@@ -1081,9 +1082,16 @@ impl PartialEq for Detail {
}
impl Clone for Detail {
- /// The topology cache is deliberately *not* cloned. It is derived, the
- /// clone exists to be modified, and rebuilding is cheaper than reasoning
- /// about whether a stale cache came along.
+ /// The topology, when built, is SHARED with the clone (since
+ /// 2026-10-07; until then it was deliberately dropped). It is derived
+ /// from the primitives and the point count alone, every edit of either
+ /// drops it (`invalidate`, called by every structural writer: `add_point`,
+ /// `add_points`, `add_prim`, the gathers, `fuse_points`, `merge`), and
+ /// moving points or writing attributes does not touch it — so a clone
+ /// edited structurally builds its own, and one that is not keeps a
+ /// topology that is still true. Dropping it cost every copy of a cached
+ /// simulation state its whole topology again, a frame at a time:
+ /// 6 ms at 57k points for the wireframe's edges alone.
fn clone(&self) -> Self {
Self {
pos: self.pos.clone(),
@@ -1095,7 +1103,10 @@ impl Clone for Detail {
prim_start: self.prim_start.clone(),
prims: self.prims.clone(),
detail: self.detail.clone(),
- topo: OnceLock::new(),
+ topo: match self.topo.get() {
+ Some(t) => OnceLock::from(t.clone()),
+ None => OnceLock::new(),
+ },
}
}
}
@@ -1331,7 +1342,16 @@ impl Detail {
/// drops it.
pub fn topology(&self) -> &Topology {
self.topo
- .get_or_init(|| Topology::build(self.num_points(), &self.vert_point, &self.prim_start))
+ .get_or_init(|| std::sync::Arc::new(Topology::build(self.num_points(), &self.vert_point, &self.prim_start)))
+ }
+
+ /// About what the topology holds when it is built, in bytes; 0 when it
+ /// is not. For the simulation checkpoints' budget.
+ pub fn topology_bytes(&self) -> usize {
+ self.topo.get().map_or(0, |t| {
+ 4 * (t.point_prim_start.len() + t.point_prim.len() + t.point_nbr_start.len() + t.point_nbr.len())
+ + 8 * t.edges.len()
+ })
}
/// The points sharing an edge with point `p`.
@@ -1377,6 +1397,11 @@ impl Detail {
/// Drop the derived topology. Called by every structural edit; public
/// because an operator writing `vert_point` through a future bulk path
/// must be able to say so.
+ /// Whether the topology is built (and so free to ask for).
+ pub fn has_topology(&self) -> bool {
+ self.topo.get().is_some()
+ }
+
pub fn invalidate(&mut self) {
self.topo.take();
}
@@ -1513,6 +1538,11 @@ impl Detail {
/// of geometry that were generated independently — and therefore both
/// number their points from zero — do not collide.
pub fn merge(&mut self, other: &Detail) {
+ // Merged into nothing, the result has `other`'s structure exactly —
+ // the same points and primitives, only the identities renumbered —
+ // so its topology is `other`'s, built or not. The scene is
+ // assembled this way, one displayed node into an empty detail.
+ let shared = if self.num_points() == 0 && self.num_prims() == 0 { other.topo.get().cloned() } else { None };
let point_offset = self.num_points() as u32;
let vert_offset = self.vert_point.len() as u32;
@@ -1533,6 +1563,9 @@ impl Detail {
self.prims.append(&other.prims);
self.invalidate();
+ if let Some(t) = shared {
+ let _ = self.topo.set(t);
+ }
}
// ---- convenience ----
diff --git a/src/geometry.rs b/src/geometry.rs
index fd0bdcf..b534486 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1177,7 +1177,7 @@ pub const CHECKPOINT_BUDGET: usize = 2048 * 1024 * 1024;
/// of attributes a point, the primitives' indices, twice. An estimate — the
/// budget is a guard against a runaway, not an accounting.
fn checkpoint_bytes(state: &Detail) -> usize {
- 2 * (state.num_points() * 96 + state.num_verts() * 8 + state.num_prims() * 8 + 256)
+ 2 * (state.num_points() * 96 + state.num_verts() * 8 + state.num_prims() * 8 + 256) + state.topology_bytes()
}
/// One solve's checkpoints, in frame order, how far apart they are being
@@ -7603,6 +7603,7 @@ pub fn resolve_simnet_geometry_with_errors(
// A frame on the interval is kept as the solve passes it — not the
// frame asked for, which is the entry itself.
if done % checkpoints.every == 0 && done < due {
+ state.topology();
checkpoints.keep(Checkpoint { frame: done, state: state.clone(), prev: prev_frame.clone() });
}
}
@@ -7617,6 +7618,14 @@ pub fn resolve_simnet_geometry_with_errors(
if caching && due > 0 && done > resumed_at {
write_sim_cache(&target.id, key, due, &state, &prev_frame);
}
+ // A frame solved has its topology built here, once, and every copy of
+ // the state taken from the cache from now on shares it (`Clone for
+ // Detail`): the wireframe's edges and anything else that asks of the
+ // scene are free on a replay, where they were built again every frame.
+ // It costs about 1% of a step and is counted in the checkpoints' budget.
+ if done > resumed_at {
+ state.topology();
+ }
sim.cache.entries.insert(
target.id.clone(),
SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints, edited_at, start: start_frame, chain },
diff --git a/src/main.rs b/src/main.rs
index 737e80c..83a4461 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -17967,6 +17967,70 @@ mod tests {
}
}
+ /// The wire pass's vertices are written a piece of edges a thread, into
+ /// one buffer: what one thread writes, vertex for vertex, on a mesh too
+ /// small to share out and on one large enough to.
+ #[test]
+ fn the_wire_vertices_are_the_edges_in_order() {
+ for (lat, lon) in [(6, 9), (120, 240)] {
+ let mut g = crate::geometry::sphere_detail(Vec3::new(0.2, -0.1, 0.3), 1.0, lat, lon);
+ let n = g.num_points();
+ g.points_mut().insert(crate::detail::CD, crate::detail::AttribData::Float3((0..n).map(|p| [p as f32 / n as f32, 0.5, 0.1]).collect())).unwrap();
+ let want: Vec<[f32; 6]> = g
+ .edges()
+ .iter()
+ .flat_map(|e| e.iter().map(|&p| { let (a, c) = (g.pos(p as usize).to_array(), g.color(p as usize)); [a[0], a[1], a[2], c[0], c[1], c[2]] }).collect::<Vec<_>>())
+ .collect();
+ let got: Vec<[f32; 6]> = crate::render::scene_edge_verts(&g)
+ .iter()
+ .map(|v| [v.position[0], v.position[1], v.position[2], v.color[0], v.color[1], v.color[2]])
+ .collect();
+ assert_eq!(got, want, "{} edges", g.edges().len());
+ }
+ }
+
+ /// A copy of a Detail shares its topology, built once; an edit that
+ /// moves points or writes attributes keeps it, a structural edit drops
+ /// it; a Detail merged into an empty one keeps it; and a simulation's
+ /// solved frame comes out of the cache with it built, so the scene's
+ /// edges cost nothing on a replay.
+ #[test]
+ fn a_copy_shares_the_topology_until_it_is_edited() {
+ let mut g = crate::geometry::sphere_detail(Vec3::ZERO, 1.0, 5, 8);
+ assert!(!g.has_topology());
+ let edges = g.edges().to_vec();
+ let mut copy = g.clone();
+ assert!(copy.has_topology(), "shared by the copy");
+ copy.positions_mut()[0][0] += 1.0;
+ copy.points_mut().create("mass", crate::detail::AttribValue::Float(2.0));
+ assert!(copy.has_topology(), "moving points and writing attributes keep it");
+ assert_eq!(copy.edges(), edges.as_slice());
+ let mut merged = crate::detail::Detail::new();
+ merged.merge(&g);
+ assert!(merged.has_topology(), "merged into nothing keeps it");
+ assert_eq!(merged.edges(), edges.as_slice());
+ merged.merge(&g);
+ assert!(!merged.has_topology(), "merged into something does not");
+ copy.add_prim(&[0, 1, 2]);
+ assert!(!copy.has_topology(), "a structural edit drops it");
+ assert_ne!(copy.edges().len(), 0);
+ assert!(g.has_topology(), "and the original's is its own");
+
+ // A simnet's solved frame, taken back out of the cache, has it.
+ let mut state = State::new(false);
+ let mut redraw = false;
+ state.apply_action(McpAction::AddNode { template_name: "Simnet".into(), name: Some("sim".into()), x: 6.0, y: 8.0 }, &mut redraw).unwrap();
+ let slot = state.current_dir().children.iter().position(|c| c.name == "sim").unwrap();
+ state.apply_action(McpAction::SetParam { slot, name: "input".into(), value: "sphere1".into() }, &mut redraw).unwrap();
+ let simnet = state.current_dir().children[slot].clone();
+ let mut cache = crate::geometry::SimCache::default();
+ for frame in [4, 4, 2] {
+ let mut sim = crate::geometry::EvalSim::new(frame, 1, &mut cache);
+ let solved = crate::geometry::resolve_simnet_geometry_with_errors(&state.fs_root, &simnet, &mut Vec::new(), &mut None, &mut sim).unwrap();
+ assert!(solved.has_topology(), "frame {frame} comes with its topology");
+ }
+ }
+
/// The playbar's cache strip, as a rule: a frame is cached when every
/// simnet in the tree holds it, stale when one of them holds it from
/// the chain as it was — before an edit the solve went on across, or
diff --git a/src/render.rs b/src/render.rs
index 96620f0..28ff4ff 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1856,17 +1856,37 @@ pub(crate) fn scene_element_overlays(
/// colour up by name at both ends of every edge — the largest part of a
/// replayed frame at 57k points.
pub(crate) fn scene_edge_verts(geom: &crate::detail::Detail) -> Vec<crate::geometry::Vertex3D> {
+ use crate::geometry::Vertex3D;
let edges = geom.edge_list();
let colors = geom.point_colors();
- let mut wires = Vec::with_capacity(edges.len() * 2);
- for e in edges.iter() {
- for &p in e {
- let p = p as usize;
- wires.push(crate::geometry::Vertex3D {
- position: geom.positions()[p],
- color: colors.get(p).copied().unwrap_or(crate::detail::DEFAULT_COLOR),
- });
+ let positions = geom.positions();
+ let vertex = |p: u32| {
+ let p = p as usize;
+ Vertex3D { position: positions[p], color: colors.get(p).copied().unwrap_or(crate::detail::DEFAULT_COLOR) }
+ };
+ // Written into one buffer a piece of edges a thread, each piece its own
+ // stretch of it, so the result is the one a single thread writes. A
+ // thread costs tens of microseconds to start, so a piece is at least
+ // `PIECE` edges and a small mesh stays on one.
+ const PIECE: usize = 16_384;
+ let mut wires = vec![Vertex3D { position: [0.0; 3], color: [0.0; 3] }; edges.len() * 2];
+ let threads = std::thread::available_parallelism().map_or(1, |n| n.get()).min(edges.len() / PIECE).max(1);
+ let per = edges.len().div_ceil(threads).max(1);
+ let fill = |out: &mut [Vertex3D], edges: &[[u32; 2]]| {
+ for (pair, e) in out.chunks_exact_mut(2).zip(edges) {
+ pair[0] = vertex(e[0]);
+ pair[1] = vertex(e[1]);
}
+ };
+ if threads == 1 {
+ fill(&mut wires, &edges);
+ } else {
+ std::thread::scope(|scope| {
+ for (out, edges) in wires.chunks_mut(per * 2).zip(edges.chunks(per)) {
+ let fill = &fill;
+ scope.spawn(move || fill(out, edges));
+ }
+ });
}
wires
}