Redraw callback model #107

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

Context

Today the user drives the draw cycle: render → bj_present(renderer, window) → bj_dispatch_events, hot-looped. This works for game-style fixed-tick programs but is wrong for everything else — UI apps redraw needlessly, the framebuffer pointer is exposed directly (so the API is software-only forever), and there's no notion of "only this part changed."

Native windowing systems all use a damage-tracked redraw callback (Cocoa setNeedsDisplayInRect: / drawRect:, Win32 InvalidateRect + WM_PAINT, GTK draw signal, Web Canvas + rAF). Banjo should too, and the implementation must cover all backends, not just one.

Outcome

Three new public pieces, plus a backend sweep:

1. Opaque render-target type

struct bj_render_target;   /* in inc/banjo/renderer.h */

BANJO_EXPORT struct bj_bitmap*    bj_render_target_as_bitmap(struct bj_render_target*);
/* later: bj_gl_context* bj_render_target_as_gl(struct bj_render_target*); */

Software renderers expose a bj_bitmap* via the accessor; a future GL/Vulkan renderer hands back a context handle. Locks no backend in.

2. Redraw callback registered per window

typedef void (*bj_redraw_fn)(struct bj_window*, struct bj_render_target*, const struct bj_rect* dirty, void* user_data);

BANJO_EXPORT void bj_set_redraw_callback(struct bj_window*, bj_redraw_fn, void* user_data);

Banjo invokes this when it decides "draw now." dirty == NULL means whole window. The callback receives the accumulated damage rect, not whatever individual area triggered the last invalidate.

3. Dirty-rect invalidation

BANJO_EXPORT void bj_invalidate_rect(struct bj_window*, const struct bj_rect*);
BANJO_EXPORT void bj_invalidate_window(struct bj_window*);   /* static inline: bj_invalidate_rect(w, NULL) */

The backend keeps a per-window pending damage rect, unions incoming invalidations into it, and resets to "empty" after firing the callback. A NULL-rect invalidate promotes the union to whole-window and stays whole-window until the next callback fires.

Per-backend coverage

Every backend gets wired. No "Wayland-only, X11 no-op" half-measures.

Backend Union Tick Notes
fake trivial configurable / immediate Tests drive the callback synchronously.
Wayland ours wl_surface.frame callback Native fit; pairs with wl_buffer.release double-buffering (frame-pacing card).
X11 ours soft-paced timer Honor Expose events for OS-driven damage; soft-pace a timer for app-driven animation between Expose events.
Win32 OS WM_PAINT InvalidateRect unions for you (PAINTSTRUCT.rcPaint carries the union); fire the callback from WM_PAINT. Direct mapping.
Cocoa OS drawRect: cycle setNeedsDisplayInRect: unions; drawRect: fires on next display sync. Direct mapping.
Emscripten ours requestAnimationFrame JS wrapper unions per-frame, passes the rect to putImageData(buf, 0, 0, dx, dy, dw, dh).

Migration / breaking changes

  • bj_renderer_get_framebuffer removed from the public API. Backends still own a framebuffer; it's reached through bj_render_target_as_bitmap inside the redraw callback.
  • bj_present retained as a convenience that invalidates the whole window and pumps once. Idiomatic code uses the redraw callback instead.
  • The framebuffer persists between callback invocations — the app holds its model and repaints only the dirty region. Tutorials make this explicit, including "mark whole window dirty every frame" as the valid game-style pattern.
  • Missing redraw callback at the moment banjo wants to draw: bj_warn once per window, then silent.

Examples affected

  • start.c — canonical tutorial. Major rewrite to the callback shape.
  • load_bmp.c — perfect showcase. Static image, only redraws on Expose / damage. Demonstrates the power of partial damage.
  • drawing_2d.c, drawing_text.c, sprite_animation.c, pong.c, physics_*.c, bitmap_*.c — game-style; show "mark whole window dirty every frame" idiom.
  • event_callbacks.c — already callback-driven for input; redraw fits naturally.
  • template.c / template_callbacks.c — both rewritten; the callback template becomes the more prominent one.
  • window.c — basic open-a-window; switch to the new shape.

CI gap (honest)

.forgejo/workflows/ today only exercises Linux (linux-linux-gcc) and MinGW cross-compile (linux-windows-mingw). The Cocoa and Emscripten backend changes ship without CI coverage. Worth tracking as a separate issue to add macOS and Emscripten CI runs.

Out of scope

  • Frame pacing / vsync alignment — separate card.
  • Integer scaling / letterboxing of the framebuffer — issue #20 already covers this; the redraw path will respect whatever scaling is configured.
  • Resize handling — issue #22; resize will use bj_invalidate_window.
  • GL/Vulkan renderer — much later. The opaque bj_render_target makes it possible without breaking the API again.

Depends on / blocks

  • Depends on #10 (Application API) — setup is where users create a window and register its redraw callback.
  • Blocks the frame-pacing card (pacing is a per-backend tweak on top of this callback model).
  • Blocks #22 (resize uses bj_invalidate_window).
## Context Today the user drives the draw cycle: `render → bj_present(renderer, window) → bj_dispatch_events`, hot-looped. This works for game-style fixed-tick programs but is wrong for everything else — UI apps redraw needlessly, the framebuffer pointer is exposed directly (so the API is software-only forever), and there's no notion of "only this part changed." Native windowing systems all use a damage-tracked redraw callback (Cocoa `setNeedsDisplayInRect:` / `drawRect:`, Win32 `InvalidateRect` + WM_PAINT, GTK draw signal, Web Canvas + rAF). Banjo should too, and the implementation must cover **all backends**, not just one. ## Outcome Three new public pieces, plus a backend sweep: ### 1. Opaque render-target type ```c struct bj_render_target; /* in inc/banjo/renderer.h */ BANJO_EXPORT struct bj_bitmap* bj_render_target_as_bitmap(struct bj_render_target*); /* later: bj_gl_context* bj_render_target_as_gl(struct bj_render_target*); */ ``` Software renderers expose a `bj_bitmap*` via the accessor; a future GL/Vulkan renderer hands back a context handle. Locks no backend in. ### 2. Redraw callback registered per window ```c typedef void (*bj_redraw_fn)(struct bj_window*, struct bj_render_target*, const struct bj_rect* dirty, void* user_data); BANJO_EXPORT void bj_set_redraw_callback(struct bj_window*, bj_redraw_fn, void* user_data); ``` Banjo invokes this when it decides "draw now." `dirty == NULL` means whole window. The callback receives the accumulated damage rect, not whatever individual area triggered the last invalidate. ### 3. Dirty-rect invalidation ```c BANJO_EXPORT void bj_invalidate_rect(struct bj_window*, const struct bj_rect*); BANJO_EXPORT void bj_invalidate_window(struct bj_window*); /* static inline: bj_invalidate_rect(w, NULL) */ ``` The backend keeps a per-window pending damage rect, unions incoming invalidations into it, and resets to "empty" after firing the callback. A `NULL`-rect invalidate promotes the union to whole-window and stays whole-window until the next callback fires. ## Per-backend coverage Every backend gets wired. No "Wayland-only, X11 no-op" half-measures. | Backend | Union | Tick | Notes | |---|---|---|---| | **fake** | trivial | configurable / immediate | Tests drive the callback synchronously. | | **Wayland** | ours | `wl_surface.frame` callback | Native fit; pairs with `wl_buffer.release` double-buffering (frame-pacing card). | | **X11** | ours | soft-paced timer | Honor Expose events for OS-driven damage; soft-pace a timer for app-driven animation between Expose events. | | **Win32** | OS | WM_PAINT | `InvalidateRect` unions for you (`PAINTSTRUCT.rcPaint` carries the union); fire the callback from WM_PAINT. Direct mapping. | | **Cocoa** | OS | `drawRect:` cycle | `setNeedsDisplayInRect:` unions; `drawRect:` fires on next display sync. Direct mapping. | | **Emscripten** | ours | `requestAnimationFrame` | JS wrapper unions per-frame, passes the rect to `putImageData(buf, 0, 0, dx, dy, dw, dh)`. | ## Migration / breaking changes - `bj_renderer_get_framebuffer` removed from the public API. Backends still own a framebuffer; it's reached through `bj_render_target_as_bitmap` inside the redraw callback. - `bj_present` retained as a convenience that invalidates the whole window and pumps once. Idiomatic code uses the redraw callback instead. - The framebuffer **persists between callback invocations** — the app holds its model and repaints only the dirty region. Tutorials make this explicit, including "mark whole window dirty every frame" as the valid game-style pattern. - Missing redraw callback at the moment banjo wants to draw: `bj_warn` once per window, then silent. ## Examples affected - **`start.c`** — canonical tutorial. Major rewrite to the callback shape. - **`load_bmp.c`** — perfect showcase. Static image, only redraws on Expose / damage. Demonstrates the power of partial damage. - **`drawing_2d.c`, `drawing_text.c`, `sprite_animation.c`, `pong.c`, `physics_*.c`, `bitmap_*.c`** — game-style; show "mark whole window dirty every frame" idiom. - **`event_callbacks.c`** — already callback-driven for input; redraw fits naturally. - **`template.c` / `template_callbacks.c`** — both rewritten; the callback template becomes the more prominent one. - **`window.c`** — basic open-a-window; switch to the new shape. ## CI gap (honest) `.forgejo/workflows/` today only exercises Linux (`linux-linux-gcc`) and MinGW cross-compile (`linux-windows-mingw`). The Cocoa and Emscripten backend changes ship without CI coverage. Worth tracking as a separate issue to add macOS and Emscripten CI runs. ## Out of scope - Frame pacing / vsync alignment — separate card. - Integer scaling / letterboxing of the framebuffer — issue #20 already covers this; the redraw path will respect whatever scaling is configured. - Resize handling — issue #22; resize will use `bj_invalidate_window`. - GL/Vulkan renderer — much later. The opaque `bj_render_target` makes it possible without breaking the API again. ## Depends on / blocks - **Depends on #10** (Application API) — `setup` is where users create a window and register its redraw callback. - **Blocks** the frame-pacing card (pacing is a per-backend tweak on top of this callback model). - **Blocks #22** (resize uses `bj_invalidate_window`).
OragonEfreet added this to the 1.0 milestone 2026-05-28 11:06:41 +02:00
OragonEfreet added this to the (deleted) project 2026-05-28 11:06:41 +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#107
No description provided.