perf(ui): cooperative shared-memory tile culling in UI compute shaders

Each 8×8-tile workgroup previously had all 64 threads walk the entire
item list, re-reading every item from the SSBO 64× and running the
per-pixel reject for items that never touch the tile — O(tiles × N)
item loads dominated by SSBO traffic.

The workgroup now streams the item list in chunks of 64: each thread
loads one item, tests its AABB against the whole tile, and the survivors
are compacted — in original buffer order — into shared memory. Every
thread then runs the unchanged per-pixel accumulate over only those
survivors. This drops SSBO traffic ~64× (each item read once per
workgroup instead of once per pixel) and shrinks the inner loop to the
items that actually overlap the tile.

Draw order is preserved exactly: chunks run in array order and the
in-chunk compaction is a stable in-order scan, so later items still
overdraw earlier ones (no atomic-append nondeterminism). The inner loop
body is byte-for-byte the original per-pixel logic, so output is
pixel-identical — the cull only decides which items reach it. Threads
no longer early-return so every thread reaches the workgroup barriers.

Applied to ui-quads, ui-circles, ui-images and ui-text; shared helpers
(uiTileBounds / uiAabbOverlapsTile) live in ui-shared.glsl.

Resolves #46

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-06-16 15:45:12 +00:00
commit 6eb11fbf71
5 changed files with 337 additions and 107 deletions

View file

@ -148,3 +148,38 @@ float uiSdRoundRect(vec2 p, vec2 halfSize, vec4 r) {
vec2 q = abs(p) - halfSize + r.x;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r.x;
}
// ─── shared-memory cooperative tile culling ─────────────────────────────
// Each shader still dispatches one 8×8 workgroup per screen tile, but rather
// than have all 64 threads walk the FULL item list — re-reading every item
// from the SSBO 64× and running the per-pixel reject for items that never
// touch the tile — the workgroup streams the list in chunks of 64. Each
// thread loads ONE item, tests its AABB against the whole tile, and the
// survivors are compacted (in original array order) into a shared list that
// every thread then iterates for its own pixel.
//
// This preserves draw order exactly: chunks run in array order and the
// in-chunk compaction is a stable in-order scan, so the per-pixel inner loop
// still sees items in buffer order (later overdraws earlier). It also drops
// SSBO traffic ~64× (each item read once per workgroup, not once per pixel)
// and shrinks the inner loop to only items that actually overlap the tile.
// The inner loop body is left byte-for-byte identical to the unculled path,
// so output is pixel-identical — the cull only decides which items reach it.
const uint UI_TILE_SIZE = 8u; // matches local_size_x / local_size_y
const uint UI_CHUNK = 64u; // = workgroup size (8×8); one item per thread
// This workgroup's tile bounds in pixels.
void uiTileBounds(out vec2 tileMin, out vec2 tileMax) {
tileMin = vec2(gl_WorkGroupID.xy * UI_TILE_SIZE);
tileMax = tileMin + vec2(float(UI_TILE_SIZE));
}
// Conservative AABB-vs-tile overlap. MUST be a superset of "some pixel in the
// tile passes the in-loop per-pixel reject" so an item a pixel needs is never
// dropped. Pixel centers live in (tileMin, tileMax), so testing the full tile
// box only ever keeps more, never fewer.
bool uiAabbOverlapsTile(vec2 lo, vec2 hi, vec2 tileMin, vec2 tileMax) {
return lo.x < tileMax.x && hi.x > tileMin.x
&& lo.y < tileMax.y && hi.y > tileMin.y;
}