Crafter.Graphics/shaders/ui-shared.glsl
catbot 6eb11fbf71 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>
2026-06-16 15:45:12 +00:00

185 lines
9.4 KiB
GLSL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Crafter.Graphics UI shader contract — shared by every standard UI compute
// shader and intended to be #included by user-authored shaders that want to
// dispatch alongside them. Layouts here are FROZEN: only additive changes
// (using the reserved `flags` bits or `_pad`).
#extension GL_EXT_shader_image_load_formatted : enable
#extension GL_EXT_shader_explicit_arithmetic_types_int16 : enable
#extension GL_EXT_descriptor_heap : enable
#extension GL_EXT_nonuniform_qualifier : enable
#extension GL_EXT_buffer_reference : enable
// ─── bindless heap declarations ─────────────────────────────────────────
// The same heap slot can be read as either uiImages[] (storage image) or
// uiTextures[] (sampled image) depending on which descriptor was written
// at that slot. Samplers live in a separate sampler heap.
layout(descriptor_heap) uniform image2D uiImages[];
layout(descriptor_heap) uniform texture2D uiTextures[];
layout(descriptor_heap) uniform sampler uiSamplers[];
// ─── push-constant header ───────────────────────────────────────────────
// Every UI dispatch's push-constant struct begins with this. User shaders
// MUST embed it as the first member so UIRenderer::FillHeader works.
struct UIDispatchHeader {
uint outImage; // heap slot of the swapchain image (this frame)
uint itemBuffer; // heap slot of the item SSBO
uvec2 surfaceSize; // window pixel size
vec4 clipRectPx; // (xy, wh) — every standard shader honors this
uint itemCount;
uint frameIdx;
uint flags; // user-defined feature bits
uint _pad; // reserved — keep zeroed
};
// ─── standard item structs ──────────────────────────────────────────────
// These match the C++ Crafter::QuadItem / CircleItem / ImageItem / GlyphItem
// byte-for-byte under std430.
struct QuadItem { vec4 rect; vec4 color; vec4 corners; vec4 outline; };
// rect = (x, y, w, h) in pixels
// color = filled body RGBA (premultiplied alpha not assumed)
// corners = per-corner radius in px (TL, TR, BR, BL); 0 = sharp
// outline = (thickness, R, G, B); thickness > 0 paints an outline of given color
struct CircleItem { vec4 centerRadius; vec4 color; vec4 outline; };
// centerRadius = (cx, cy, radius, _)
// outline.x = thickness (0 = filled), .yzw = outline RGB
struct ImageItem { vec4 rect; vec4 uv; vec4 tint; uvec4 slots; };
// rect = (x, y, w, h)
// uv = (u0, v0, u1, v1) into the source texture
// tint = multiplied with the sampled color
// slots = (textureHeapSlot, samplerHeapSlot, _, _)
struct GlyphItem { vec4 rect; vec4 uv; vec4 color; };
// rect = (x, y, w, h) on screen
// uv = (u0, v0, u1, v1) into the SDF font atlas
// color = glyph color (alpha modulated by SDF)
// ─── SSBO heap views ────────────────────────────────────────────────────
// One declaration per item type; each shader uses the one matching its
// dispatch. Indexed by hdr.itemBuffer.
layout(descriptor_heap, std430) readonly buffer UIQuadBuf { QuadItem items[]; } uiQuadHeap[];
layout(descriptor_heap, std430) readonly buffer UICircleBuf { CircleItem items[]; } uiCircleHeap[];
layout(descriptor_heap, std430) readonly buffer UIImageBuf { ImageItem items[]; } uiImageHeap[];
layout(descriptor_heap, std430) readonly buffer UIGlyphBuf { GlyphItem items[]; } uiGlyphHeap[];
// ──── Driver workaround: per-member SSBO load ────────────────────────────
// `UIItem it = itemHeap[idx].items[i]` emits an OpLoad of a composite type
// from a descriptor-heap'd SSBO, which crashes the GPU on the NVIDIA
// VK_EXT_descriptor_heap path (verified with a 1-float struct repro).
// Reading individual members works (each becomes OpAccessChain + scalar
// OpLoad). LoadItem reassembles the struct member-by-member into a local;
// the rest of the shader then operates on a regular local var.
ImageItem LoadImageItem(uint heap, uint i) {
ImageItem it;
it.rect = uiImageHeap[heap].items[i].rect;
it.uv = uiImageHeap[heap].items[i].uv;
it.tint = uiImageHeap[heap].items[i].tint;
it.slots = uiImageHeap[heap].items[i].slots;
return it;
}
GlyphItem LoadGlpyhtem(uint heap, uint i) {
GlyphItem it;
it.rect = uiGlyphHeap[heap].items[i].rect;
it.uv = uiGlyphHeap[heap].items[i].uv;
it.color = uiGlyphHeap[heap].items[i].color;
return it;
}
CircleItem LoadCircleItem(uint heap, uint i) {
CircleItem it;
it.centerRadius = uiCircleHeap[heap].items[i].centerRadius;
it.color = uiCircleHeap[heap].items[i].color;
it.outline = uiCircleHeap[heap].items[i].outline;
return it;
}
QuadItem LoadQuadItem(uint heap, uint i) {
QuadItem it;
it.rect = uiQuadHeap[heap].items[i].rect;
it.color = uiQuadHeap[heap].items[i].color;
it.corners = uiQuadHeap[heap].items[i].corners;
it.outline = uiQuadHeap[heap].items[i].outline;
return it;
}
// ─── pixel-tile dispatch model ─────────────────────────────────────────
// Standard shaders dispatch one workgroup per 8×8 screen tile. Each thread
// owns ONE pixel and iterates ALL items in order, accumulating the result
// in a local register, then stores once at the end. This guarantees correct
// z-order within a single dispatch (no inter-workgroup race on imageLoad/
// imageStore) and gives the user simple semantics: "items render in array
// order, later items overdraw earlier ones".
//
// Caller dispatches `(ceil(W/8), ceil(H/8), 1)` — no need to know the max
// item size.
// Returns the screen pixel and validates against the surface and clip rect.
bool uiResolveScreenPixel(UIDispatchHeader hdr, out ivec2 screenPx) {
uvec2 px = gl_GlobalInvocationID.xy;
if (px.x >= hdr.surfaceSize.x || px.y >= hdr.surfaceSize.y) return false;
if (float(px.x) < hdr.clipRectPx.x || float(px.y) < hdr.clipRectPx.y) return false;
if (float(px.x) >= hdr.clipRectPx.x + hdr.clipRectPx.z) return false;
if (float(px.y) >= hdr.clipRectPx.y + hdr.clipRectPx.w) return false;
screenPx = ivec2(px);
return true;
}
// Non-premultiplied "src over dst" blend. Both operands and result are
// straight-alpha vec4. Use this when iterating items in a loop with a local
// accumulator.
vec4 uiBlendOver(vec4 dst, vec4 src) {
float a = clamp(src.a, 0.0, 1.0);
vec3 outRGB = mix(dst.rgb, src.rgb, a);
float outA = a + dst.a * (1.0 - a);
return vec4(outRGB, outA);
}
// SDF for a rounded rect with per-corner radius. p is the point relative to
// the rect's center; halfSize is the rect half-extents; r is per-corner
// (TL, TR, BR, BL). Returns signed distance (negative inside).
float uiSdRoundRect(vec2 p, vec2 halfSize, vec4 r) {
// Pick the radius for the quadrant p is in.
r.xy = (p.x > 0.0) ? r.zy : r.wx; // pick TR/BR vs TL/BL
r.x = (p.y > 0.0) ? r.x : r.y;
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;
}