git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commite129370a75ada804c1e9b22cb89ff6dc95d6d8ff
parent53eb8146c0
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-06 21:35
perf(sim): play back solved frames without a step, and refill the table and markers faster

A checkpoint is kept at every frame (within 2 GB a simnet, the spacing
doubling past it), so playing back through what has been solved runs no
step: on the project measured, a replayed frame's evaluation went from
100 ms to 1 at 8k points. The spreadsheet looks each column up once and
formats floats through fmt4 (format!("{:.4}") to the character), and
the point markers move one precomputed sphere to each point. A replayed
frame there: 140 ms to 24.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md       |  24 ++++++++++++--
 src/app.rs      | 100 ++++++++++++++++++++++++++++++--------------------------
 src/geometry.rs |  98 ++++++++++++++++++++++++++++--------------------------
 src/main.rs     |  25 ++++++++++++++
 4 files changed, 151 insertions(+), 96 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4689705..9bc522a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -2223,7 +2223,13 @@ now keeps CHECKPOINTS in memory (`geometry::Checkpoint`, on the
 substep consumed, which is everything a resume and the interior view
 need.
 
-- **One every `CHECKPOINT_EVERY` (10) frames**, kept as the solve passes
+- **One every `CHECKPOINT_EVERY` frames — every frame since
+  2026-10-06**, so a frame once solved plays back with no step at all.
+  It was ten until then, and playing back through what had been solved
+  (the playbar's cyan) stepped nine frames in ten again, a whole step
+  each, growing with the mesh: on the user's project at 8k points the
+  evaluation of a replayed frame was 100 ms and is 1 ms. Kept as the
+  solve passes
   it — and as it LEAVES it, which is the case that is easy to miss:
   played a frame at a time the solve is always asked for the very next
   frame, so it never passes a frame on the interval, it arrives on one
@@ -2236,8 +2242,9 @@ need.
 - **They belong to one key.** An edit to the chain or the seed changes
   the key and they go with the solve they were frames of; the frame in
   hand does not (the next section).
-- **Within a count and a budget** (`CHECKPOINTS_MAX` 48,
-  `CHECKPOINT_BUDGET` 512 MB by an estimate of a state's size). With no
+- **Within a count and a budget** (`CHECKPOINTS_MAX` 1024,
+  `CHECKPOINT_BUDGET` 2 GB per simnet by an estimate of a state's size;
+  48 and 512 MB until every frame was kept). 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
   oldest: a scrub is as likely to land near the start. And not every
@@ -3372,6 +3379,17 @@ end and the start half a frame each, and with the cap would have stepped
 over the start. With Repeat off, landing on the far end stops there.
 `playback_plays_every_frame_however_late_the_tick` is the test.
 
+What a replayed frame costs past the solve (measured 2026-10-06 on the
+user's project, 8k points, markers, two visualizers and the spreadsheet
+on): the spreadsheet's refill and the point markers were most of it, and
+both got cheaper without changing what they show — the table looks each
+column up once and formats through `app::fmt4` (`{:.4}` to the
+character, `fmt4_is_format_4`), and `points_vertices` works the marker
+sphere out once and moves it to each point. A replayed frame went from
+140 ms to 24 there. What is left grows with the mesh: the markers'
+vertices (240 a point, uploaded every frame — instancing in cce-ui's
+renderer is the fix), the visualizers' copy of the scene, and the table.
+
 ### The playbar shows what is cached, and what is stale (since 2026-10-06)
 
 A strip along the foot of the playbar's track says what the simulations
diff --git a/src/app.rs b/src/app.rs
index 585c475..f1592ff 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1256,6 +1256,38 @@ pub fn strip_meta_children(root: &mut FsNode) {
 /// lowercased, as Houdini names its nodes (`sphere1`, `camera1`), since a
 /// path convention with exceptions is two conventions. Empty comes back as
 /// `node`, since a node with no name has no path at all.
+/// One attribute value as spreadsheet cells: a cell a component, `-` for
+/// each when there is none.
+fn push_cells(row: &mut Vec<String>, value: Option<crate::detail::AttribValue>, components: usize) {
+    match value {
+        Some(crate::detail::AttribValue::Float(f)) => row.push(fmt4(f)),
+        Some(crate::detail::AttribValue::Int(i)) => row.push(i.to_string()),
+        Some(crate::detail::AttribValue::Float2(a)) => row.extend(a.iter().map(|v| fmt4(*v))),
+        Some(crate::detail::AttribValue::Float3(a)) => row.extend(a.iter().map(|v| fmt4(*v))),
+        Some(crate::detail::AttribValue::Float4(a)) => row.extend(a.iter().map(|v| fmt4(*v))),
+        None => row.extend(std::iter::repeat("-".to_string()).take(components)),
+    }
+}
+
+/// `format!("{:.4}", x)`, character for character, several times faster:
+/// an f32 times ten thousand is exact in an f64 (24 bits of mantissa and
+/// 14), so rounding it half to even is rounding the exact decimal value,
+/// which is what the formatter does. What does not fit an integer goes to
+/// the formatter. `fmt4_is_format_4` holds the two equal.
+pub(crate) fn fmt4(x: f32) -> String {
+    if !x.is_finite() || x.abs() >= 1.0e14 {
+        return format!("{:.4}", x);
+    }
+    use std::fmt::Write;
+    let y = (x.abs() as f64 * 10000.0).round_ties_even() as u64;
+    let mut s = String::with_capacity(12);
+    if x.is_sign_negative() {
+        s.push('-');
+    }
+    let _ = write!(s, "{}.{:04}", y / 10000, y % 10000);
+    s
+}
+
 /// The playbar's cache strip, frame by frame over `start..=end` and run
 /// together: a frame is CACHED when every simnet in the tree has it in hand
 /// (at or before its start it shows its seed, which every simnet has), STALE
@@ -7740,57 +7772,33 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Detail) -> (Vec<String>, Vec<V
         }
     }
 
-    let mut rows = Vec::new();
+    // Each column's store looked up once, not once a row: a playing
+    // simulation refills the table every frame, and at ten thousand points
+    // the lookups and `format!` were most of a frame (see `fmt4`).
+    let point_cols: Vec<(Option<&crate::detail::AttribData>, usize)> =
+        attribs.iter().map(|(name, ty)| (geom.points().get(name), ty.components())).collect();
+    let detail_cells: Vec<String> = {
+        let mut cells = Vec::new();
+        for (name, ty) in &detail {
+            push_cells(&mut cells, geom.detail().value(name, 0), ty.components());
+        }
+        cells
+    };
+    let width = headers.len();
+    let mut rows = Vec::with_capacity(geom.num_points());
     for p in 0..geom.num_points() {
         let pos = geom.positions()[p];
         let col = geom.color(p);
-        let mut row = vec![p.to_string()];
+        let mut row = Vec::with_capacity(width);
+        row.push(p.to_string());
         row.extend(groups.iter().map(|g| if geom.points().in_group(g, p) { "1" } else { "0" }.to_string()));
-        row.extend([
-            format!("{:.4}", pos[0]),
-            format!("{:.4}", pos[1]),
-            format!("{:.4}", pos[2]),
-            format!("{:.4}", col[0]),
-            format!("{:.4}", col[1]),
-            format!("{:.4}", col[2]),
-        ]);
-
-        for (name, ty) in &attribs {
-            // A column covers its whole class, so there is no "this element
-            // does not have it" case left to render as a dash.
-            match geom.points().value(name, p) {
-                Some(crate::detail::AttribValue::Float(f)) => row.push(format!("{:.4}", f)),
-                Some(crate::detail::AttribValue::Int(i)) => row.push(i.to_string()),
-                Some(crate::detail::AttribValue::Float2(a)) => {
-                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
-                }
-                Some(crate::detail::AttribValue::Float3(a)) => {
-                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
-                }
-                Some(crate::detail::AttribValue::Float4(a)) => {
-                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
-                }
-                None => row.extend(std::iter::repeat("-".to_string()).take(ty.components())),
-            }
-        }
-
-        for (name, ty) in &detail {
-            match geom.detail().value(name, 0) {
-                Some(crate::detail::AttribValue::Float(f)) => row.push(format!("{:.4}", f)),
-                Some(crate::detail::AttribValue::Int(i)) => row.push(i.to_string()),
-                Some(crate::detail::AttribValue::Float2(a)) => {
-                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
-                }
-                Some(crate::detail::AttribValue::Float3(a)) => {
-                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
-                }
-                Some(crate::detail::AttribValue::Float4(a)) => {
-                    row.extend(a.iter().map(|v| format!("{:.4}", v)))
-                }
-                None => row.extend(std::iter::repeat("-".to_string()).take(ty.components())),
-            }
+        row.extend([fmt4(pos[0]), fmt4(pos[1]), fmt4(pos[2]), fmt4(col[0]), fmt4(col[1]), fmt4(col[2])]);
+        // A column covers its whole class, so there is no "this element
+        // does not have it" case left to render as a dash.
+        for (data, components) in &point_cols {
+            push_cells(&mut row, data.and_then(|d| d.get(p)), *components);
         }
-
+        row.extend(detail_cells.iter().cloned());
         rows.push(row);
     }
 
diff --git a/src/geometry.rs b/src/geometry.rs
index 6728625..bb7b908 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -1160,14 +1160,18 @@ thread_local! {
     pub static STEPS_ON_THIS_THREAD: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
 }
 
-/// How many frames apart checkpoints start out.
-pub const CHECKPOINT_EVERY: i32 = 10;
-/// The most kept for one simnet, however large its states.
-pub const CHECKPOINTS_MAX: usize = 48;
+/// How many frames apart checkpoints start out: every frame, so a frame
+/// once solved is played back without a step. Until 2026-10-06 it was ten,
+/// and playing back through what had been solved stepped nine frames in
+/// ten again — a frame's whole step, growing with the mesh.
+pub const CHECKPOINT_EVERY: i32 = 1;
+/// The most kept for one simnet, however small its states.
+pub const CHECKPOINTS_MAX: usize = 1024;
 /// What one simnet's checkpoints may hold, by [`checkpoint_bytes`]. A state
 /// is kept twice over (itself and what its last substep consumed), so a
-/// hundred-thousand-point surface is tens of megabytes a checkpoint.
-pub const CHECKPOINT_BUDGET: usize = 512 * 1024 * 1024;
+/// hundred-thousand-point surface is tens of megabytes a checkpoint; past
+/// the budget the spacing doubles.
+pub const CHECKPOINT_BUDGET: usize = 2048 * 1024 * 1024;
 
 /// About what a checkpoint of `state` holds: positions, ids and a handful
 /// of attributes a point, the primitives' indices, twice. An estimate — the
@@ -6537,12 +6541,41 @@ pub fn origin_vectors_vertices(scale: f32) -> Vec<Vertex3D> {
 /// sphere kernel's exactly — that winding is the one the raster pass's
 /// backface cull is known to keep.
 pub fn points_vertices(src: &[Vertex3D], size: f32, color: [f32; 3]) -> Vec<Vertex3D> {
-    let mut seen = std::collections::HashSet::new();
-    let mut out = Vec::new();
     let r = size.max(0.001);
     const LAT_STEPS: usize = 4;
     const LON_STEPS: usize = 10;
     let pi = std::f32::consts::PI;
+    // The sphere's corners about the origin, worked out once: every marker
+    // is the same sphere moved, and each is its centre plus these. Until
+    // 2026-10-06 each of a marker's 240 corners took its own sines and
+    // cosines, which at ten thousand points was most of a playing frame.
+    // The same arithmetic in the same order, so the corners are the same
+    // to the bit.
+    let off = |theta: f32, phi: f32| [r * theta.sin() * phi.cos(), r * theta.cos(), r * theta.sin() * phi.sin()];
+    let mut unit = Vec::with_capacity(LAT_STEPS * LON_STEPS * 6);
+    for lat in 0..LAT_STEPS {
+        let theta0 = pi * lat as f32 / LAT_STEPS as f32;
+        let theta1 = pi * (lat + 1) as f32 / LAT_STEPS as f32;
+        for lon in 0..LON_STEPS {
+            let phi0 = 2.0 * pi * lon as f32 / LON_STEPS as f32;
+            let phi1 = 2.0 * pi * (lon + 1) as f32 / LON_STEPS as f32;
+            let p00 = off(theta0, phi0);
+            let p10 = off(theta1, phi0);
+            let p11 = off(theta1, phi1);
+            let p01 = off(theta0, phi1);
+            // Counter-clockwise seen from OUTSIDE, as `sphere_detail`
+            // winds and as the raster fill's back-face cull expects.
+            // This kept the retired soup's inward order until
+            // 2026-09-24, so the cull drew the INSIDE of each marker's
+            // far half and nothing of its near half — and a marker on a
+            // surface showed only where that far half poked out of the
+            // mesh, vanishing from the views where it did not.
+            // `point_markers_wind_outward` holds the sign.
+            unit.extend([p00, p11, p10, p00, p01, p11]);
+        }
+    }
+    let mut seen = std::collections::HashSet::with_capacity(src.len());
+    let mut out = Vec::with_capacity(src.len() * unit.len());
     for v in src {
         let key = (
             (v.position[0] * 1000.0).round() as i32,
@@ -6553,36 +6586,7 @@ pub fn points_vertices(src: &[Vertex3D], size: f32, color: [f32; 3]) -> Vec<Vert
             continue;
         }
         let [cx, cy, cz] = v.position;
-        let sp = |theta: f32, phi: f32| {
-            [
-                cx + r * theta.sin() * phi.cos(),
-                cy + r * theta.cos(),
-                cz + r * theta.sin() * phi.sin(),
-            ]
-        };
-        for lat in 0..LAT_STEPS {
-            let theta0 = pi * lat as f32 / LAT_STEPS as f32;
-            let theta1 = pi * (lat + 1) as f32 / LAT_STEPS as f32;
-            for lon in 0..LON_STEPS {
-                let phi0 = 2.0 * pi * lon as f32 / LON_STEPS as f32;
-                let phi1 = 2.0 * pi * (lon + 1) as f32 / LON_STEPS as f32;
-                let p00 = sp(theta0, phi0);
-                let p10 = sp(theta1, phi0);
-                let p11 = sp(theta1, phi1);
-                let p01 = sp(theta0, phi1);
-                // Counter-clockwise seen from OUTSIDE, as `sphere_detail`
-                // winds and as the raster fill's back-face cull expects.
-                // This kept the retired soup's inward order until
-                // 2026-09-24, so the cull drew the INSIDE of each marker's
-                // far half and nothing of its near half — and a marker on a
-                // surface showed only where that far half poked out of the
-                // mesh, vanishing from the views where it did not.
-                // `point_markers_wind_outward` holds the sign.
-                for p in [p00, p11, p10, p00, p01, p11] {
-                    out.push(Vertex3D { position: p, color });
-                }
-            }
-        }
+        out.extend(unit.iter().map(|o| Vertex3D { position: [cx + o[0], cy + o[1], cz + o[2]], color }));
     }
     out
 }
@@ -10237,12 +10241,12 @@ mod simnet_tests {
         // Out to frame 101: a hundred frames of two substeps.
         let (_, _, cost) = at(&mut cache, 101);
         assert_eq!(cost, 200);
-        assert_eq!(cache.checkpoint_frames(&simnet.id), vec![10, 20, 30, 40, 50, 60, 70, 80, 90]);
+        assert_eq!(cache.checkpoint_frames(&simnet.id), (1..=99).collect::<Vec<_>>(), "every frame passed");
 
-        // Back, forward, back again, onto a checkpoint, to the start and to
-        // where it came from: each arrives where a solve from the seed
-        // does, and costs the frames from the checkpoint behind it.
-        for (frame, frames_stepped) in [(100, 9), (38, 7), (84, 3), (37, 6), (61, 0), (1, 0), (101, 0), (96, 5), (99, 3)] {
+        // Back, forward, back again, to the start and to where it came
+        // from: each arrives where a solve from the seed does, and every
+        // frame solved is kept, so none costs a step.
+        for (frame, frames_stepped) in [(100, 0), (38, 0), (84, 0), (37, 0), (61, 0), (1, 0), (101, 0), (96, 0), (99, 0)] {
             let (state, fed, cost) = at(&mut cache, frame);
             let (want, want_fed) = fresh(frame);
             assert_eq!(state.positions(), want.positions(), "frame {frame}: the state");
@@ -10261,9 +10265,9 @@ mod simnet_tests {
         for frame in 1..=35 {
             at(&mut cache, frame);
         }
-        assert_eq!(cache.checkpoint_frames(&simnet.id), vec![10, 20, 30]);
+        assert_eq!(cache.checkpoint_frames(&simnet.id), (1..=33).collect::<Vec<_>>());
         let (state, _, cost) = at(&mut cache, 24);
-        assert_eq!((cost, state.positions() == fresh(24).0.positions()), (3 * 2, true));
+        assert_eq!((cost, state.positions() == fresh(24).0.positions()), (0, true));
 
         // An edit goes on from the frame in hand (24): the frames before
         // it stand, and none of the old solve's checkpoints are resumed
@@ -10275,7 +10279,7 @@ mod simnet_tests {
         let state = resolve_simnet_geometry_with_errors(&edited, &edited.children[1], &mut Vec::new(), &mut err, &mut sim).unwrap();
         assert_eq!(sim.cache.steps_run() - before, 27 * 2, "twenty-seven frames on from the one in hand");
         assert_ne!(state.positions(), fresh(51).0.positions());
-        assert_eq!(cache.checkpoint_frames(&simnet.id), vec![30, 40], "and its own checkpoints, kept as it passed them");
+        assert_eq!(cache.checkpoint_frames(&simnet.id), (23..=49).collect::<Vec<_>>(), "the frame it went on from, and its own, kept as it passed them");
         // Back to the start it is the seed, and the edit is in from the
         // first frame: frame 51 is then the edited chain's own fifty.
         let mut sim = EvalSim::new(1, 1, &mut cache);
@@ -10297,7 +10301,7 @@ mod simnet_tests {
         assert!(kept.len() <= CHECKPOINTS_MAX && kept.len() > CHECKPOINTS_MAX / 3, "{}", kept.len());
         assert!(kept[0] <= 100 && *kept.last().unwrap() >= 1900, "{kept:?}");
         let widest = kept.windows(2).map(|w| w[1] - w[0]).max().unwrap();
-        assert!(widest <= 80, "no gap wider than the spacing the cap asks for: {widest}");
+        assert!(widest as usize <= 2 * (2000 / CHECKPOINTS_MAX + 1), "no gap wider than the spacing the cap asks for: {widest}");
     }
 
     /// A Relax's `Rest` wire resolves to its OWN sibling. It was a
diff --git a/src/main.rs b/src/main.rs
index 9642eda..44e3192 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18128,6 +18128,31 @@ mod tests {
         }
     }
 
+    /// The spreadsheet's fast float formatter writes what `{:.4}` writes:
+    /// over every exponent, the exact ties (multiples of 1/2^k that land
+    /// on a fifth decimal of 5), negative zero and what rounds to it, and
+    /// the values too large for the integer path.
+    #[test]
+    fn fmt4_is_format_4() {
+        let mut check = |x: f32| assert_eq!(crate::app::fmt4(x), format!("{:.4}", x), "{x:e} ({:#x})", x.to_bits());
+        for x in [0.0, -0.0, 0.00005, -0.00005, 0.03125, -0.03125, 0.15625, 1.00005, 2.5e-5, 1.0e13, 9.9e13, 1.0e14, 3.0e38, f32::MIN_POSITIVE, 1.0e-45, f32::INFINITY, f32::NEG_INFINITY, f32::NAN] {
+            check(x);
+        }
+        // Ties: k/2^n with a 5 in the fifth decimal.
+        for n in 1..=24 {
+            for k in 0..2000u32 {
+                check(k as f32 / (1u32 << n) as f32);
+                check(-(k as f32) / (1u32 << n) as f32);
+            }
+        }
+        // And a sweep through the bit patterns.
+        let mut bits = 0u32;
+        while bits < u32::MAX - 997 {
+            check(f32::from_bits(bits));
+            bits += 997;
+        }
+    }
+
     /// 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