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.

The swapchain image is asked for last

The game runtime’s frame is two halves that need very different things. The meshlet stage — cull, raster, shading, shadows, TAA, tonemap — draws into textures the engine owns and submits its own command buffer. Only the sky and the blit write to the surface.

So the surface image is acquired between them, and not before both:

record + submit the scene   ──►  get_current_texture()  ──►  sky, blit, present
        ~34 ms of GPU work         blocks on the compositor      ~0.65 ms

🔴 Acquiring first costs a full frame of overlap, and that is what this did until #837. get_current_texture blocks until the presentation engine releases an image, so asking for it before recording puts the whole CPU-side of the frame after the wait — and the GPU cannot start this frame’s work until the compositor has let go of the last one. Measured on the OneXFly: a median frame of 37.14 ms made of 34 ms of GPU and 3.006 ms of recording, added together rather than overlapped.

Nothing about the image is needed to record the scene. The dependency was in the control flow, not in the data.

⚠️ The editor already did this correctly, which is why the two paths look different: systems/present.rs tessellates the UI, uploads its textures and updates its buffers before acquiring, and the viewport passes run earlier still. Only the game runtime had the acquire on top.

Vsync is a setting, not only a variable

vsync lives in .rendersettings, beside the shading path and the render scale, and Presentation carries it to the surface through the same environment first, asset second rule the rest of quality uses: KOOCH_PRESENT_MODE=novsync overrides the file for one run, vsync overrides it back, and unset leaves the project’s choice alone.

🔴 It was an environment variable and nothing else until then, which meant a game built with Kóoch could not offer a vsync toggle — the one graphics option every game ships. Its own options menu would have had to ask the player to set an environment variable.

⚠️ vsync: false in a project file is a measurement configuration, not a performance setting. It costs a GPU drawing frames nobody sees, and on a handheld it costs battery for the same. It is off in exactly one case: somebody is reading a frame time and needs it to show work rather than the wait for the vblank.

GpuContext::set_vsync compares against the mode the surface already has before it reconfigures, because configure rebuilds the swapchain — applying the resource unconditionally would rebuild it once a frame. There is no headless surface, so no test in the suite watches that happen; what the tests pin is the precedence rule and the serde default, which is what every .rendersettings already on disk silently becomes.

What this does not fix

The frame-time distribution is bimodal on the OneXFly — the same GPU work produces a 34.7 ms frame and a 69.4 ms one — and this change does not address that.

Two explanations were on the table and both are now refuted, by three 30-second captures of one binary with one variable changed each:

latency 2latency 3novsync
frame/GPU p801.981.991.94
frame/GPU p902.492.502.21
vkAcquireNextImageKHR ms/frame35.20933.64637.162

A third swapchain image does not move the ratio by a hundredth. Neither does leaving FIFO.

🔴 An acquire of ~35 ms against a GPU of ~35 ms is not a defect. Being GPU-bound means the CPU waits somewhere, and get_current_texture is where. Reading that number as a symptom is a mistake this document used to make. What is genuinely unexplained is only the tail: the frames where the wait grows by 50 ms while our own GPU work grows by 2.

⚠️ A present mode is close to decorative when a compositor owns the display. These captures run under gamescope, which composites on the same GPU, on its own schedule, and is invisible to our scopes — they time our passes and nothing else. novsync turns off our vsync, not its. Whatever is left lives outside this process, and no environment variable on this side is going to find it.

The next measurement is not another engine knob. It is gamescope’s own frame statistics, or a run without gamescope at all.

And what the tail was hiding

GPU:          ~35 ms
budget:        13.9 ms

2.5x over, with a still camera and at half shading rate. If the tail vanished entirely the frame would still miss by more than double, and shade: compute (half rate) alone — 19.7 to 22.8 ms across the three captures — costs more than the whole frame is allowed.

The tail is a mystery in 30% of frames. The shading is 60% of every one of them.