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

Merged
catbot merged 2 commits from claude/issue-46 into master 2026-06-16 18:03:16 +02:00
5 changed files with 349 additions and 119 deletions

View file

@ -2,24 +2,71 @@
#extension GL_GOOGLE_include_directive : enable #extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl" #include "ui-shared.glsl"
// One workgroup per 8×8 screen tile. The workgroup cooperatively streams the
// CircleItem list in chunks of 64 (see ui-shared.glsl), culling each chunk
// against the tile and compacting survivors — in buffer order — into shared
// memory; every thread then accumulates over only those survivors.
layout(push_constant) uniform PC { layout(push_constant) uniform PC {
UIDispatchHeader hdr; UIDispatchHeader hdr;
} pc; } pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
shared vec4 s_centerRadius[UI_CHUNK];
shared vec4 s_color[UI_CHUNK];
shared vec4 s_outline[UI_CHUNK];
shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK];
shared uint s_count;
void main() { void main() {
ivec2 screenPx; ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return; bool valid = uiResolveScreenPixel(pc.hdr, screenPx);
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx); vec4 dst = vec4(0.0);
vec2 sp = vec2(screenPx) + 0.5; vec2 sp = vec2(0.0);
if (valid) {
dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
sp = vec2(screenPx) + 0.5;
}
for (uint i = 0u; i < pc.hdr.itemCount; ++i) { vec2 tileMin, tileMax;
CircleItem it = LoadCircleItem(pc.hdr.itemBuffer, i); uiTileBounds(tileMin, tileMax);
vec2 center = it.centerRadius.xy; uint lid = gl_LocalInvocationIndex;
float radius = it.centerRadius.z; for (uint base = 0u; base < pc.hdr.itemCount; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < pc.hdr.itemCount) {
s_centerRadius[lid] = uiCircleHeap[pc.hdr.itemBuffer].items[idx].centerRadius;
s_color[lid] = uiCircleHeap[pc.hdr.itemBuffer].items[idx].color;
s_outline[lid] = uiCircleHeap[pc.hdr.itemBuffer].items[idx].outline;
// Cull box matches the in-loop bounding-box reject (radius + 1px).
float radius = s_centerRadius[lid].z;
if (radius > 0.0) {
vec2 c = s_centerRadius[lid].xy;
vec2 r = vec2(radius + 1.0);
keep = uiAabbOverlapsTile(c - r, c + r, tileMin, tileMax);
}
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
if (lid == 0u) {
uint n = 0u;
uint lim = min(UI_CHUNK, pc.hdr.itemCount - base);
for (uint k = 0u; k < lim; ++k)
if (s_keep[k] != 0u) s_order[n++] = k;
s_count = n;
}
barrier();
if (valid) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 center = s_centerRadius[c].xy;
float radius = s_centerRadius[c].z;
if (radius <= 0.0) continue; if (radius <= 0.0) continue;
// Cheap bounding-box reject. // Cheap bounding-box reject.
@ -28,21 +75,26 @@ void main() {
float d = length(sp - center) - radius; float d = length(sp - center) - radius;
vec4 outline = s_outline[c];
float bodyA = clamp(0.5 - d, 0.0, 1.0); float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && it.outline.x <= 0.0) continue; if (bodyA <= 0.0 && outline.x <= 0.0) continue;
vec4 src = vec4(it.color.rgb, it.color.a * bodyA); vec4 col = s_color[c];
vec4 src = vec4(col.rgb, col.a * bodyA);
if (it.outline.x > 0.0) { if (outline.x > 0.0) {
float t = abs(d + it.outline.x * 0.5) - it.outline.x * 0.5; float t = abs(d + outline.x * 0.5) - outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0); float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, it.outline.yzw, outlineA); src.rgb = mix(src.rgb, outline.yzw, outlineA);
src.a = max(src.a, outlineA); src.a = max(src.a, outlineA);
} }
if (src.a <= 0.0) continue; if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src); dst = uiBlendOver(dst, src);
} }
}
barrier();
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst); if (valid) imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
} }

View file

@ -2,42 +2,89 @@
#extension GL_GOOGLE_include_directive : enable #extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl" #include "ui-shared.glsl"
// One workgroup per 8×8 screen tile. The workgroup cooperatively streams the
// ImageItem list in chunks of 64 (see ui-shared.glsl), culling each chunk
// against the tile and compacting survivors — in buffer order — into shared
// memory; every thread then accumulates over only those survivors.
layout(push_constant) uniform PC { layout(push_constant) uniform PC {
UIDispatchHeader hdr; UIDispatchHeader hdr;
} pc; } pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
shared vec4 s_rect[UI_CHUNK];
shared vec4 s_uv[UI_CHUNK];
shared vec4 s_tint[UI_CHUNK];
shared uvec4 s_slots[UI_CHUNK];
shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK];
shared uint s_count;
void main() { void main() {
ivec2 screenPx; ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return; bool valid = uiResolveScreenPixel(pc.hdr, screenPx);
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx); vec4 dst = vec4(0.0);
vec2 sp = vec2(screenPx) + 0.5; vec2 sp = vec2(0.0);
if (valid) {
dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
sp = vec2(screenPx) + 0.5;
}
for (uint i = 0u; i < pc.hdr.itemCount; ++i) { vec2 tileMin, tileMax;
ImageItem it = LoadImageItem(pc.hdr.itemBuffer, i); uiTileBounds(tileMin, tileMax);
vec2 lo = it.rect.xy; uint lid = gl_LocalInvocationIndex;
vec2 hi = it.rect.xy + it.rect.zw; for (uint base = 0u; base < pc.hdr.itemCount; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < pc.hdr.itemCount) {
s_rect[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].rect;
s_uv[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].uv;
s_tint[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].tint;
s_slots[lid] = uiImageHeap[pc.hdr.itemBuffer].items[idx].slots;
keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
if (lid == 0u) {
uint n = 0u;
uint lim = min(UI_CHUNK, pc.hdr.itemCount - base);
for (uint k = 0u; k < lim; ++k)
if (s_keep[k] != 0u) s_order[n++] = k;
s_count = n;
}
barrier();
if (valid) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_rect[c].xy;
vec2 hi = s_rect[c].xy + s_rect[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue; if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue; if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - it.rect.xy) / it.rect.zw; vec2 t = (sp - s_rect[c].xy) / s_rect[c].zw;
vec2 uv = mix(it.uv.xy, it.uv.zw, t); vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t);
uint texSlot = it.slots.x; uint texSlot = s_slots[c].x;
uint sampSlot = it.slots.y; uint sampSlot = s_slots[c].y;
vec4 sampled = texture( vec4 sampled = texture(
sampler2D(uiTextures[nonuniformEXT(texSlot)], sampler2D(uiTextures[nonuniformEXT(texSlot)],
uiSamplers[nonuniformEXT(sampSlot)]), uiSamplers[nonuniformEXT(sampSlot)]),
uv uv
); );
vec4 src = sampled * it.tint; vec4 src = sampled * s_tint[c];
if (src.a <= 0.0) continue; if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src); dst = uiBlendOver(dst, src);
} }
}
barrier();
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst); if (valid) imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
} }

View file

@ -2,50 +2,102 @@
#extension GL_GOOGLE_include_directive : enable #extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl" #include "ui-shared.glsl"
// One workgroup per 8×8 screen tile. Each thread owns one pixel and iterates // One workgroup per 8×8 screen tile. The workgroup cooperatively streams the
// every QuadItem in order, accumulating into a local dst register, so item // QuadItem list in chunks of 64 (see ui-shared.glsl), culling each chunk
// order in the buffer == draw order on screen (later items overdraw earlier). // against the tile and compacting survivors — in buffer order — into shared
// memory; every thread then runs the per-pixel accumulate over only those
// survivors. Item order in the buffer == draw order on screen.
layout(push_constant) uniform PC { layout(push_constant) uniform PC {
UIDispatchHeader hdr; UIDispatchHeader hdr;
} pc; } pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in; layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
// Per-chunk cooperative-cull scratch (one slot per workgroup thread).
shared vec4 s_rect[UI_CHUNK];
shared vec4 s_color[UI_CHUNK];
shared vec4 s_corners[UI_CHUNK];
shared vec4 s_outline[UI_CHUNK];
shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK];
shared uint s_count;
void main() { void main() {
// NOTE: do not early-return — every thread must reach the barriers below.
ivec2 screenPx; ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return; bool valid = uiResolveScreenPixel(pc.hdr, screenPx);
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx); vec4 dst = vec4(0.0);
vec2 sp = vec2(screenPx) + 0.5; vec2 sp = vec2(0.0);
if (valid) {
dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
sp = vec2(screenPx) + 0.5;
}
for (uint i = 0u; i < pc.hdr.itemCount; ++i) { vec2 tileMin, tileMax;
QuadItem it = LoadQuadItem(pc.hdr.itemBuffer, i); uiTileBounds(tileMin, tileMax);
uint lid = gl_LocalInvocationIndex;
for (uint base = 0u; base < pc.hdr.itemCount; base += UI_CHUNK) {
// Cooperative load: each thread reads one item member-by-member
// (the NVIDIA descriptor-heap workaround) into shared and culls it.
uint idx = base + lid;
bool keep = false;
if (idx < pc.hdr.itemCount) {
s_rect[lid] = uiQuadHeap[pc.hdr.itemBuffer].items[idx].rect;
s_color[lid] = uiQuadHeap[pc.hdr.itemBuffer].items[idx].color;
s_corners[lid] = uiQuadHeap[pc.hdr.itemBuffer].items[idx].corners;
s_outline[lid] = uiQuadHeap[pc.hdr.itemBuffer].items[idx].outline;
keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
// Stable in-order compaction of this chunk's survivors.
if (lid == 0u) {
uint n = 0u;
uint lim = min(UI_CHUNK, pc.hdr.itemCount - base);
for (uint k = 0u; k < lim; ++k)
if (s_keep[k] != 0u) s_order[n++] = k;
s_count = n;
}
barrier();
if (valid) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
// Cheap pre-test against the item's axis-aligned rect. // Cheap pre-test against the item's axis-aligned rect.
vec2 lo = it.rect.xy; vec2 lo = s_rect[c].xy;
vec2 hi = it.rect.xy + it.rect.zw; vec2 hi = s_rect[c].xy + s_rect[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue; if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue; if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 halfSize = it.rect.zw * 0.5; vec2 halfSize = s_rect[c].zw * 0.5;
vec2 p = sp - (it.rect.xy + halfSize); vec2 p = sp - (s_rect[c].xy + halfSize);
float d = uiSdRoundRect(p, halfSize, it.corners); float d = uiSdRoundRect(p, halfSize, s_corners[c]);
vec4 outline = s_outline[c];
float bodyA = clamp(0.5 - d, 0.0, 1.0); float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && it.outline.x <= 0.0) continue; if (bodyA <= 0.0 && outline.x <= 0.0) continue;
vec4 src = vec4(it.color.rgb, it.color.a * bodyA); vec4 col = s_color[c];
vec4 src = vec4(col.rgb, col.a * bodyA);
if (it.outline.x > 0.0) { if (outline.x > 0.0) {
float t = abs(d + it.outline.x * 0.5) - it.outline.x * 0.5; float t = abs(d + outline.x * 0.5) - outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0); float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, it.outline.yzw, outlineA); src.rgb = mix(src.rgb, outline.yzw, outlineA);
src.a = max(src.a, outlineA); src.a = max(src.a, outlineA);
} }
if (src.a <= 0.0) continue; if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src); dst = uiBlendOver(dst, src);
} }
}
barrier(); // done reading shared for this chunk before it's overwritten
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst); if (valid) imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
} }

View file

@ -148,3 +148,38 @@ float uiSdRoundRect(vec2 p, vec2 halfSize, vec4 r) {
vec2 q = abs(p) - halfSize + r.x; vec2 q = abs(p) - halfSize + r.x;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - 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;
}

View file

@ -2,8 +2,10 @@
#extension GL_GOOGLE_include_directive : enable #extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl" #include "ui-shared.glsl"
// One workgroup per 8×8 screen tile. Iterates every glyph in order; each // One workgroup per 8×8 screen tile. The workgroup cooperatively streams the
// pixel keeps a local accumulator so order in the buffer == draw order. // GlyphItem list in chunks of 64 (see ui-shared.glsl), culling each chunk
// against the tile and compacting survivors — in buffer order — into shared
// memory; every thread then accumulates over only those survivors.
layout(push_constant) uniform PC { layout(push_constant) uniform PC {
UIDispatchHeader hdr; UIDispatchHeader hdr;
uint fontTextureSlot; uint fontTextureSlot;
@ -18,23 +20,61 @@ layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
const float ON_EDGE = 128.0 / 255.0; const float ON_EDGE = 128.0 / 255.0;
const float DIST_SCALE = 32.0; const float DIST_SCALE = 32.0;
shared vec4 s_rect[UI_CHUNK];
shared vec4 s_uv[UI_CHUNK];
shared vec4 s_color[UI_CHUNK];
shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK];
shared uint s_count;
void main() { void main() {
ivec2 screenPx; ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return; bool valid = uiResolveScreenPixel(pc.hdr, screenPx);
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx); vec4 dst = vec4(0.0);
vec2 sp = vec2(screenPx) + 0.5; vec2 sp = vec2(0.0);
if (valid) {
dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
sp = vec2(screenPx) + 0.5;
}
for (uint i = 0u; i < pc.hdr.itemCount; ++i) { vec2 tileMin, tileMax;
GlyphItem it = LoadGlpyhtem(pc.hdr.itemBuffer, i); uiTileBounds(tileMin, tileMax);
vec2 lo = it.rect.xy; uint lid = gl_LocalInvocationIndex;
vec2 hi = it.rect.xy + it.rect.zw; for (uint base = 0u; base < pc.hdr.itemCount; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < pc.hdr.itemCount) {
s_rect[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].rect;
s_uv[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].uv;
s_color[lid] = uiGlyphHeap[pc.hdr.itemBuffer].items[idx].color;
keep = uiAabbOverlapsTile(s_rect[lid].xy, s_rect[lid].xy + s_rect[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
if (lid == 0u) {
uint n = 0u;
uint lim = min(UI_CHUNK, pc.hdr.itemCount - base);
for (uint k = 0u; k < lim; ++k)
if (s_keep[k] != 0u) s_order[n++] = k;
s_count = n;
}
barrier();
if (valid) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_rect[c].xy;
vec2 hi = s_rect[c].xy + s_rect[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue; if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue; if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - it.rect.xy) / it.rect.zw; vec2 t = (sp - s_rect[c].xy) / s_rect[c].zw;
vec2 uv = mix(it.uv.xy, it.uv.zw, t); vec2 uv = mix(s_uv[c].xy, s_uv[c].zw, t);
// Font slots are push constants — provably dynamically uniform, so no // Font slots are push constants — provably dynamically uniform, so no
// nonuniformEXT: lets the compiler hoist the descriptor load out of the // nonuniformEXT: lets the compiler hoist the descriptor load out of the
@ -50,9 +90,9 @@ void main() {
// Atlas-px per screen-px along this glyph's transform — keeps AA crisp // Atlas-px per screen-px along this glyph's transform — keeps AA crisp
// at any rendering size. uvSpan * atlasSize / screenSpan. // at any rendering size. uvSpan * atlasSize / screenSpan.
vec2 uvSpan = it.uv.zw - it.uv.xy; vec2 uvSpan = s_uv[c].zw - s_uv[c].xy;
// FontAtlas::kAtlasSize = 1024. // FontAtlas::kAtlasSize = 1024.
vec2 atlasPerScreen = (uvSpan * 1024.0) / it.rect.zw; vec2 atlasPerScreen = (uvSpan * 1024.0) / s_rect[c].zw;
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y); float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
// 1-screen-px AA band, expressed in atlas-pixel units of dAtlas. // 1-screen-px AA band, expressed in atlas-pixel units of dAtlas.
float band = max(scalePx, 0.0001); float band = max(scalePx, 0.0001);
@ -60,9 +100,13 @@ void main() {
float a = clamp(0.5 - dAtlas / band, 0.0, 1.0); float a = clamp(0.5 - dAtlas / band, 0.0, 1.0);
if (a <= 0.0) continue; if (a <= 0.0) continue;
vec4 src = vec4(it.color.rgb, it.color.a * a); vec4 col = s_color[c];
vec4 src = vec4(col.rgb, col.a * a);
dst = uiBlendOver(dst, src); dst = uiBlendOver(dst, src);
} }
}
barrier();
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst); if (valid) imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
} }