Skip to content

fix: Run mode behaviour (loop_once / loop_ntimes) - #1084

Merged
mitchmindtree merged 2 commits into
nannou-org:masterfrom
mitchmindtree:loop-once
Jul 15, 2026
Merged

fix: Run mode behaviour (loop_once / loop_ntimes)#1084
mitchmindtree merged 2 commits into
nannou-org:masterfrom
mitchmindtree:loop-once

Conversation

@mitchmindtree

Copy link
Copy Markdown
Member

Summary

Reintroduces the pre-0.20 LoopMode::loop_once() behaviour that was lost in the Bevy port, and cleans up the surrounding RunMode API.

Previously, set_update_mode(UpdateMode::reactive(..)) keeps re-running on mouse movement, and no single UpdateMode can both ignore input and stay responsive, because bevy_winit cannot distinguish cursor movement from window close/resize (both are "window events").

What this adds

  • RunMode::loop_once() and RunMode::loop_ntimes(n), with matching .loop_once() / .loop_ntimes(n) shortcuts on both the app Builder and the SketchBuilder. update and view each run exactly n times (default 1), then the last frame is held on screen while the window idles and stays closable and resizable. This is the intended mode for static, sketch-based compositions.
  • App::set_run_mode(..) to change the run mode at runtime, mirroring App::set_update_mode. Loop modes can be entered and re-entered live.
  • Two examples under examples/nannou_basics/:
    • loop_once - draws a static spiral once and holds it, and
    • run_modes - switches between Continuous, reactive rate, wait, and the loop modes at runtime with the keyboard.

How the frame is held

Simply capping view presents a black window. Under Bevy the drawn frame lives in the camera's persistent intermediate render texture, which is only kept populated while the window keeps drawing. Once drawing stops, empty passes plus MSAA-writeback ping-pong plus TextureCache eviction leave it black, so unfortunately our old approach of just re-using the texture is blocked at the moment.

Instead, in this workaround we freeze the Draw instance itself. A new nannou_draw::DrawFrozen resource gates the per-frame draw systems (reset_draw, clear_previous_frame, update_draw_mesh), so the last frame's rendered meshes and clear color persist and the render graph keeps drawing them. update/view are then skipped too, so they run exactly n times even for a sketch whose view reads live time or input. A Last-schedule system (apply_loop_once) counts rendered frames and, once the budget is spent, freezes the draw and switches to freeze() to idle. It runs in Last so the final frame's meshes, spawned in PostUpdate, survive into the next frame.

Breaking change: RunMode trimmed

RunMode is reduced to UntilExit and LoopNTimes. The Ticks, Duration and once() variants (run N frames or a duration, then quit) are removed. They were unused and undocumented, and once() (quit after one frame) was easily confused with loop_once() (hold after one frame). To run a fixed number of frames and then quit, call App::quit() from your own update once a counter reaches the target.

Migrating from the pre-0.20 LoopMode

  • LoopMode::loop_once() -> RunMode::loop_once() (or .loop_once() on the app or sketch builder).
  • LoopMode::loop_ntimes(n) -> RunMode::loop_ntimes(n).
  • LoopMode::Wait -> app.set_update_mode(UpdateMode::wait()).
  • LoopMode::Rate / rate_fps(fps) -> app.set_update_rate(fps) or UpdateMode::rate(hz).
  • LoopMode::RefreshSync -> UpdateMode::Continuous (the default).

Reintroduce the pre-0.20 `LoopMode::loop_once()` / `NTimes` behaviour, lost in the
Bevy port, as a true single-view mode. `RunMode::loop_once()` / `loop_ntimes(n)`
run `update` and `view` exactly `n` times, then hold the last frame on screen while
the window idles at ~0 CPU and stays closable and resizable - ideal for static
`sketch`-based compositions. Because `view` runs a fixed number of times rather
than continuously, a sketch reading live time or input is genuinely frozen.

Holding a static frame is not free under Bevy: once a window stops drawing, the
camera's intermediate render texture is no longer kept populated, so simply
skipping `view` presents a black window. Instead the draw is frozen. A new
`nannou_draw::DrawFrozen` resource gates the per-frame draw systems (`reset_draw`,
`clear_previous_frame`, `update_draw_mesh`) so the last frame's meshes and clear
color persist and the render graph keeps drawing them. `apply_loop_once` (a `Last`
system) counts rendered frames and, once the budget is spent, freezes the draw and
switches to `freeze()` to idle. It runs in `Last` so the final frame's meshes,
spawned in `PostUpdate`, survive into the next frame.

Add `App::set_run_mode` to change the run mode at runtime; `apply_loop_once` resets
its state on any run-mode change, so loop modes can be entered and re-entered live.

Reshape `RunMode` to just `UntilExit` and `LoopNTimes`, dropping the unused and
undocumented `Ticks` / `Duration` / `once()` - `once()` (quit after one frame) was
easily confused with `loop_once()` (hold after one frame), and "run N frames then
quit" is a one-line `app.quit()` from `update`.

Also fix two stale `LoopMode` doc references and document the `freeze()` caveat.
`loop_once` draws a static spiral once via `sketch(view).loop_once()` and holds it.
`run_modes` switches run/update modes at runtime with the keyboard (Continuous,
reactive rate, wait, loop_once, loop_ntimes(60)) - a moving dot and update counter
make each mode's cadence tangible.
@mitchmindtree mitchmindtree self-assigned this Jul 15, 2026
@mitchmindtree
mitchmindtree merged commit bffa988 into nannou-org:master Jul 15, 2026
11 checks passed
@mitchmindtree
mitchmindtree deleted the loop-once branch July 15, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant