Crafter.Graphics/shaders/ui-fused.comp.glsl
catbot ac9180076c perf(ui): hoist loop-invariant per-item math out of the per-pixel loop (#125)
The cooperative-load section already streams each item into shared memory
once per workgroup, but the per-pixel inner loop then recomputed values
that are constant for the whole item/glyph. The compiler can't hoist them
itself — they read shared memory at a varying index.

Precompute them once per item at load time into new shared slots:

  - ui-images / ui-text / ui-fused (images+text phases): invRectSize =
    1.0/rect.zw, turning the per-pixel `(sp-rect.xy)/rect.zw` vec2 divide
    into a multiply.
  - ui-text / ui-fused (text phase): the SDF AA `band` (a vec2 divide +
    two maxes depending only on the glyph's uv span and rect size).

In ui-fused the new slots (s_inv, s_band) are reused across phases like
the existing s_v* scratch, so the LDS bump is per-category, not summed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:22:07 +00:00

344 lines
15 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.

#version 460
#extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl"
// ─── fused UI uber-kernel (issue #47) ────────────────────────────────────
// One dispatch that composites up to four standard categories in canonical
// back-to-front order — quads → circles → images → text — into a single
// per-pixel register, loading the destination image ONCE and storing it ONCE.
// A run of consecutive Dispatch* calls would instead load+store the image per
// category and drain compute with a VkMemoryBarrier between each; this kernel
// collapses all of that to 1 load + 1 store + 0 inter-pass barriers.
//
// Each category keeps the exact cooperative shared-memory tile-cull + per-pixel
// accumulate of its standalone shader (see ui-quads/circles/images/text), so a
// fused category is pixel-identical to its standalone Dispatch* pass (the only
// difference is that intermediate results stay in full float precision between
// categories instead of round-tripping through the storage image — strictly
// more accurate). Categories run sequentially within each thread, so the VGPR
// high-water mark is ~max(per-category), not the sum.
//
// An absent category (itemCount 0) is a zero-trip, push-constant-uniform loop:
// no divergence, no memory traffic — using DispatchFused for only quads+text
// costs ~nothing for the unused circle/image phases.
//
// This is ADDITIVE: it has its own push-constant layout and does NOT touch the
// frozen 48-byte UIDispatchHeader or the per-element Dispatch* contract.
// Push-constant block. Mirrors Crafter::UIFusedHeader byte-for-byte: every
// member is vec4-aligned, no padding holes, exactly 128 bytes (the guaranteed
// push-constant minimum).
layout(push_constant) uniform PC {
uvec4 itemBuffers; // heap slots: (quads, circles, images, text)
uvec4 itemCounts; // item counts: (quads, circles, images, text)
uvec4 misc; // (outImage, fontTexture, fontSampler, flags)
uvec4 surface; // (surfaceWidth, surfaceHeight, frameIdx, _pad)
vec4 clipQuads;
vec4 clipCircles;
vec4 clipImages;
vec4 clipText;
} pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
// SDF tuning for the text phase — must match Crafter::FontAtlas constants
// (same values as ui-text.comp.glsl).
const float ON_EDGE = 128.0 / 255.0;
const float DIST_SCALE = 32.0;
// Per-category clip-active bits packed into pc.misc.w (the fused analogue of
// ui-shared.glsl's UI_FLAG_CLIP). A bit is set only when that category's clip
// rect is narrower than the surface; when clear, the category skips its four
// per-pixel clip compares entirely (the common full-surface case). The branch
// is push-constant-uniform, so it never diverges.
const uint UI_FUSED_CLIP_QUADS = 0x1u;
const uint UI_FUSED_CLIP_CIRCLES = 0x2u;
const uint UI_FUSED_CLIP_IMAGES = 0x4u;
const uint UI_FUSED_CLIP_TEXT = 0x8u;
// Generic per-chunk cooperative-cull scratch, REUSED across the four phases
// (they composite sequentially with a barrier between, so the storage is free
// once a phase's last read completes). Keeping one shared set instead of four
// keeps the shared-memory footprint — and thus occupancy — at the per-category
// level. Member mapping per phase:
// quads: v0=rect v1=color v2=corners v3=outline
// circles: v0=centerRadius v1=color v2=outline
// images: v0=rect v1=uv v2=tint v4=slots
// text: v0=rect v1=uv v2=color
shared vec4 s_v0[UI_CHUNK];
shared vec4 s_v1[UI_CHUNK];
shared vec4 s_v2[UI_CHUNK];
shared vec4 s_v3[UI_CHUNK];
shared uvec4 s_v4[UI_CHUNK];
// Per-item constants precomputed once at cooperative-load time, REUSED across
// phases like the s_v* scratch (the per-pixel inner loops read them instead of
// recomputing per pixel × item):
// images: s_inv = 1.0/rect.zw
// text: s_inv = 1.0/rect.zw, s_band = SDF AA scale (a divide + two maxes)
shared vec2 s_inv[UI_CHUNK];
shared float s_band[UI_CHUNK];
shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK];
shared uint s_count;
// Stable in-order compaction of one chunk's survivors. Lane 0 scans s_keep in
// buffer order so the inner per-pixel loop still sees items in draw order.
void uiCompactChunk(uint base, uint count) {
if (gl_LocalInvocationIndex == 0u) {
uint n = 0u;
uint lim = min(UI_CHUNK, count - base);
for (uint k = 0u; k < lim; ++k)
if (s_keep[k] != 0u) s_order[n++] = k;
s_count = n;
}
}
void main() {
// NOTE: do not early-return — every thread must reach the barriers below.
uvec2 pxu = gl_GlobalInvocationID.xy;
bool inSurface = pxu.x < pc.surface.x && pxu.y < pc.surface.y;
ivec2 screenPx = ivec2(pxu);
vec2 sp = vec2(screenPx) + 0.5;
// Single load of the destination for the whole surface.
vec4 dst = vec4(0.0);
if (inSurface) dst = imageLoad(uiImages[pc.misc.x], screenPx);
vec2 tileMin, tileMax;
uiTileBounds(tileMin, tileMax);
uint lid = gl_LocalInvocationIndex;
// ─── QUADS ────────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.x;
uint count = pc.itemCounts.x;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_QUADS) == 0u || uiPixelInClipRect(pxu, pc.clipQuads));
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiQuadHeap[heap].items[idx].rect;
s_v1[lid] = uiQuadHeap[heap].items[idx].color;
s_v2[lid] = uiQuadHeap[heap].items[idx].corners;
s_v3[lid] = uiQuadHeap[heap].items[idx].outline;
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
uiCompactChunk(base, count);
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_v0[c].xy;
vec2 hi = s_v0[c].xy + s_v0[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 halfSize = s_v0[c].zw * 0.5;
vec2 p = sp - (s_v0[c].xy + halfSize);
float d = uiSdRoundRect(p, halfSize, s_v2[c]);
vec4 outline = s_v3[c];
float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && outline.x <= 0.0) continue;
vec4 col = s_v1[c];
vec4 src = vec4(col.rgb, col.a * bodyA);
if (outline.x > 0.0) {
float t = abs(d + outline.x * 0.5) - outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, outline.yzw, outlineA);
src.a = max(src.a, outlineA);
}
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
}
barrier(); // done reading shared for this chunk before reuse
}
}
// ─── CIRCLES ──────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.y;
uint count = pc.itemCounts.y;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_CIRCLES) == 0u || uiPixelInClipRect(pxu, pc.clipCircles));
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiCircleHeap[heap].items[idx].centerRadius;
s_v1[lid] = uiCircleHeap[heap].items[idx].color;
s_v2[lid] = uiCircleHeap[heap].items[idx].outline;
float radius = s_v0[lid].z;
if (radius > 0.0) {
vec2 cen = s_v0[lid].xy;
vec2 r = vec2(radius + 1.0);
keep = uiAabbOverlapsTile(cen - r, cen + r, tileMin, tileMax);
}
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
uiCompactChunk(base, count);
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 center = s_v0[c].xy;
float radius = s_v0[c].z;
if (radius <= 0.0) continue;
if (abs(sp.x - center.x) > radius + 1.0) continue;
if (abs(sp.y - center.y) > radius + 1.0) continue;
float d = length(sp - center) - radius;
vec4 outline = s_v2[c];
float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && outline.x <= 0.0) continue;
vec4 col = s_v1[c];
vec4 src = vec4(col.rgb, col.a * bodyA);
if (outline.x > 0.0) {
float t = abs(d + outline.x * 0.5) - outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, outline.yzw, outlineA);
src.a = max(src.a, outlineA);
}
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
}
barrier();
}
}
// ─── IMAGES ───────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.z;
uint count = pc.itemCounts.z;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_IMAGES) == 0u || uiPixelInClipRect(pxu, pc.clipImages));
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiImageHeap[heap].items[idx].rect;
s_v1[lid] = uiImageHeap[heap].items[idx].uv;
s_v2[lid] = uiImageHeap[heap].items[idx].tint;
s_v4[lid] = uiImageHeap[heap].items[idx].slots;
s_inv[lid] = 1.0 / s_v0[lid].zw;
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
uiCompactChunk(base, count);
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_v0[c].xy;
vec2 hi = s_v0[c].xy + s_v0[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_v0[c].xy) * s_inv[c];
vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t);
uint texSlot = s_v4[c].x;
uint sampSlot = s_v4[c].y;
vec4 sampled = texture(
sampler2D(uiTextures[nonuniformEXT(texSlot)],
uiSamplers[nonuniformEXT(sampSlot)]),
uv
);
vec4 src = sampled * s_v2[c];
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
}
barrier();
}
}
// ─── TEXT ─────────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.w;
uint count = pc.itemCounts.w;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_TEXT) == 0u || uiPixelInClipRect(pxu, pc.clipText));
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiGlyphHeap[heap].items[idx].rect;
s_v1[lid] = uiGlyphHeap[heap].items[idx].uv;
s_v2[lid] = uiGlyphHeap[heap].items[idx].color;
s_inv[lid] = 1.0 / s_v0[lid].zw;
// SDF AA band — atlas-px per screen-px, constant per glyph
// (uvSpan * kAtlasSize(1024) / screenSpan), max'd to 1px floor.
vec2 uvSpan = s_v1[lid].zw - s_v1[lid].xy;
vec2 atlasPerScreen = (uvSpan * 1024.0) * s_inv[lid];
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
s_band[lid] = max(scalePx, 0.0001);
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
uiCompactChunk(base, count);
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_v0[c].xy;
vec2 hi = s_v0[c].xy + s_v0[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_v0[c].xy) * s_inv[c];
vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t);
// Font slots are push constants — provably dynamically uniform,
// so no nonuniformEXT (same as ui-text.comp.glsl).
float sdf = texture(
sampler2D(uiTextures[pc.misc.y],
uiSamplers[pc.misc.z]),
uv
).r;
float dAtlas = (ON_EDGE - sdf) * DIST_SCALE;
// band (the AA scale) was precomputed once per glyph at load.
float a = clamp(0.5 - dAtlas / s_band[c], 0.0, 1.0);
if (a <= 0.0) continue;
vec4 col = s_v2[c];
vec4 src = vec4(col.rgb, col.a * a);
dst = uiBlendOver(dst, src);
}
}
barrier();
}
}
if (inSurface) imageStore(uiImages[pc.misc.x], screenPx, dst);
}