Crafter.Graphics/shaders/ui-shared.glsl
catbot 574242dd30 perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47)
Add a fifth UI compute path — DispatchFused — alongside the four
per-element Dispatch* calls. It composites quads → circles → images →
text (canonical back-to-front order) in ONE dispatch that loads the
destination image once and stores once, eliminating the per-pass
load+store and the inter-pass VkMemoryBarriers a run of consecutive
Dispatch* calls would pay. The win materialises at >= 2 non-empty
categories; an absent category is a zero-trip, push-constant-uniform
loop (~free).

The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's
exact cooperative shared-memory tile-cull + per-pixel accumulate, so a
fused category is pixel-identical to its standalone pass. Categories run
sequentially per thread and share one set of shared-memory scratch, so
the VGPR/shared high-water mark stays at the per-category level, not the
sum.

Additive and non-breaking: the per-element Dispatch* calls and the
frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own
128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four
per-category clip vec4s). To interleave a custom ui.Dispatch() between
standard passes, bracket it with two DispatchFused calls — the dispatch
boundary is the explicit, app-declared flush point.

WebGPU (Vulkan-second): DispatchFused falls back to the per-element
Dispatch* calls in canonical order — one texture/sampler per dispatch
there can't feed the bindless image phase — so the result matches; only
the load/store fusion is Vulkan-only.

HelloUI now composites its background quads, mouse circle, and label
text in a single DispatchFused. Verified rendering identical on a live
Vulkan/Wayland session.

Resolves #47

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00

198 lines
10 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;
}
// Per-category clip test for the fused kernel (issue #47). The fused shader
// loads/stores the destination image once for the whole surface but each
// category carries its own clip rect, so the clip can only gate compositing —
// not the shared load/store. This mirrors the integer-pixel comparison
// uiResolveScreenPixel does (compare the pixel's top-left, not its center), so
// a fused category is pixel-identical to its standalone Dispatch* pass.
bool uiPixelInClipRect(uvec2 px, vec4 clip) {
if (float(px.x) < clip.x || float(px.y) < clip.y) return false;
if (float(px.x) >= clip.x + clip.z) return false;
if (float(px.y) >= clip.y + clip.w) return false;
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;
}