Skip to content

perf(web): render the mindwalk scenes on change instead of every frame - #18

Merged
Dillpickleschmidt merged 4 commits into
mainfrom
feat/scenes-render-on-change
Aug 1, 2026
Merged

Dillpickleschmidt merged 4 commits into
mainfrom
feat/scenes-render-on-change

Conversation

@Dillpickleschmidt

@Dillpickleschmidt Dillpickleschmidt commented Aug 1, 2026 •

Copy link
Copy Markdown
Owner

The verbatim scene port landed mindwalk's unconditional requestAnimationFrame loop, so a parked, visible 3D view repainted every frame forever — the case AGENTS.md names: "No continuously repainting animations; they peg the GPU on high-refresh displays."

A new scene/frameLoop.ts drives both scenes instead. The gate is cameraMoved || labelsEasing || lerpMoving || playbackRunning, using signals the scenes already had: OrbitControls.update() returns whether the camera moved, and the height lerps already tracked a moving flag. invalidate() is called from every data effect, from resize, and from an OrbitControls change listener (which also covers fitView and ensureVisible). An IntersectionObserver plus document.hidden gates tab-hidden and surface-inactive.

Stayed on plain three.js. Nothing about suppressing frames needed react-three-fiber, and a rewrite would have discarded the verbatim baseline built in #9.

Measured, not inspected

window main this branch
idle, visible, untouched — 5s terrain 397 draws / 11 frames, tree 122 / 4 0 / 0 both
orbit drag 479 / 14 484 / 14
tab hidden — 3s 219 / 7, 156 / 4 0 / 0 both

Idle is zero, not merely low. Interaction is unchanged. The post-drag damping tail terminates — dampingFactor = 0.08 over roughly 100 frames, seconds on a GPU.

Two deliberate deviations from verbatim

Both are recorded in apps/web/src/mindwalk/README.md and on the map.

controls.autoRotate is disabled. Upstream drifts the camera on arrival and stops permanently at the first user interaction (TreeScene.tsx:123-127), already gated on prefers-reduced-motion. Bounded in the common case, unbounded if you open the view and never touch it — which is a realistic way to use a panel you glance at while an agent works. This is the one decision worth a second opinion; a middle option is to keep the drift but bound it by time or to hover/focus rather than removing it.

The firefly's sine pulse runs only while playing. Both scenes take a new playing?: boolean prop defaulting to false — plumbing for the future playback deck, and what makes the idle measurement meaningful today.

Also found

Mindwalk's lerps are frame-count based rather than time based, so the intro animation's duration depends on frame rate — 30+ seconds under software rendering. A genuine upstream defect, recorded as post-port work alongside the three found in #16, not fixed here.

Not covered

No unit tests. The behaviour under test is "does this schedule a frame," which is a browser scheduling property; the evidence is the frame profile above rather than an assertion that would mostly restate the implementation.

Refs #17.

Model: Claude Opus 5 via Claude Code.

🤖 Generated with Claude Code

Note

Render mindwalk scenes on demand instead of every frame

  • Adds a new FrameLoop class that schedules frames only when invalidated or while animations are active, suspending when the host is off-screen or the tab is hidden.
  • Replaces the perpetual requestAnimationFrame loops in CityScene and TreeScene with a drawFrame callback that returns whether any animation is still in flight.
  • Camera auto-rotate is now time-bounded via ATTRACT_DRIFT_MS and stops on the first user interaction; previously it ran indefinitely.
  • The firefly breathing pulse in both scenes now animates only while the playing prop is true and settles when playback stops.
  • DirLabelSet.ease now returns a boolean indicating whether any label opacity moved, so the frame loop can determine whether to continue.
  • Behavioral Change: scenes no longer render continuously at idle — discrete state changes (resize, slot swaps, selection updates) must call invalidate() to trigger a frame.

Macroscope summarized d984a7e.

Dillpickleschmidt and others added 2 commits August 1, 2026 08:32
The verbatim port ran mindwalk's unconditional rAF loop, repainting forever
while mounted. AGENTS.md: "No continuously repainting animations; they peg the
GPU on high-refresh displays." A visible but idle scene must now cost nothing,
which is stricter than pausing on a hidden tab — a parked, visible view is
exactly the case the rule names.

FrameLoop drives both scenes: an explicit invalidate() books one repaint for a
discrete change, the draw callback's return value keeps the loop going while an
animation is in flight, and a hidden tab or an off-screen host suspends both.
Playback is the one continuous mode, gated behind a new `playing` prop.

Auto-rotate is dropped. It is unbounded idle motion with no bounded form, so it
cannot survive the rule; the firefly's sine pulse moves under the playback
exception instead. DirLabelSet.ease now reports whether a fade is still easing,
and the height/halo lerps report the same, so the loop knows when to stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread apps/web/src/mindwalk/README.md Outdated
The render-on-change work disabled `controls.autoRotate` outright, reading
it as unbounded idle motion. It is narrower than that: mindwalk stops the
drift permanently at the first user interaction and already skips it under
prefers-reduced-motion, so it is bounded in the common case and unbounded
only on a view you open and never touch.

That case is still the one AGENTS.md names, but removing the effect gives
up how the scene reads as 3D on arrival. Bound it instead: the drift plays
and stops at whichever comes first, the first interaction or
ATTRACT_DRIFT_MS. At autoRotateSpeed -0.5 that is roughly 18 degrees of
parallax. Idle repaint after the window is still zero, since the frame
loop already gates on whether the camera moved.

Refs #17.

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

Copy link
Copy Markdown
Owner Author

autoRotate is now bounded rather than dropped (deef976ce).

The original reasoning was right but the facts were incomplete. Mindwalk stops the drift permanently at the first interaction (TreeScene.tsx:123-127 upstream) and already skips it under prefers-reduced-motion — so it is bounded in the common case, and unbounded only on a view you open and never touch. That case is still the one AGENTS.md names, but it did not justify giving up how the scene reads as 3D on arrival.

The drift now ends at whichever comes first: the first interaction, still mindwalk's own rule, or ATTRACT_DRIFT_MS (6s). At autoRotateSpeed = -0.5 — a full orbit in roughly 120s — that is about 18 degrees, enough parallax to read depth. Reduced-motion users get no drift, as upstream. The timer is cleared on interaction and on unmount.

The idle measurement is unaffected. The frame loop already gates on whether the camera moved, so once the window closes the scene returns to zero draws on a parked, visible host. The only change is that the first six seconds after mount are animated — bounded, user-visible, and over on its own.

Rejected alternative: scoping the drift to playing. Rotating the camera while someone is reading a playback fights the thing they are trying to watch, which is worse than either keeping or dropping it.

Typecheck and lint clean. The one lint warning in playback/recorder.ts is verbatim upstream code and predates this branch.

The section lists four deviations but opened by claiming one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Dillpickleschmidt
Dillpickleschmidt merged commit 35ea15d into main Aug 1, 2026
2 checks passed
@Dillpickleschmidt
Dillpickleschmidt deleted the feat/scenes-render-on-change branch August 1, 2026 13:56
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