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.
6.5 KiB
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_dispatchevent-loop model.
Non-Goals:
- Optimizing
fill_rectitself (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, andsimulation_tickswaps whole buffers anyway, so per-set tracking would need extra bookkeeping after the swap. - Diffing
gridvsgrid_back(previous generation) — rejected:grid_backis 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_redrawstyle 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):
- 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. - 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.