The update system: what changed since 1.4.1
This document explains, in plain terms, how the object update architecture evolved from version 1.4.1 to the current design, and why.
The problem the update system solves
A jmathanim scene is full of objects that depend on each other: a Shape owns a
JMPath, the path owns its JMPathPoints, each point owns its Vec coordinates;
a LaTeX formula groups dozens of glyph shapes; a delimiter stretches between two
objects; a label follows the thing it annotates. When something moves, everything
that depends on it must notice and recompute itself — and nothing else should waste
time recomputing.
Every versioned object carries a version number: a global counter is incremented on every mutation, and the mutated object records that value. "Did X change since I last looked?" becomes a simple number comparison.
How 1.4.1 worked
In 1.4.1, getVersion() returned only the object's own counter. Each frame, every
scene object asked "did any of my direct dependencies change their number?" and, if
so, updated itself and bumped its own number. Because objects were processed in
dependency order (dependencies first), a change cascaded level by level through the
frame: the point bumps, then the path sees it and bumps, then the shape sees the path...
This was cheap — each object only ever looked one level down — but it had a blind spot: an object's version told you nothing about its insides. If you asked a path for its version after moving one of its points (without a scene update pass in between), the answer didn't change. Geometric caches keyed on those versions (bounding boxes, arc lengths, areas) could go stale, which caused real, hard-to-trace bugs: collapsed bounding boxes, labels not following, group members not propagating.
The 1.4.2–1.4.4 redesign: effective versions
To fix that class of bugs, getVersion() was redefined as the effective version:
the maximum version over the object and its whole dependency tree, computed
recursively. Now "did anything inside this object change?" is always answerable, at
any moment, with one call. All the features added since (delimiters, connectors,
labels, rigid boxes, the path cache system) are built on this guarantee.
The catch was the cost. The recursive answer was memoized, but the cache was declared stale whenever the global counter moved — and during an animation something moves every frame, so every cache in the scene died every frame, and the recursive walks ran again over the entire tree. Two independent problems made it worse:
- Style copies performed during every animation frame replaced paint objects, which counted as a structural change of the dependency graph — so the graph (183,000 nodes in a text-heavy scene) was rebuilt and re-sorted almost every frame.
- The update pass itself iterated every discovered node, every frame, even the tens of thousands of coordinate leaves that had nothing to do.
The result was the reported 3–4× render slowdown.
The current design: pull semantics, push invalidation
The fix keeps the 1.4.2+ semantics (effective versions, always correct) but restores the 1.4.1 cost model (only what changed gets touched). The key idea: instead of guessing when caches are stale, changed objects tell their dependents directly.
Four pieces work together:
1. A changed-objects queue. changeVersion() — the one method every mutation goes
through — appends the object to a static list (deduplicated, so it costs one comparison
in the common case). Nothing else happens at mutation time; mutating stays cheap.
2. Reverse edges and dirty flags. The DependencyGraph always knew who depends on
whom (node → dependencies); it now also stores the mirror image (node → dependents).
At the start of each frame's update pass it drains the queue and walks upward from
each changed object, setting a "your cached answer is stale" flag on every dependent
it reaches — and only on those. A frame where one circle moved flags a few dozen
objects, not 183,000. Changes that happen during the pass (an object bumping its
version as it updates) are pushed upward immediately, so dependents processed later
in the same frame still see them. Climbs stop early at already-flagged nodes, which
keeps long dependency chains linear instead of quadratic.
3. Lazy recomputation. getVersion() answers from its cache unless the flag says
otherwise, in which case it recomputes (and the recursion only descends into flagged
regions). The update pass itself now visits only the updateable objects, in
topological order — the coordinate leaves are never iterated at all.
4. A trusted-query window. While the scene is inside its per-frame
update-and-draw phase, flags are the source of truth and clean objects answer in O(1).
Outside that window (user code running between frames), the system falls back to the
old conservative check against the global counter, so a shape.getArea() right after
a point.shift(...) in user code is always exact. The window covers drawing too, so
the renderer's per-shape version checks — used to decide which JavaFX nodes need
re-syncing — are constant-time.
One more fix sits outside the versioning machinery: style copies
(MODrawProperties.copyFrom and friends) now copy paint values in place instead of
replacing the paint object, so per-frame animation bookkeeping no longer counts as a
structural graph change. Graph rebuilds happen only when objects are genuinely added,
removed, or re-wired.
In the same spirit, no-op mutations are filtered at the source and never touch a
version counter: shift(0,0), scale(1), rotate(0) and their variants return
immediately, and the style-copy methods (copyFrom, rawCopyFrom, interpolateFrom)
compare each attribute against the current value, absorb only the ones that actually
differ, and bump the version once — or not at all when the source style is identical,
which is exactly what happens every frame when an animation restores the state of an
object whose style did not change.
Cost per frame, before and after
| Scenario | 1.4.1 | 1.4.4 (as released) | Current |
|---|---|---|---|
| Static frame | O(scene objects) | O(whole graph), several times over | ~free |
| One object animated | O(its subtree) | O(whole graph) + full graph rebuild | O(its subtree) |
| Structural change (add/remove) | cheap | full rebuild (often every frame) | O(what was rewired) |
The last row was still a full rebuild until the graph started recording which
nodes were rewired instead of only that something had been. A structural change
is now reported to the graph that holds the node, and to nobody when no graph
does, so an animation building throwaway geometry every frame stops disturbing
the scene graph at all; what the graph does hold is repaired edge by edge, and
the full rediscover-and-re-sort is kept as the fallback for a repair that cannot
preserve the topological order. On the GraphRebuild benchmark (twelve LaTeX
lines, 56,169 nodes, a two-second showCreation) that took 60 frames from 41
full rebuilds and 1,209,305 nodes visited down to 1 rebuild and 4,341 visits.
DEPENDENCY_GRAPH.md §5 has the rules the repair has to respect.
Measured on the reporter's benchmark (40 LaTeX lines + one animated shape, 3000 frames at 1080p60, same machine, back-to-back runs): 1.4.1 rendered in 79.0 s, the released 1.4.4 in ~320 s, and the current code in 80.2 s — with the update phase itself down from a cumulative 253 s to 2.9 s.
Semantics and known trade-offs
getVersion()is still the effective (whole-subtree) version everywhere. All behavior that 1.4.2–1.4.4 features rely on is preserved; the full test suite passes unchanged.- If an updater mutates an object that comes earlier in the update order without declaring it as a dependency, that change is seen one frame late. 1.4.1 behaved the same way; declaring the dependency avoids it.
- The changed-objects queue is shared process-wide and is drained by whichever graph runs its update pass. This assumes one active scene graph at a time (which is how scenes work); standalone graphs used in tests are unaffected in practice because correctness outside update passes never relies on the queue.