Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Frame Pacing

A game loop is supposed to spin. An editor showing a still image is not.

Until #656 the engine made no distinction: the winit handler asked for the next redraw at the end of every frame, unconditionally, so the loop fed itself forever. Vsync capped it at the refresh rate, which is the only reason it cost one core per process rather than all of them. Idle, with a project open and nothing happening, that measured at two pinned cores and 51.8 W on a 9800X3D — to display an image that was not changing.

The contract

Two types in kooch_core::frame_pacing:

  • FrameRequest — what this frame decided the next one needs. Systems raise it; the runner reads it once per frame and resets it to a baseline. Raising is monotonic within a frame: the most urgent request wins, so draw order can never talk a system out of a repaint it asked for.
  • FrameWaker — a clonable handle any thread can use to break the loop out of a sleep. The wake is sticky, so one that lands between the end of a frame and the moment the runner commits to sleeping is not lost.

Three paces, in order of urgency:

PaceMeansControlFlow
ContinuousSomething is animating or simulatingPoll + request_redraw
After(d)Something is on a timerWaitUntil(now + d)
WaitNothing to drawWait

An app that inserts no FrameRequest keeps spinning. That is not an oversight — it is what a shipped game wants, and it means the opt-in is explicit at every call site that needs it.

Who asks for what

The editor (FrameRequest::new(FramePace::Wait)) takes its answer from egui, which already computes one: run_ui returns a repaint_delay per viewport, ZERO while something animates and Duration::MAX when the UI has drawn everything it has. Two things egui cannot see are folded in on top:

  • Play — the viewport texture changes from another process, with no widget to notice it through. Continuous for as long as Play lasts.
  • A live remote session — the project’s stdout arrives on a socket, not as a window event, so a fully asleep editor would hold its Console output until the user happened to move the mouse. After(250 ms).

A frame that failed to present asks for another unconditionally: what is on screen is not what that frame drew.

A project under an editor (RemotePlugin) sleeps by default and is woken by its own socket. Between edits nothing simulates, so a frame nobody asked for is a core spent mirroring a still scene; Playing raises the pace for as long as Play lasts. The listener thread parks on a reply only the main thread can produce, which is why the wake is not optional — without it, an editor asking a perfectly healthy project a question would hang until something unrelated produced a frame.

Frames stopped being a clock

Anything that said “every N frames” was reading a clock that no longer ticks at a fixed rate. An idle editor draws roughly four frames a second, so “every thirtieth frame” went from half a second to seven and a half.

The remote snapshot pull was the one such cadence in the tree, and it is now expressed as a Duration. Any new cadence should be too — frame counts were always a stand-in for time, and they are no longer even a good one.

Input never waits

While the loop is idle, an input event is the only thing that will produce a frame, so every window event other than RedrawRequested asks for one. A WaitUntil deadline expiring reports through StartCause::ResumeTimeReached, and a cross-thread wake arrives as a winit user event — the proxy rather than request_redraw, because the proxy is the API documented to be callable from another thread.