Files
arek 81a1b26842 perf(render): redraw on demand and only changed cells
Rendering used to clear the whole canvas and redraw every cell on
every frame callback (~60 fps) even though the simulation ticks at
20 tps and starts paused, keeping CPU usage at ~85% while idle.

Split simulation timing out of redraw() into the event loop and
render only when displayed content can have changed (tick, painting,
pattern placement, preview movement, grid-line toggle, resize);
idle frames commit only a new frame callback. Repaint incrementally
by diffing the grid against a rendered-state snapshot per row and
redrawing just the changed cells, with damage limited to the
repainted bounding box.

Idle CPU drops to ~1% of one core; running frames render at tick
rate (~20 fps) at microsecond cost. Adds grid_copy() snapshot
helper with tests and archives the optimize-render-on-demand change
with the new on-demand-rendering spec.
2026-11-08 20:11:55 +01:00

6.5 KiB
Raw Permalink Blame History

Design

Context

redraw() in wlgol.c currently does everything: accumulates time and runs simulation ticks, handles editing, clears the whole framebuffer, draws grid lines, scans all cells, and commits. It is invoked from wl_callack_done whenever the (single) buffer is released, i.e. at display refresh rate. The simulation ticks at ~20 tps and starts paused, so most frames are identical repaints. The app renders into one persistent SHM buffer and waits for its release before reattaching.

Goals / Non-Goals

Goals:

  • Near-zero CPU when paused and idle; render cost proportional to changed cells while running.
  • Keep the visible output identical to the current renderer.
  • Keep the blocking wl_display_dispatch event-loop model.

Non-Goals:

  • Optimizing fill_rect itself (SIMD-style row fills); a full repaint is now rare.
  • Double buffering, EGL/Vulkan, subsurfaces.
  • Changing simulation semantics, editing rules, or key bindings.

Decisions

1. Simulation timing moves out of the renderer into the event loop

Tick accumulation (sim_accumulator, simulation_tick()) runs once per loop iteration after dispatch, not inside redraw(). Rationale: if rendering is skipped, ticks must still occur or the simulation freezes. The event loop stays blocking on wl_display_dispatch; each frame callback (or input event) wakes it.

2. Idle = empty commit

When nothing is dirty, the loop attaches nothing and commits only a freshly requested wl_surface_frame callback. This keeps frame callbacks flowing (the loop stays event-driven) without giving the compositor new content. Alternative considered: a timer fd / sleep-based loop — rejected, it changes the event-loop model for no benefit since frame callbacks already provide the heartbeat.

3. Dirty sources, one flag each

A single screen_dirty flag plus a full_redraw flag, set by:

  • simulation tick applied → screen_dirty
  • grid modification (paint drag, click placement) → screen_dirty
  • pointer motion/crossing while active_tool >= 0 → screen_dirty
  • tool select / rotate / grid-line toggle → screen_dirty + full_redraw
  • need_resize → screen_dirty + full_redraw

Rationale: content has exactly five sources of truth (ticks, edits, preview, toggle, resize); covering all of them is what makes "skip the frame" safe. Forgetting the preview path is the classic bug here — without it the cursor preview would smear.

4. Diff against a rendered-state snapshot, not dirty-tracking in grid_set

Add grid_rendered (a third grid buffer alongside grid/grid_back). After each render, copy the current grid into it. At render time, diff row-by-row with memcmp (9216 bytes total — negligible) and repaint only differing cells. Alternatives:

  • Marking dirty cells inside grid_set — rejected: every call site becomes a correctness dependency, and simulation_tick swaps whole buffers anyway, so per-set tracking would need extra bookkeeping after the swap.
  • Diffing grid vs grid_back (previous generation) — rejected: grid_back is clobbered by the next tick's computation and misses edits made between ticks.

The snapshot approach is independent of why the grid changed, so ticks, painting, and pattern placement all work through one mechanism.

5. Per-cell repaint includes shared edges

Repainting cell (c, r) draws: background fill for the cell area, the cell body if alive, and the grid-line fragments on all four of its edges (x = c·cell_px and (c+1)·cell_px columns, y = r·cell_px and (r+1)·cell_px rows, clipped to the cell). When a neighbor didn't change, its edge gets redrawn identically — harmless. When it did change, it repaints itself. This avoids the "line between two cells vanishes" bug that cell-local-only edges would cause.

6. Preview = extra dirty cells, drawn last

The preview is overlay state, not grid state, so grid_rendered cannot track it. At render time, the dirty cell set is: diff(grid, grid_rendered) ∪ preview_cells(last frame) ∪ preview_cells(this frame). Store the last preview's cells (and whether it was shown) between frames. Preview cells are repainted (so the overlay disappears correctly) and the new overlay is drawn after all cell work, preserving the current draw order.

7. Keep the single buffer; gate real frames on release

Incremental rendering relies on the framebuffer surviving between frames — the existing single SHM buffer already provides that. When dirty, render only if the buffer is released; otherwise leave screen_dirty set and render on the next frame callback after release (same gating as today, just conditional). Alternative: double buffering + copying the front buffer — rejected as a bigger change for no additional benefit at this scale.

8. Damage = bounding box of repainted cells

wl_surface_damage accepts one rectangle per commit; use the bounding box of all cells repainted this frame (whole canvas for full redraws, empty for idle commits). Per-cell damage rects would need wl_surface_damage_buffer per rect — not worth the complexity for a SHM client where the compositor-side gain is small.

Risks / Trade-offs

  • [Empty-commit frame callbacks may throttle or behave inconsistently across compositors] → The heartbeat is only used for timing ticks and deferred renders; if a compositor stops calling back, the next input event still wakes the loop. Behavior on the reference compositor (existing setup) is verified manually.
  • [Preview drag while painting causes near-continuous rendering] → Accepted: that is genuinely changing content every frame, and per-frame cost is now proportional to the moved cells, not the canvas.
  • [FPS counter now counts rendered frames, not display refreshes] → Expected side effect of skipping idle frames; the log line semantics shift with this change.
  • [Diff covers whole grid on dense animations] → full_redraw style fallback is not needed; even a full-diff repaint via per-cell work stays correct, just slower. Revisit only if it shows in practice.

Migration Plan

Two independently verifiable stages, both internal to wlgol.c (plus a grid_rendered snapshot helper):

  1. Event-driven rendering: move tick timing out of redraw(), add dirty flags and the empty-commit idle path. Verify: paused idle CPU ≈ 0%, running still animates, editing/preview still update.
  2. Incremental repaint: add grid_rendered, diff, per-cell repaint, bounded damage. Verify: no visual regression vs stage 1 (grid lines intact at shared edges, preview clears correctly).

Rollback is a plain git revert — no data or protocol changes.

Open Questions

None.