graphic design tool
git clone https://git.lucas.co/cce-designer.git
perf: a simnet keeps checkpoints, and a backward scrub resumes from one
A step is not invertible, so going back meant solving from the seed: a
scrub from frame 120 to 119 was 119 steps, and dragging the playhead
backwards re-solved the whole history at every frame it passed. A solve
now keeps a frame's state, and what its last substep consumed, every ten
frames — as it passes one and as it leaves one, since a solve played a
frame at a time never passes a frame, it arrives and leaves — and keeps
the frame a backward scrub leaves. A resume takes the nearest kept frame
at or behind the one asked for.
They belong to one key, so an edit drops them: an edit at frame 120 is
still 120 steps. They are kept within a count and a byte budget; with no
room the spacing doubles and stays doubled, so a long solve stays evenly
covered from its start.
What a resume arrives at is what a solve from the seed arrives at, state
and feedback both. On the project it was measured on, a scrub back over
sixty frames from frame 120 went from a mean of 15 ms a frame to 1.3,
and from frame 240 from 67 to 4.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
CLAUDE.md | 52 ++++++++++--
src/geometry.rs | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++++----
2 files changed, 286 insertions(+), 20 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 1799c75..e8565c0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -171,8 +171,10 @@ gone from cce-ui with the wgpu path).
`input` node reads off the feedback stack instead of jumping to the outer
graph. Solves run up to the playbar frame and cache per node id on `State::
sim_cache` (playing forward = one step per frame); the cache key hashes the
- simnet subtree + seed, so edits restart the sim, and backward scrubs restart
- from the seed (steps are not invertible). The scene walk does NOT recurse
+ simnet subtree + seed, so edits restart the sim, and backward scrubs resume
+ from the nearest CHECKPOINT behind them (steps are not invertible; until
+ 2026-09-29 they restarted from the seed — see "Simulation checkpoints"
+ below). The scene walk does NOT recurse
into a simnet's children — that would draw one un-iterated pass of the chain
on top of the solved result. Dived INTO a simnet, the output child's
geometry flag draws the solved state, and every OTHER visible child draws
@@ -1156,9 +1158,49 @@ thickness of itself every pass runs and the pairs themselves are the
work, and no bookkeeping saves that.
What still costs is the solver's, not the node's: an edit inside a simnet
-and a backward scrub both re-solve from the seed, so a change at frame
-120 is 120 steps. In-memory checkpoints are the fix for scrubs and are
-not built.
+re-solves from the seed, so a change at frame 120 is 120 steps. A
+backward scrub no longer does — the next section.
+
+### Simulation checkpoints
+
+A step is not invertible, so going back means going forward from
+somewhere earlier, and until 2026-09-29 that somewhere was the seed: a
+scrub from frame 120 to 119 was 119 steps, and dragging the playhead
+backwards re-solved the whole history at every frame it passed. A solve
+now keeps CHECKPOINTS in memory (`geometry::Checkpoint`, on the
+`SimSolve` beside the latest state): a frame's state and what its last
+substep consumed, which is everything a resume and the interior view
+need.
+
+- **One every `CHECKPOINT_EVERY` (10) frames**, 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
+ and leaves from it.
+- **The frame a backward scrub leaves is kept too**, so coming forward
+ to it again is a resume.
+- **A resume takes the nearest kept frame at or behind the one asked
+ for**, the latest state included, so a scrub either way inside what
+ has been solved steps fewer than an interval's frames.
+- **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. An edit at
+ frame 120 is still 120 steps: nothing earlier than an edit survives it.
+- **Within a count and a budget** (`CHECKPOINTS_MAX` 48,
+ `CHECKPOINT_BUDGET` 512 MB by an estimate of a state's size). 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
+ other one while new ones arrive at the old spacing, which is what the
+ first cut did and which thinned the start of a long solve again and
+ again until it had gaps of hundreds of frames.
+
+What a resume arrives at is what a solve from the seed arrives at, state
+and feedback both: `a_scrub_resumes_from_a_checkpoint_and_arrives_at_the_same_state`
+compares them frame by frame and counts the steps each cost
+(`SimCache::steps_run`). On the project this was measured on, a scrub
+back over sixty frames from frame 120 went from a mean of 15 ms a frame
+to 1.3, and from frame 240 from 67 to 4. The disk cache (`Cache` on the
+simnet) is unchanged and still holds the one latest frame.
### The volume representation
diff --git a/src/geometry.rs b/src/geometry.rs
index 9177438..c6c446e 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -930,6 +930,90 @@ struct SimSolve {
/// 2026-09-28 it was the state at the START of the frame, which under
/// substeps showed one substep of a frame that took several.
prev: Detail,
+ /// Earlier frames of the same solve, kept so a backward scrub resumes
+ /// from the nearest one behind it instead of the seed. See
+ /// [`Checkpoint`].
+ checkpoints: Checkpoints,
+}
+
+/// A frame of a solve, kept in memory: its state and what its last substep
+/// consumed, as [`SimSolve`] keeps the latest.
+///
+/// A step is not invertible, so going BACK used to mean solving from the
+/// seed: a scrub from frame 120 to 119 was 119 steps, and dragging the
+/// playhead backwards re-solved the whole history at every frame it passed.
+/// One of these is kept every [`CHECKPOINT_EVERY`] frames as a solve runs
+/// (further apart once there are too many: [`Checkpoints::keep`]),
+/// and the frame a solve is asked to leave behind is kept too, so a scrub
+/// in either direction inside what has been solved steps less than an
+/// interval.
+///
+/// They belong to one key. An edit to the chain or the seed changes it and
+/// they go with the state they were frames of, which is why an edit at
+/// frame 120 is still 120 steps: nothing earlier than the edit survives it.
+#[derive(Clone)]
+struct Checkpoint {
+ frame: i32,
+ state: Detail,
+ prev: Detail,
+}
+
+/// 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;
+/// 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;
+
+/// About what a checkpoint of `state` holds: positions, ids and a handful
+/// 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)
+}
+
+/// One solve's checkpoints, in frame order, and how far apart they are
+/// being kept.
+struct Checkpoints {
+ every: i32,
+ kept: Vec<Checkpoint>,
+}
+
+impl Default for Checkpoints {
+ fn default() -> Self {
+ Checkpoints { every: CHECKPOINT_EVERY, kept: Vec::new() }
+ }
+}
+
+impl Checkpoints {
+ /// Keep `at`, within the count and the budget. When there is no room
+ /// the SPACING doubles, and stays doubled: what is not on the wider
+ /// interval goes, and what arrives from then on arrives that far
+ /// apart. Dropping the oldest would leave a long solve with nothing
+ /// near its start, and dropping every other one while new ones kept
+ /// arriving at the old spacing thinned the start again and again — a
+ /// scrub is as likely to land there as anywhere, and an even spacing
+ /// is what bounds the steps from anywhere.
+ fn keep(&mut self, at: Checkpoint) {
+ let each = checkpoint_bytes(&at.state).max(1);
+ match self.kept.binary_search_by_key(&at.frame, |c| c.frame) {
+ Ok(i) => self.kept[i] = at,
+ Err(i) => self.kept.insert(i, at),
+ }
+ let room = (CHECKPOINT_BUDGET / each).clamp(2, CHECKPOINTS_MAX);
+ while self.kept.len() > room {
+ self.every = self.every.saturating_mul(2);
+ let every = self.every;
+ self.kept.retain(|c| c.frame % every == 0);
+ }
+ }
+
+ /// The nearest kept frame at or behind `due` and ahead of `after`.
+ fn behind(&self, due: i32, after: i32) -> Option<&Checkpoint> {
+ self.kept.iter().rev().find(|c| c.frame <= due && c.frame > after)
+ }
}
/// Per-simnet solved states, keyed by node id. Owned by the caller (the app keeps
@@ -938,12 +1022,27 @@ struct SimSolve {
#[derive(Default)]
pub struct SimCache {
entries: std::collections::HashMap<String, SimSolve>,
+ /// Runs of a chain since this cache was made — every substep of every
+ /// simnet. What a solve COST, for the tests that a scrub resumes and
+ /// does not re-solve.
+ steps_run: usize,
}
impl SimCache {
pub fn clear(&mut self) {
self.entries.clear();
}
+
+ /// See [`SimCache::steps_run`].
+ pub fn steps_run(&self) -> usize {
+ self.steps_run
+ }
+
+ /// The frames simnet `id` has checkpoints at, ascending — relative to
+ /// its start frame, as the solve counts them.
+ pub fn checkpoint_frames(&self, id: &str) -> Vec<i32> {
+ self.entries.get(id).map(|e| e.checkpoints.kept.iter().map(|c| c.frame).collect()).unwrap_or_default()
+ }
}
/// The simulation half of an evaluation: which frame the graph is being evaluated
@@ -6240,12 +6339,41 @@ pub fn resolve_simnet_geometry_with_errors(
let start_frame = node_param_f32(target, "Start Frame", sim.start_frame as f32).round() as i32;
let due = (sim.frame - start_frame).max(0);
- // Resume from the cached solve when it is still valid and has not run PAST
- // the frame asked for; scrubbing backwards has to restart from the seed,
- // because a step is not invertible. Memory first, then disk.
- let cached = sim.cache.entries.get(&target.id).and_then(|prev| {
- (prev.key == key && prev.frame <= due).then(|| (prev.state.clone(), prev.frame))
- });
+ // Resume from what is kept of this solve: the latest state when it has
+ // not run PAST the frame asked for, else the nearest checkpoint behind
+ // it — a step is not invertible, so going back means going forward from
+ // somewhere earlier. Memory first, then disk, then the seed.
+ //
+ // The entry is taken out while the solve runs and put back at its end.
+ // One of another key (the chain or the seed was edited) is dropped
+ // here, its checkpoints with it.
+ let prior = sim.cache.entries.remove(&target.id).filter(|e| e.key == key);
+ let mut checkpoints = Checkpoints::default();
+ let mut cached: Option<(Detail, Detail, i32)> = None;
+ if let Some(prior) = prior {
+ checkpoints = prior.checkpoints;
+ if prior.frame <= due {
+ // Playing forward a frame at a time, the frame in hand is
+ // always the one before the frame asked for, so the solve never
+ // PASSES a frame on the interval — it arrives on one and leaves
+ // from it. Kept as it is left.
+ if prior.frame < due && prior.frame > 0 && prior.frame % checkpoints.every == 0 {
+ checkpoints.keep(Checkpoint { frame: prior.frame, state: prior.state.clone(), prev: prior.prev.clone() });
+ }
+ cached = Some((prior.state, prior.prev, prior.frame));
+ } else {
+ // Going back. The frame being left is kept, so coming forward
+ // to it again is a resume too.
+ checkpoints.keep(Checkpoint { frame: prior.frame, state: prior.state, prev: prior.prev });
+ }
+ }
+ // A checkpoint nearer the frame than what is in hand wins: behind a
+ // backward scrub there is nothing in hand, and a forward jump past
+ // several may land on one.
+ let in_hand = cached.as_ref().map_or(-1, |c| c.2);
+ if let Some(c) = checkpoints.behind(due, in_hand) {
+ cached = Some((c.state.clone(), c.prev.clone(), c.frame));
+ }
let caching = node_param_bool(target, "Cache", false);
// What the last substep consumed, carried with the solve so the interior
// view can be drawn without re-solving: resumed from the cache when the
@@ -6255,15 +6383,10 @@ pub fn resolve_simnet_geometry_with_errors(
// seed for that frame — the interior view and the pull arrows drawn from
// where the sim started, once per app launch with Cache on.
let disk = if cached.is_none() && caching { read_sim_cache(&target.id, key, due) } else { None };
- let mut prev_frame = match (&cached, &disk) {
- (Some(_), _) => sim.cache.entries.get(&target.id).map(|e| e.prev.clone()).unwrap_or_default(),
- (None, Some((_, prev, _))) => prev.clone(),
- (None, None) => seed.clone(),
- };
- let (mut state, mut done) = match (cached, disk) {
+ let (mut state, mut prev_frame, mut done) = match (cached, disk) {
(Some(hit), _) => hit,
- (None, Some((state, _, frame))) => (state, frame),
- (None, None) => (seed, 0),
+ (None, Some(hit)) => hit,
+ (None, None) => (seed.clone(), seed, 0),
};
// Substeps run the chain more than once per frame. A step's size is what
@@ -6314,8 +6437,14 @@ pub fn resolve_simnet_geometry_with_errors(
// 3 — no longer silently takes the simulation's memory with it.
state.restore_live_from(&prev);
prev_frame = prev;
+ sim.cache.steps_run += 1;
}
done += 1;
+ // 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 {
+ checkpoints.keep(Checkpoint { frame: done, state: state.clone(), prev: prev_frame.clone() });
+ }
}
if caching && due > 0 {
@@ -6323,7 +6452,7 @@ pub fn resolve_simnet_geometry_with_errors(
}
sim.cache.entries.insert(
target.id.clone(),
- SimSolve { key, frame: due, state: state.clone(), prev: prev_frame },
+ SimSolve { key, frame: due, state: state.clone(), prev: prev_frame, checkpoints },
);
Some(state)
}
@@ -8821,6 +8950,101 @@ mod simnet_tests {
let _ = std::fs::remove_file(path.with_extension("simcache.tmp"));
}
+ /// A solve keeps checkpoints, and a scrub resumes from the nearest one
+ /// behind it: going back a frame is a handful of steps, not the whole
+ /// history. What it arrives at is what a solve from the seed arrives
+ /// at — the state and the feedback both — whichever way it came; an
+ /// edit drops them; and they are kept within a count however long the
+ /// solve runs.
+ #[test]
+ fn a_scrub_resumes_from_a_checkpoint_and_arrives_at_the_same_state() {
+ let sim_of = |value: &str, substeps: &str| {
+ let sphere = node("id-sphere", "Sphere 1", "sphere", vec![param("Radius", "0.5")], vec![]);
+ let inner_input = node("id-in", "input1", "input", vec![], vec![]);
+ let pull = node("id-pull", "pull1", "attribute", vec![
+ param("Input", "input1"), param("Operation", "Modify"), param("Attribute Name", "Pos"),
+ param("Value", value), param("Combine", "Multiply"), param("Group", ""),
+ ], vec![]);
+ let inner_output = node("id-out", "output1", "output", vec![param("Input", "pull1")], vec![]);
+ let sim_node = node("id-sim-checkpoints", "Simnet 1", "simnet",
+ vec![param("Input", "Sphere 1"), param("Substeps", substeps)],
+ vec![inner_input, pull, inner_output]);
+ node("id-root", "root", "node", vec![], vec![sphere, sim_node])
+ };
+ // A Multiply, so every frame's state is a function of the one
+ // before and a wrong resume shows.
+ let root = sim_of("1.01:0.99:1.02", "2");
+ let simnet = &root.children[1];
+ let at = |cache: &mut SimCache, frame: i32| -> (Detail, Detail, usize) {
+ let before = cache.steps_run();
+ let mut sim = EvalSim::new(frame, 1, cache);
+ let mut err = None;
+ let fed = simnet_step_feedback(&root, simnet, &mut Vec::new(), &mut err, &mut sim).expect("solves");
+ let state = resolve_simnet_geometry_with_errors(&root, simnet, &mut Vec::new(), &mut err, &mut sim).expect("solves");
+ (state, fed, cache.steps_run() - before)
+ };
+ let fresh = |frame: i32| {
+ let mut cache = SimCache::default();
+ let (state, fed, _) = at(&mut cache, frame);
+ (state, fed)
+ };
+
+ let mut cache = SimCache::default();
+ // 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]);
+
+ // 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)] {
+ let (state, fed, cost) = at(&mut cache, frame);
+ let (want, want_fed) = fresh(frame);
+ assert_eq!(state.positions(), want.positions(), "frame {frame}: the state");
+ assert_eq!(fed.positions(), want_fed.positions(), "frame {frame}: what its last substep consumed");
+ assert_eq!(cost, frames_stepped * 2, "frame {frame} cost {cost} substeps");
+ }
+ // The frames a scrub LEFT are kept beside the interval's.
+ let kept = cache.checkpoint_frames(&simnet.id);
+ for frame in [100, 83, 60] {
+ assert!(kept.contains(&frame), "{frame} was left behind, and kept: {kept:?}");
+ }
+
+ // Played a frame at a time, as the playbar does, the interval's
+ // frames are kept as they are left.
+ let mut cache = SimCache::default();
+ for frame in 1..=35 {
+ at(&mut cache, frame);
+ }
+ assert_eq!(cache.checkpoint_frames(&simnet.id), vec![10, 20, 30]);
+ let (state, _, cost) = at(&mut cache, 24);
+ assert_eq!((cost, state.positions() == fresh(24).0.positions()), (3 * 2, true));
+
+ // An edit is another solve: nothing of the old one is resumed from.
+ let edited = sim_of("1.02:0.99:1.02", "2");
+ let mut sim = EvalSim::new(51, 1, &mut cache);
+ let mut err = None;
+ let before = sim.cache.steps_run();
+ 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, 100, "fifty frames from the seed");
+ assert_ne!(state.positions(), fresh(51).0.positions());
+ assert_eq!(cache.checkpoint_frames(&simnet.id), vec![10, 20, 30, 40], "and its own checkpoints");
+
+ // However long it runs, the count is bounded, and what is kept
+ // stays spread over the whole of it.
+ let root = sim_of("1.0001:1.0:1.0", "1");
+ let simnet = &root.children[1];
+ let mut cache = SimCache::default();
+ let mut sim = EvalSim::new(2001, 1, &mut cache);
+ resolve_simnet_geometry_with_errors(&root, simnet, &mut Vec::new(), &mut None, &mut sim).unwrap();
+ let kept = cache.checkpoint_frames(&simnet.id);
+ 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}");
+ }
+
/// A Relax's `Rest` wire resolves to its OWN sibling. It was a
/// whole-tree search by name, so in the second of two subnets that each
/// hold a `shape`, Rest found the first one's — here a sphere of half