Frame pacing #108

Closed
opened 2026-05-28 11:10:23 +02:00 by OragonEfreet · 0 comments
Owner

Context

Banjo example loops run flat-out today. Two distinct problems hide behind that:

  1. The application loop has no timing model. The step callback runs as fast as the CPU allows, examples paper over it with bj_sleep(15). Physics demos manually compute dt from a stopwatch; pong has to reinvent frame-rate-independent motion. There's no separation between simulation logic that should run at a fixed rate (physics, network ticks, AI) and per-frame logic that should ride the display.

  2. Backends mostly self-throttle now, after #107: Wayland fires from wl_surface.frame, X11 has a 60Hz soft-pace, Emscripten uses requestAnimationFrame, Win32 fires from WM_PAINT, Cocoa fires from drawRect:. What's still missing: Wayland needs proper wl_buffer.release + double-buffering; Win32 has no vsync between paints (animation in step still busy-waits); Cocoa would benefit from CADisplayLink for animation pacing; the fake backend has no configurable tick for tests.

This card delivers both: the application-loop redesign first (so users have a clean way to express "physics at 60Hz, render at vsync"), then the per-backend pacing tweaks on top.

Scope 1 — Application loop redesign

Two callback slots managed by bj_app_run, internally a Glenn Fiedler accumulator. Inspired by Unity (Update + FixedUpdate) and Godot (_process + _physics_process). See https://gafferongames.com/post/fix_your_timestep/.

Public API additions

One unified bj_tick_info struct, passed by value to both step callbacks. Small enough (16 bytes) to ride in registers on every ABI Banjo targets, so no per-iteration allocation question to worry about.

typedef struct {
    bj_real delta;   /* seconds since previous call of this callback;
                        in fixed_step, always equals 1/hz */
    bj_real alpha;   /* [0, 1) interpolation factor; always 0 in fixed_step */
} bj_tick_info;

typedef void (*bj_app_step_fn)(
    struct bj_app* app,
    bj_tick_info   tick,
    void*          user_data
);

typedef void (*bj_app_fixed_step_fn)(
    struct bj_app* app,
    bj_tick_info   tick,
    void*          user_data
);

BANJO_EXPORT void bj_app_set_fixed_step_callback(
    struct bj_app*       app,
    bj_app_fixed_step_fn fn,
    void*                user_data
);

/* Default: 60 Hz. Call before bj_app_run. */
BANJO_EXPORT void bj_app_set_fixed_step_rate(struct bj_app* app, int hz);

/* Accessor for when the user needs the configured rate outside a callback
   (e.g. setup-time calculations). Returns 60 by default. */
BANJO_EXPORT int  bj_app_fixed_step_rate(const struct bj_app* app);

Breaking changes

bj_app_step_fn gains the bj_tick_info parameter. All 18 examples migrate. bj_app_setup_fn and bj_app_teardown_fn signatures don't change (no clock context to carry there).

Internal flow (Fiedler accumulator)

while (!quit) {
    real_dt = clock_now() - last_clock;
    real_dt = min(real_dt, DT_CAP);            /* spiral-of-death guard */
    accumulator += real_dt;
    while (accumulator >= fixed_dt) {
        if (fixed_step) {
            bj_tick_info t = { .delta = fixed_dt, .alpha = 0 };
            fixed_step(app, t, user_data);
        }
        accumulator -= fixed_dt;
    }
    if (step) {
        bj_tick_info t = { .delta = real_dt, .alpha = accumulator / fixed_dt };
        step(app, t, user_data);
    }
    /* draw callbacks fire from the backend on their own cadence */
}

Why alpha is on the struct but always 0 in fixed_step: render-time is where you interpolate physics state (pos_rendered = lerp(pos_prev, pos_current, alpha)), so the value is meaningful for step only. Putting it on the unified struct keeps one type to remember; fixed_step ignoring its zero is harmless.

Examples migrating to use fixed_step

  • pong.c — physics moves to fixed_step; the existing bj_step_delay_stopwatch + clamped_dt plumbing goes away.
  • physics_kinematics.c, physics_particle.c — same.

Tests

  • Extend unit_app with cases asserting fixed_step fires the right number of times for a given simulated dt; assert step's delta is monotonic; assert fixed-rate setter takes effect.

Scope 2 — Per-backend pacing tweaks

The per-backend tick is already mostly aligned with platform refresh after #107. What's left:

Backend Status post-#107 Remaining work
Wayland wl_surface.frame callback already drives draw Add wl_buffer.release listener; double-buffer (two wl_buffers, two shm fds); attach the one not held by the compositor. Closes the buffer-reuse correctness issue.
X11 Soft-paced timer at ~60 Hz Probably nothing — verify that the new accumulator + soft-pace play nicely.
Win32 Fires from WM_PAINT Add DwmFlush() (or Sleep(refresh_period - elapsed)) inside step for animation-driven invalidate cycles.
Cocoa Fires from drawRect: Optional: switch to CADisplayLink so animation cadence matches the display, not the run-loop.
Emscripten Already uses requestAnimationFrame Nothing.
fake Fires synchronously from poll_events Add bj_fake_set_tick_interval(ns) for tests. (Optional; depends on whether the accumulator tests need it.)

Acceptance criteria

  • examples/pong.c reads cleanly: update moves to fixed_step, no manual bj_step_delay_stopwatch, no clamped_dt plumbing.
  • CPU usage on examples/start matches refresh rate on Wayland/Emscripten/Win32 (real vsync) and is bounded by the soft-pace on X11.
  • unit_app covers the fixed-step accumulator semantics.
  • No DT_NEEDED regression on Wayland (wl_buffer.release routes through existing wl_proxy_* dlsym infrastructure).
  • The placeholder-buffer "leak" wl_surface.frame lives with today is properly released.

Out of scope

  • Hard real-time guarantees.
  • GL/EGL vsync via eglSwapInterval — separate card, lands when the GL renderer does.
  • Variable refresh rate / adaptive sync.
  • Triple buffering — double-buffer is the Wayland deliverable.
  • bj_get_last_frame_ns and similar frame-time introspection — separate API.
  • LateUpdate-style third callback — Banjo doesn't need it at its size.

CI gap

Same as #107: Cocoa and Emscripten ship without automated tests. Tracked in the "add macOS + Emscripten CI workflows" card.

Depends on

  • #107 (draw-callback model) — merged.
  • References Fiedler's "Fix Your Timestep!" article for the accumulator pattern.
## Context Banjo example loops run flat-out today. Two distinct problems hide behind that: 1. **The application loop has no timing model.** The `step` callback runs as fast as the CPU allows, examples paper over it with `bj_sleep(15)`. Physics demos manually compute `dt` from a stopwatch; pong has to reinvent frame-rate-independent motion. There's no separation between simulation logic that should run at a fixed rate (physics, network ticks, AI) and per-frame logic that should ride the display. 2. **Backends mostly self-throttle now**, after #107: Wayland fires from `wl_surface.frame`, X11 has a 60Hz soft-pace, Emscripten uses `requestAnimationFrame`, Win32 fires from `WM_PAINT`, Cocoa fires from `drawRect:`. What's *still* missing: Wayland needs proper `wl_buffer.release` + double-buffering; Win32 has no vsync between paints (animation in step still busy-waits); Cocoa would benefit from `CADisplayLink` for animation pacing; the fake backend has no configurable tick for tests. This card delivers both: the application-loop redesign first (so users have a clean way to express "physics at 60Hz, render at vsync"), then the per-backend pacing tweaks on top. ## Scope 1 — Application loop redesign Two callback slots managed by `bj_app_run`, internally a Glenn Fiedler accumulator. Inspired by Unity (`Update` + `FixedUpdate`) and Godot (`_process` + `_physics_process`). See <https://gafferongames.com/post/fix_your_timestep/>. ### Public API additions One unified `bj_tick_info` struct, passed **by value** to both step callbacks. Small enough (16 bytes) to ride in registers on every ABI Banjo targets, so no per-iteration allocation question to worry about. ```c typedef struct { bj_real delta; /* seconds since previous call of this callback; in fixed_step, always equals 1/hz */ bj_real alpha; /* [0, 1) interpolation factor; always 0 in fixed_step */ } bj_tick_info; typedef void (*bj_app_step_fn)( struct bj_app* app, bj_tick_info tick, void* user_data ); typedef void (*bj_app_fixed_step_fn)( struct bj_app* app, bj_tick_info tick, void* user_data ); BANJO_EXPORT void bj_app_set_fixed_step_callback( struct bj_app* app, bj_app_fixed_step_fn fn, void* user_data ); /* Default: 60 Hz. Call before bj_app_run. */ BANJO_EXPORT void bj_app_set_fixed_step_rate(struct bj_app* app, int hz); /* Accessor for when the user needs the configured rate outside a callback (e.g. setup-time calculations). Returns 60 by default. */ BANJO_EXPORT int bj_app_fixed_step_rate(const struct bj_app* app); ``` ### Breaking changes `bj_app_step_fn` gains the `bj_tick_info` parameter. All 18 examples migrate. `bj_app_setup_fn` and `bj_app_teardown_fn` signatures **don't change** (no clock context to carry there). ### Internal flow (Fiedler accumulator) ```c while (!quit) { real_dt = clock_now() - last_clock; real_dt = min(real_dt, DT_CAP); /* spiral-of-death guard */ accumulator += real_dt; while (accumulator >= fixed_dt) { if (fixed_step) { bj_tick_info t = { .delta = fixed_dt, .alpha = 0 }; fixed_step(app, t, user_data); } accumulator -= fixed_dt; } if (step) { bj_tick_info t = { .delta = real_dt, .alpha = accumulator / fixed_dt }; step(app, t, user_data); } /* draw callbacks fire from the backend on their own cadence */ } ``` Why `alpha` is on the struct but always 0 in fixed_step: render-time is where you interpolate physics state (`pos_rendered = lerp(pos_prev, pos_current, alpha)`), so the value is meaningful for `step` only. Putting it on the unified struct keeps one type to remember; fixed_step ignoring its zero is harmless. ### Examples migrating to use fixed_step - `pong.c` — physics moves to `fixed_step`; the existing `bj_step_delay_stopwatch` + `clamped_dt` plumbing goes away. - `physics_kinematics.c`, `physics_particle.c` — same. ### Tests - Extend `unit_app` with cases asserting fixed_step fires the right number of times for a given simulated dt; assert step's delta is monotonic; assert fixed-rate setter takes effect. ## Scope 2 — Per-backend pacing tweaks The per-backend tick is already mostly aligned with platform refresh after #107. What's left: | Backend | Status post-#107 | Remaining work | |---|---|---| | **Wayland** | `wl_surface.frame` callback already drives draw | Add `wl_buffer.release` listener; double-buffer (two `wl_buffer`s, two shm fds); attach the one not held by the compositor. Closes the buffer-reuse correctness issue. | | **X11** | Soft-paced timer at ~60 Hz | Probably nothing — verify that the new accumulator + soft-pace play nicely. | | **Win32** | Fires from `WM_PAINT` | Add `DwmFlush()` (or `Sleep(refresh_period - elapsed)`) inside step for animation-driven invalidate cycles. | | **Cocoa** | Fires from `drawRect:` | Optional: switch to `CADisplayLink` so animation cadence matches the display, not the run-loop. | | **Emscripten** | Already uses `requestAnimationFrame` | Nothing. | | **fake** | Fires synchronously from `poll_events` | Add `bj_fake_set_tick_interval(ns)` for tests. (Optional; depends on whether the accumulator tests need it.) | ## Acceptance criteria - `examples/pong.c` reads cleanly: `update` moves to `fixed_step`, no manual `bj_step_delay_stopwatch`, no `clamped_dt` plumbing. - CPU usage on `examples/start` matches refresh rate on Wayland/Emscripten/Win32 (real vsync) and is bounded by the soft-pace on X11. - `unit_app` covers the fixed-step accumulator semantics. - No DT_NEEDED regression on Wayland (`wl_buffer.release` routes through existing `wl_proxy_*` dlsym infrastructure). - The placeholder-buffer "leak" `wl_surface.frame` lives with today is properly released. ## Out of scope - Hard real-time guarantees. - GL/EGL vsync via `eglSwapInterval` — separate card, lands when the GL renderer does. - Variable refresh rate / adaptive sync. - Triple buffering — double-buffer is the Wayland deliverable. - `bj_get_last_frame_ns` and similar frame-time introspection — separate API. - `LateUpdate`-style third callback — Banjo doesn't need it at its size. ## CI gap Same as #107: Cocoa and Emscripten ship without automated tests. Tracked in the "add macOS + Emscripten CI workflows" card. ## Depends on - #107 (draw-callback model) — merged. - References Fiedler's "Fix Your Timestep!" article for the accumulator pattern.
OragonEfreet added this to the 1.0 milestone 2026-05-28 11:10:23 +02:00
OragonEfreet added this to the (deleted) project 2026-05-28 11:10:23 +02:00
Sign in to join this conversation.
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
OragonEfreet/banjo#108
No description provided.