UI rewrite 3rd attempt

This commit is contained in:
Jorijn van der Graaf 2026-05-02 21:08:20 +02:00
commit 1f5697326c
48 changed files with 2155 additions and 6190 deletions

View file

@ -0,0 +1,48 @@
#version 460
#extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl"
layout(push_constant) uniform PC {
UIDispatchHeader hdr;
} pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
void main() {
ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return;
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
vec2 sp = vec2(screenPx) + 0.5;
for (uint i = 0u; i < pc.hdr.itemCount; ++i) {
CircleItem it = LoadCircleItem(pc.hdr.itemBuffer, i);
vec2 center = it.centerRadius.xy;
float radius = it.centerRadius.z;
if (radius <= 0.0) continue;
// Cheap bounding-box reject.
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;
float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && it.outline.x <= 0.0) continue;
vec4 src = vec4(it.color.rgb, it.color.a * bodyA);
if (it.outline.x > 0.0) {
float t = abs(d + it.outline.x * 0.5) - it.outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, it.outline.yzw, outlineA);
src.a = max(src.a, outlineA);
}
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
}

View file

@ -0,0 +1,43 @@
#version 460
#extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl"
layout(push_constant) uniform PC {
UIDispatchHeader hdr;
} pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
void main() {
ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return;
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
vec2 sp = vec2(screenPx) + 0.5;
for (uint i = 0u; i < pc.hdr.itemCount; ++i) {
ImageItem it = LoadImageItem(pc.hdr.itemBuffer, i);
vec2 lo = it.rect.xy;
vec2 hi = it.rect.xy + it.rect.zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - it.rect.xy) / it.rect.zw;
vec2 uv = mix(it.uv.xy, it.uv.zw, t);
uint texSlot = it.slots.x;
uint sampSlot = it.slots.y;
vec4 sampled = texture(
sampler2D(uiTextures[nonuniformEXT(texSlot)],
uiSamplers[nonuniformEXT(sampSlot)]),
uv
);
vec4 src = sampled * it.tint;
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
}

View file

@ -0,0 +1,51 @@
#version 460
#extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl"
// One workgroup per 8×8 screen tile. Each thread owns one pixel and iterates
// every QuadItem in order, accumulating into a local dst register, so item
// order in the buffer == draw order on screen (later items overdraw earlier).
layout(push_constant) uniform PC {
UIDispatchHeader hdr;
} pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
void main() {
ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return;
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
vec2 sp = vec2(screenPx) + 0.5;
for (uint i = 0u; i < pc.hdr.itemCount; ++i) {
QuadItem it = LoadQuadItem(pc.hdr.itemBuffer, i);
// Cheap pre-test against the item's axis-aligned rect.
vec2 lo = it.rect.xy;
vec2 hi = it.rect.xy + it.rect.zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 halfSize = it.rect.zw * 0.5;
vec2 p = sp - (it.rect.xy + halfSize);
float d = uiSdRoundRect(p, halfSize, it.corners);
float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && it.outline.x <= 0.0) continue;
vec4 src = vec4(it.color.rgb, it.color.a * bodyA);
if (it.outline.x > 0.0) {
float t = abs(d + it.outline.x * 0.5) - it.outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, it.outline.yzw, outlineA);
src.a = max(src.a, outlineA);
}
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
}

150
shaders/ui-shared.glsl Normal file
View file

@ -0,0 +1,150 @@
// 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;
}

65
shaders/ui-text.comp.glsl Normal file
View file

@ -0,0 +1,65 @@
#version 460
#extension GL_GOOGLE_include_directive : enable
#include "ui-shared.glsl"
// One workgroup per 8×8 screen tile. Iterates every glyph in order; each
// pixel keeps a local accumulator so order in the buffer == draw order.
layout(push_constant) uniform PC {
UIDispatchHeader hdr;
uint fontTextureSlot;
uint fontSamplerSlot;
uint _p0;
uint _p1;
} pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
// SDF tuning — must match Crafter::FontAtlas::kOnEdgeValue / kPixelDistScale.
const float ON_EDGE = 128.0 / 255.0;
const float DIST_SCALE = 32.0;
void main() {
ivec2 screenPx;
if (!uiResolveScreenPixel(pc.hdr, screenPx)) return;
vec4 dst = imageLoad(uiImages[pc.hdr.outImage], screenPx);
vec2 sp = vec2(screenPx) + 0.5;
for (uint i = 0u; i < pc.hdr.itemCount; ++i) {
GlyphItem it = LoadGlpyhtem(pc.hdr.itemBuffer, i);
vec2 lo = it.rect.xy;
vec2 hi = it.rect.xy + it.rect.zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - it.rect.xy) / it.rect.zw;
vec2 uv = mix(it.uv.xy, it.uv.zw, t);
float sdf = texture(
sampler2D(uiTextures[nonuniformEXT(pc.fontTextureSlot)],
uiSamplers[nonuniformEXT(pc.fontSamplerSlot)]),
uv
).r;
// Distance in atlas-pixels (negative inside the glyph).
float dAtlas = (ON_EDGE - sdf) * DIST_SCALE;
// Atlas-px per screen-px along this glyph's transform — keeps AA crisp
// at any rendering size. uvSpan * atlasSize / screenSpan.
vec2 uvSpan = it.uv.zw - it.uv.xy;
// FontAtlas::kAtlasSize = 1024.
vec2 atlasPerScreen = (uvSpan * 1024.0) / it.rect.zw;
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
// 1-screen-px AA band, expressed in atlas-pixel units of dAtlas.
float band = max(scalePx, 0.0001);
float a = clamp(0.5 - dAtlas / band, 0.0, 1.0);
if (a <= 0.0) continue;
vec4 src = vec4(it.color.rgb, it.color.a * a);
dst = uiBlendOver(dst, src);
}
imageStore(uiImages[pc.hdr.outImage], screenPx, dst);
}

View file

@ -1,192 +0,0 @@
#version 460
#extension GL_EXT_descriptor_heap : enable
#extension GL_EXT_nonuniform_qualifier : enable
#extension GL_EXT_scalar_block_layout : enable
#extension GL_EXT_shader_image_load_formatted : enable
#extension GL_EXT_shader_explicit_arithmetic_types_int16 : enable
layout(local_size_x = 16, local_size_y = 16) in;
// ──── Item types — must match UI::ItemType in UIDrawList.cppm ────────────
const uint TYPE_RECT = 0u;
const uint TYPE_ROUND_RECT = 1u;
const uint TYPE_GLYPH = 2u;
const uint TYPE_IMAGE = 3u;
const uint TYPE_CLIP_PUSH = 5u;
const uint TYPE_CLIP_POP = 6u;
#define MAX_CLIP_DEPTH 8
// ──── Draw item — must match UI::UIItem layout (88 bytes, scalar) ────────
struct UIItem {
uint itype;
uint flags;
vec2 posPx;
vec2 sizePx;
vec4 color;
vec4 colorB;
vec4 uvRect;
uint imageIdx;
uint cornerRadiusPx;
vec2 reserved;
};
// ──── Bindless heap views — VK_EXT_descriptor_heap untyped model ─────────
// Each `layout(descriptor_heap)` declaration is a typed view over the same
// resource heap; indexing is in slot units (image-descriptor units for
// image2D, buffer-descriptor units for buffers, etc.). The application
// passes the absolute heap slot indices via push constants.
layout(descriptor_heap, scalar) readonly buffer UIItemBuf {
UIItem items[];
} itemHeap[];
layout(descriptor_heap) uniform image2D images[];
layout(descriptor_heap) uniform texture2D textures[];
layout(descriptor_heap) uniform sampler samplers[];
// ──── Push constants ─────────────────────────────────────────────────────
layout(push_constant) uniform PC {
uint itemCount;
vec2 surfaceSize;
float scale;
uint outImageHeapIdx; // storage-image slot of the current swapchain view
uint itemBufHeapIdx; // SSBO slot of the current frame's items
uint atlasTextureHeapIdx; // sampled-image slot of the SDF atlas
uint bindlessBaseHeapIdx; // base sampled-image slot for user images
uint linearSamplerHeapIdx; // sampler-heap slot
} pc;
// ──── 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.
UIItem LoadItem(uint i) {
UIItem it;
it.itype = itemHeap[pc.itemBufHeapIdx].items[i].itype;
it.flags = itemHeap[pc.itemBufHeapIdx].items[i].flags;
it.posPx = itemHeap[pc.itemBufHeapIdx].items[i].posPx;
it.sizePx = itemHeap[pc.itemBufHeapIdx].items[i].sizePx;
it.color = itemHeap[pc.itemBufHeapIdx].items[i].color;
it.colorB = itemHeap[pc.itemBufHeapIdx].items[i].colorB;
it.uvRect = itemHeap[pc.itemBufHeapIdx].items[i].uvRect;
it.imageIdx = itemHeap[pc.itemBufHeapIdx].items[i].imageIdx;
it.cornerRadiusPx = itemHeap[pc.itemBufHeapIdx].items[i].cornerRadiusPx;
it.reserved = itemHeap[pc.itemBufHeapIdx].items[i].reserved;
return it;
}
// ──── Shading helpers ────────────────────────────────────────────────────
// In-bounds sharp rectangle.
vec4 ShadeRect(UIItem it, vec2 fp) {
if (any(lessThan (fp, it.posPx)) ||
any(greaterThanEqual(fp, it.posPx + it.sizePx))) return vec4(0.0);
return it.color;
}
// SDF for a rounded rectangle. p is offset from rect centre.
float sdRoundRect(vec2 p, vec2 halfSize, float r) {
vec2 q = abs(p) - halfSize + vec2(r);
return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - r;
}
vec4 ShadeRoundRect(UIItem it, vec2 fp) {
vec2 centre = it.posPx + it.sizePx * 0.5;
float r = float(it.cornerRadiusPx);
float d = sdRoundRect(fp - centre, it.sizePx * 0.5, r);
// 1-pixel AA band around the edge.
float a = clamp(0.5 - d, 0.0, 1.0);
return it.color * a;
}
vec4 ShadeGlyph(UIItem it, vec2 fp) {
if (any(lessThan (fp, it.posPx)) ||
any(greaterThanEqual(fp, it.posPx + it.sizePx))) return vec4(0.0);
vec2 localUV = (fp - it.posPx) / it.sizePx;
vec2 atlasUV = it.uvRect.xy + localUV * it.uvRect.zw;
// Inline sampler2D construction — GLSL doesn't allow sampler2D as a
// local variable, only as a function argument or uniform.
float dist = texture(
sampler2D(textures[pc.atlasTextureHeapIdx],
samplers[pc.linearSamplerHeapIdx]),
atlasUV
).r;
// SDF threshold (stored on-edge value = 128/255 ≈ 0.502). A small
// sample-units band gives ~1 screen pixel of AA at typical sizes.
float aa = 0.05;
float a = smoothstep(0.5 - aa, 0.5 + aa, dist);
return it.color * a;
}
vec4 ShadeImage(UIItem it, vec2 fp) {
if (any(lessThan (fp, it.posPx)) ||
any(greaterThanEqual(fp, it.posPx + it.sizePx))) return vec4(0.0);
vec2 localUV = (fp - it.posPx) / it.sizePx;
vec2 sourceUV = it.uvRect.xy + localUV * it.uvRect.zw;
uint slot = pc.bindlessBaseHeapIdx + it.imageIdx;
return texture(
sampler2D(textures[nonuniformEXT(slot)],
samplers[pc.linearSamplerHeapIdx]),
sourceUV
) * it.color;
}
// ──── Main ───────────────────────────────────────────────────────────────
void main() {
ivec2 ip = ivec2(gl_GlobalInvocationID.xy);
if (any(greaterThanEqual(ip, ivec2(pc.surfaceSize)))) return;
vec2 fp = vec2(ip) + 0.5; // pixel centre
// Composite over what's already in the swapchain (3D output, clear, …).
vec4 dst = imageLoad(images[pc.outImageHeapIdx], ip);
// Clip stack — current effective rect in (x, y, w, h).
vec4 clipStack[MAX_CLIP_DEPTH];
int clipTop = 0;
clipStack[0] = vec4(0.0, 0.0, pc.surfaceSize);
for (uint i = 0u; i < pc.itemCount; ++i) {
UIItem it = LoadItem(i);
if (it.itype == TYPE_CLIP_PUSH) {
vec4 outer = clipStack[clipTop];
vec2 a = max(outer.xy, it.posPx);
vec2 b = min(outer.xy + outer.zw, it.posPx + it.sizePx);
int next = min(clipTop + 1, MAX_CLIP_DEPTH - 1);
clipStack[next] = vec4(a, max(b - a, vec2(0.0)));
clipTop = next;
continue;
}
if (it.itype == TYPE_CLIP_POP) {
clipTop = max(clipTop - 1, 0);
continue;
}
// Skip if pixel is outside the current clip rect.
vec4 c = clipStack[clipTop];
if (any(lessThan(fp, c.xy)) || any(greaterThanEqual(fp, c.xy + c.zw))) continue;
vec4 src;
switch (it.itype) {
case TYPE_RECT: src = ShadeRect (it, fp); break;
case TYPE_ROUND_RECT: src = ShadeRoundRect (it, fp); break;
case TYPE_GLYPH: src = ShadeGlyph (it, fp); break;
case TYPE_IMAGE: src = ShadeImage (it, fp); break;
default: src = vec4(0.0);
}
// Premultiplied "OVER": dst = src + dst * (1 - src.a)
dst.rgb = src.rgb + dst.rgb * (1.0 - src.a);
dst.a = src.a + dst.a * (1.0 - src.a);
}
imageStore(images[pc.outImageHeapIdx], ip, dst);
}