Skip to content

Bound the render cache, restore stateful nodes, and add time-dependent functions - #541

Open
fdb wants to merge 4 commits into
masterfrom
render-cache-fixes
Open

fdb wants to merge 4 commits into
masterfrom
render-cache-fixes

Conversation

@fdb

@fdb fdb commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Fixes found while reviewing master for the 3.2 release. The render cache from #535 had three bugs users would hit, and it removed stateful nodes.

User-visible changes

  • Memory stays bounded during animation and export. The cache keeps one result per node path. Before, a 2000-frame render of a 100k-element network ran out of a 2 GB heap around frame 650; it now completes with 2 cache entries and 12.6 MB of heap after the run.
  • Reload (Cmd-R) picks up code changes. Cached results are dropped when a code library is reloaded, added, removed or loaded again through the Code Libraries dialog. Undo and redo keep the cache.
  • Always-rendered nodes run with their inputs on every render. A cache hit made them run with default port values.
  • Functions can declare themselves time-dependent (the same idea as timeDependent in Figment). Such a function runs on every render instead of being cached:
    • Python: read_sensor.timeDependent = True
    • Clojure: (defn ^:time-dependent read-sensor [x] ...)
    • Java: @TimeDependent. network/httpGet and device/sendOSC use it, which replaces the hard-coded list in RenderCache.
  • Imported files are read again when they change on disk. A cached result records the modification time and size of every file read while computing it, including files read by nodes inside a subnetwork, and is used only while those files are unchanged. Nodes and networks that import files (import_svg, import_text, import_csv) stay cached.
  • Stateful nodes are back. State ports and device.buffer_points work as in 3.1. A stateful node is never cached, and the nodes upstream of it stay cached. State advances only on a completed, non-canceled render, and resets on Rewind. buffer_points also trims correctly when its size is lowered.
  • File format is 21 again, the same as 3.1.0, so 3.1 and 3.2 open each other's files. Version 22 from master nightlies is not supported.
  • Handles: pyhandles.SnapHandle draws and hit-tests in screen space (it also lacked an import of Rect). Handle.setViewTransform has an empty default, so a custom handle that implements Handle directly no longer fails on paint.

Migration note for the release

Node functions are cached: a node runs again only when its inputs, parameters or code change. In 3.1 every node ran on every render. For user code this means:

  • A function must not change its input objects in place. Clone first (p = path.clone()) or build a new object, because a cached result is shared between renders.
  • A function that reads the clock, the network, a device, unseeded random or a file it opens itself must be marked time-dependent.
  • Custom handles draw in screen space: project with self.toScreen(x, y).

Due diligence

  • In-tree: 48 example documents use only bundled libraries, with no user code, always-rendered nodes or state ports. Bundled pyvector functions clone before they change geometry.
  • Field: GitHub code search for NodeBox 3 documents linking Python or Clojure code found 15 documents with 16 user functions called by nodes, across 7 owners. 14 documents are unaffected: file reads go through file-widget ports, and randomness is seeded from ports. One breaks: bitcraftlab/nodebox-examples turtle.py mutates the Turtle (a Path subclass) it receives and returns it, so under caching the path gains extra segments on each edit. This is inferred from the code; I did not run it. The migration note above covers it.
  • Handles: no public repository defines a custom Python handle.

Cache checks

The cache must never change what renders. Two checks guard that:

  • Verify mode. -Dnodebox.cache.verify=true computes every cache hit again and compares the results by content. A difference fails with the node path. ant test and ant test-e2e set it; the end-to-end suite also fails if the running app hit a difference.
  • RenderCacheDifferentialTest. It replays random user edits on every example document and on a scenario document, and after each step compares a cached render with an uncached one. The edits are frames, mouse moves, parameter changes, rewiring, renaming, deleting, undo, switching the rendered node, file changes and code reloads. The scenario has files, user code, state, an always-rendered node and subnetworks. Each sequence runs with verify mode and without, since verify mode reruns hit nodes and would hide a skipped side effect.

Both checks were seen to fail on planted cache bugs, one per dependency the cache tracks: always-rendered nodes cached, code reload ignored, file changes ignored, stateful nodes cached, published-port arguments cached, files inside a network not passed to it, frame nodes cached, and connected inputs left out of the key. The differential test caught all eight. With the key bug planted, the end-to-end suite reported 39 stale hits.

Tests

  • ant clean test, then NODEBOX_E2E=1 ant test-e2e: 461 tests, 0 failures, 0 errors, 0 skipped (macOS).
  • New tests in RenderCacheTest, PyHandlesTest, DeviceFunctionsTest and NodeLibraryTest. Each failed before its fix.
  • Timing, with a range of 10,000 feeding a Python function and a dragged parameter downstream: 3.5–3.7 ms per render without a cache, 1.7 ms with user code cached.
  • No screenshots: the handle change is checked through recorded draw coordinates in PyHandlesTest. I did not inspect it on screen.

fdb added 4 commits September 28, 2026 09:47
…lare themselves time-dependent.

The render cache keeps one result per node path, so memory no longer grows with every animation
frame or drag step. Always-rendered nodes, nodes with context or state ports, and time-dependent
functions run on every render. Functions declare this with @timedependent (Java), a timeDependent
attribute (Python) or ^:time-dependent metadata (Clojure). Cached results are dropped when a code
library is reloaded, added or removed. Nodes that read files are cached with the modification time
and size of those files in the key.

State ports and device.buffer_points are back. A stateful node's previous output lives next to the
cache and advances only on a completed render. The file format returns to 21; version 22 files from
development builds open as 21.

Bundled handles draw in screen space, including pyhandles.SnapHandle, and Handle.setViewTransform
has a default. The launch end-to-end test drives the menu shortcut through Swing key bindings, since
macOS delivers OS-level keystrokes to the frontmost app.
…ion 22 upgrade.

A node's cached result keeps the modification time and size of every file read while computing it,
including files read by nodes inside a subnetwork. The result is used only while those files are
unchanged, so networks that import files stay cached.
…ed renders over random edits.

With -Dnodebox.cache.verify=true, which ant test and ant test-e2e set, every cache hit is computed
again and compared by content; a difference fails with the node path. RenderCacheDifferentialTest
replays random user edits (frames, mouse, parameters, rewiring, renaming, deleting, undo, rendered
node, file changes, code reloads) on every example and on a scenario document with files, user code,
state, an always-rendered node and subnetworks, and compares each cached render with an uncached one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant