perf(ui): additive DispatchFused to collapse consecutive UI passes (#47) #93

Merged
catbot merged 3 commits from claude/issue-47 into master 2026-06-16 19:12:21 +02:00
8 changed files with 742 additions and 8 deletions

View file

@ -170,14 +170,20 @@ int main() {
0, 0, 0, 0,
};
// Flush + dispatch. The library inserts the inter-dispatch barriers.
// Flush the buffers the GPU is about to read, then composite all three
// categories — background quads, the mouse circle, and the button/label
// text — in ONE fused dispatch (issue #47). The frame runs these passes
// back-to-back with no custom shader interleaved, so DispatchFused loads
// and stores the swapchain image once instead of three times and skips
// the two inter-pass barriers DispatchQuads→Circles→Text would insert.
// Canonical order is quads → circles → images → text; here there are no
// images, so that category is a free no-op.
if (qc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
#else
quadsBuf.FlushDevice();
#endif
ui.DispatchQuads(cmd, quadsSlot, qc);
}
if (cc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
@ -185,7 +191,6 @@ int main() {
#else
circlesBuf.FlushDevice();
#endif
ui.DispatchCircles(cmd, circlesSlot, cc);
}
if (gc > 0) {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
@ -193,8 +198,12 @@ int main() {
#else
glyphsBuf.FlushDevice();
#endif
ui.DispatchText(cmd, glyphsSlot, gc);
}
ui.DispatchFused(cmd,
{quadsSlot, qc},
{circlesSlot, cc},
{}, // no images this frame
{glyphsSlot, gc});
});
window.FinishInit();

View file

@ -26,7 +26,8 @@ void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, Graphi
std::filesystem::path /*quadsSpv*/,
std::filesystem::path /*circlesSpv*/,
std::filesystem::path /*imagesSpv*/,
std::filesystem::path /*textSpv*/) {
std::filesystem::path /*textSpv*/,
std::filesystem::path /*fusedSpv*/) {
(void)initCmd;
window_ = &window;
heap_ = &heap;
@ -144,6 +145,25 @@ void UIRenderer::DispatchText(GraphicsCommandBuffer /*cmd*/, std::uint32_t buffe
texHandle, sampHandle);
}
void UIRenderer::DispatchFused(GraphicsCommandBuffer cmd,
const FusedBatch& quads,
const FusedBatch& circles,
const FusedBatch& images,
const FusedBatch& text) {
// WebGPU is Vulkan-second here (issue #47): one bind group can carry only
// one texture/sampler per dispatch, so the single-load/store uber-kernel
// can't bind per-item image textures the way Vulkan bindless does. Fall
// back to the per-element Dispatch* calls in the same canonical order
// (quads → circles → images → text), which produces the identical result —
// it just pays the per-pass load/store + barriers the Vulkan path fuses
// away. The common quads+circles+text screen still works perfectly; only
// the image category leans on DispatchImages' font-atlas fallback.
DispatchQuads(cmd, quads.bufferSlot, quads.itemCount, quads.clipRectPx);
DispatchCircles(cmd, circles.bufferSlot, circles.itemCount, circles.clipRectPx);
DispatchImages(cmd, images.bufferSlot, images.itemCount, images.clipRectPx);
DispatchText(cmd, text.bufferSlot, text.itemCount, text.clipRectPx);
}
SamplerSlot UIRenderer::RegisterLinearClampSampler() {
auto range = heap_->AllocateSamplerSlots(1);
heap_->samplerTable[range.firstElement] = WebGPU::wgpuCreateLinearClampSampler();

View file

@ -39,15 +39,17 @@ void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, Graphi
std::filesystem::path quadsSpv,
std::filesystem::path circlesSpv,
std::filesystem::path imagesSpv,
std::filesystem::path textSpv) {
std::filesystem::path textSpv,
std::filesystem::path fusedSpv) {
window_ = &window;
heap_ = &heap;
// Load the four standard pipelines.
// Load the four standard pipelines plus the fused uber-kernel (issue #47).
drawQuads.Load(quadsSpv);
drawCircles.Load(circlesSpv);
drawImages.Load(imagesSpv);
drawText.Load(textSpv);
drawFused.Load(fusedSpv);
// Allocate one image slot for the swapchain output. Each per-frame heap
// copy will hold ITS frame's image at this slot.
@ -176,6 +178,69 @@ void UIRenderer::DispatchText(GraphicsCommandBuffer cmd, std::uint32_t bufferSlo
TilesFor(window_->width), TilesFor(window_->height), 1u);
}
// ─── fused dispatch (issue #47) ─────────────────────────────────────────
//
// One uber-kernel composites quads → circles → images → text into the output
// image with a single load and a single store. Routed through the generic
// Dispatch() so it still gets the standard write-after-write barrier when it
// is not the first dispatch of the frame, and a following dispatch barriers
// against it — the only barriers it removes are the INTRA-fuse ones a run of
// Dispatch* calls would have inserted between its categories.
void UIRenderer::DispatchFused(GraphicsCommandBuffer cmd,
const FusedBatch& quads,
const FusedBatch& circles,
const FusedBatch& images,
const FusedBatch& text) {
// Nothing to draw → no dispatch (and, crucially, no barrier bump).
if (quads.itemCount == 0 && circles.itemCount == 0 &&
images.itemCount == 0 && text.itemCount == 0) {
return;
}
if (text.itemCount != 0 && !fontAtlasImageSlot_) {
throw std::runtime_error("UIRenderer::DispatchFused: text batch given but no FontAtlas registered (set fontAtlas before Initialize)");
}
// Flush any glyphs ShapeText rasterised during this onBuild, mirroring
// DispatchText — only relevant when there is text to draw.
if (text.itemCount != 0 && fontAtlas != nullptr && fontAtlas->dirty) {
fontAtlas->Update(cmd);
}
UIFusedHeader pc{};
pc.itemBuffers[0] = quads.bufferSlot;
pc.itemBuffers[1] = circles.bufferSlot;
pc.itemBuffers[2] = images.bufferSlot;
pc.itemBuffers[3] = text.bufferSlot;
pc.itemCounts[0] = quads.itemCount;
pc.itemCounts[1] = circles.itemCount;
pc.itemCounts[2] = images.itemCount;
pc.itemCounts[3] = text.itemCount;
pc.outImage = outImageSlot_;
pc.fontTexture = fontAtlasImageSlot_;
pc.fontSampler = fontAtlasSamplerSlot_;
// Per-category clip-active bits, reusing the same surface-coverage test the
// standard path uses (ClipFlags). A category whose clip already covers the
// surface clears its bit so the kernel skips that category's per-pixel clip
// compares (the common full-surface case) — pixel-identical either way.
auto clipBit = [&](const FusedBatch& b, std::uint32_t bit) -> std::uint32_t {
return (ClipFlags(b.clipRectPx, window_->width, window_->height, 0) & kUIFlagClip) ? bit : 0u;
};
pc.flags = clipBit(quads, 0x1u) | clipBit(circles, 0x2u)
| clipBit(images, 0x4u) | clipBit(text, 0x8u);
pc.surfaceWidth = window_->width;
pc.surfaceHeight = window_->height;
pc.frameIdx = window_->currentBuffer;
pc._pad = 0;
std::copy(quads.clipRectPx.begin(), quads.clipRectPx.end(), pc.clipQuads);
std::copy(circles.clipRectPx.begin(), circles.clipRectPx.end(), pc.clipCircles);
std::copy(images.clipRectPx.begin(), images.clipRectPx.end(), pc.clipImages);
std::copy(text.clipRectPx.begin(), text.clipRectPx.end(), pc.clipText);
Dispatch(cmd, drawFused, &pc, sizeof(pc),
TilesFor(window_->width), TilesFor(window_->height), 1u);
}
// ─── generic Dispatch (with barrier) ────────────────────────────────────
void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader& shader,

View file

@ -65,6 +65,39 @@ export namespace Crafter {
// The remaining low bits stay free for user-defined feature flags.
inline constexpr std::uint32_t kUIFlagClip = 0x80000000u;
// ─── fused-dispatch push-constant header (issue #47) ────────────────
// Mirrors the PC block in shaders/ui-fused.comp.glsl byte-for-byte: every
// member is vec4-aligned, no padding holes, exactly 128 bytes (the
// guaranteed push-constant minimum). This is a SEPARATE contract — the
// per-element Dispatch* path and the frozen 48-byte UIDispatchHeader are
// untouched. DispatchFused builds this; users never fill it directly.
struct UIFusedHeader {
std::uint32_t itemBuffers[4]; // heap slots: quads, circles, images, text
std::uint32_t itemCounts[4]; // item counts: quads, circles, images, text
std::uint32_t outImage; // swapchain image heap slot
std::uint32_t fontTexture; // font-atlas image slot (text phase)
std::uint32_t fontSampler; // font-atlas sampler slot (text phase)
std::uint32_t flags; // per-category clip-active bits (0x1 quads .. 0x8 text)
std::uint32_t surfaceWidth;
std::uint32_t surfaceHeight;
std::uint32_t frameIdx;
std::uint32_t _pad; // reserved — keep zeroed
float clipQuads[4]; // (x, y, w, h) clip per category
float clipCircles[4];
float clipImages[4];
float clipText[4];
};
static_assert(sizeof(UIFusedHeader) == 128);
// One category's input to UIRenderer::DispatchFused. A zero itemCount
// makes that category a free no-op (its kernel loop is zero-trip).
// clipRectPx defaults to "no clip", matching the Dispatch* default.
struct FusedBatch {
std::uint32_t bufferSlot = 0;
std::uint32_t itemCount = 0;
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f};
};
// ─── standard item PODs (match GLSL std430) ─────────────────────────
struct QuadItem {
float x, y, w, h;
@ -149,6 +182,7 @@ export namespace Crafter {
GraphicsComputeShader drawCircles;
GraphicsComputeShader drawImages;
GraphicsComputeShader drawText;
GraphicsComputeShader drawFused;
FontAtlas* fontAtlas = nullptr;
@ -162,7 +196,8 @@ export namespace Crafter {
std::filesystem::path quadsSpv = "ui-quads.comp.spv",
std::filesystem::path circlesSpv = "ui-circles.comp.spv",
std::filesystem::path imagesSpv = "ui-images.comp.spv",
std::filesystem::path textSpv = "ui-text.comp.spv");
std::filesystem::path textSpv = "ui-text.comp.spv",
std::filesystem::path fusedSpv = "ui-fused.comp.spv");
void Record(GraphicsCommandBuffer cmd, std::uint32_t frameIdx, Window& window) override;
@ -204,6 +239,32 @@ export namespace Crafter {
void DispatchText(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
// Fused UI dispatch (issue #47). Composites up to four standard
// categories — quads → circles → images → text, canonical
// back-to-front order — in ONE compute dispatch that loads the
// destination image once and stores once, eliminating the per-pass
// load+store and the inter-pass memory barriers a run of consecutive
// Dispatch* calls would pay. The win materialises at >= 2 non-empty
// categories; an empty category (itemCount 0) is a free no-op.
//
// Additive: the per-element Dispatch* calls and UIDispatchHeader are
// untouched. Keep using those for single-category or fine-grained
// work. 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.
//
// The text category uses the registered font atlas (set fontAtlas
// before Initialize); passing a non-empty text batch without one
// throws, exactly like DispatchText. On the WebGPU backend this falls
// back to issuing the per-element Dispatch* calls in canonical order
// (one texture/sampler per dispatch there — see DispatchImages),
// so the result matches; the load/store fusion is Vulkan-only.
void DispatchFused(GraphicsCommandBuffer cmd,
const FusedBatch& quads,
const FusedBatch& circles,
const FusedBatch& images,
const FusedBatch& text);
// Generic dispatch for user-authored shaders. On Vulkan, `shader` is
// a SPIR-V compute pipeline (bindless via VK_EXT_descriptor_heap, so
// any resource indices baked into push data resolve through the

View file

@ -204,6 +204,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.shaders.emplace_back(fs::path("shaders/ui-circles.comp.glsl"), std::string("main"), ShaderType::Compute);
cfg.shaders.emplace_back(fs::path("shaders/ui-images.comp.glsl"), std::string("main"), ShaderType::Compute);
cfg.shaders.emplace_back(fs::path("shaders/ui-text.comp.glsl"), std::string("main"), ShaderType::Compute);
cfg.shaders.emplace_back(fs::path("shaders/ui-fused.comp.glsl"), std::string("main"), ShaderType::Compute);
cfg.buildFiles.emplace_back(fs::path("shaders/ui-shared.glsl"));
// Regression test for issue #18: drive the NVIDIA descriptor-heap
@ -414,6 +415,35 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
mc.GetInterfacesAndImplementations(ifaces, memImpls);
cfg.tests.push_back(std::move(memTest));
// Issue #47: the fused UI uber-kernel (shaders/ui-fused.comp.glsl) and
// its C++ push-constant mirror UIFusedHeader. Compiles the real shader
// with glslang, validates with spirv-val, and pins the push-constant
// member offsets to UIFusedHeader's layout so a GLSL/C++ drift can't
// slip through (the C++ static_assert only guards the C++ side). No GPU
// device at runtime, but glslang + spirv-val are required tools.
Test fusedTest;
Configuration& ftc = fusedTest.config;
ftc.path = cfg.path;
ftc.name = "UIFusedShader";
ftc.outputName = "UIFusedShader";
ftc.type = ConfigurationType::Executable;
ftc.target = cfg.target;
ftc.march = cfg.march;
ftc.mtune = cfg.mtune;
ftc.debug = cfg.debug;
ftc.sysroot = cfg.sysroot;
ftc.dependencies = cfg.dependencies;
ftc.externalDependencies = cfg.externalDependencies;
ftc.compileFlags = cfg.compileFlags;
ftc.linkFlags = cfg.linkFlags;
ftc.defines = cfg.defines;
ftc.cFiles = cfg.cFiles;
std::vector<fs::path> fusedImpls(impls.begin(), impls.end());
fusedImpls.emplace_back("tests/UIFusedShader/main");
ftc.GetInterfacesAndImplementations(ifaces, fusedImpls);
fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" };
cfg.tests.push_back(std::move(fusedTest));
// Issue #60: VulkanBuffer::FlushDevice / FlushHost now record the chosen
// memory type's propertyFlags at Create time and skip the
// flush/invalidate when the memory is HOST_COHERENT. The gate is pure

333
shaders/ui-fused.comp.glsl Normal file
View file

@ -0,0 +1,333 @@
#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];
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;
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_v0[c].zw;
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;
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_v0[c].zw;
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;
vec2 uvSpan = s_v1[c].zw - s_v1[c].xy;
vec2 atlasPerScreen = (uvSpan * 1024.0) / s_v0[c].zw;
float scalePx = max(atlasPerScreen.x, atlasPerScreen.y);
float band = max(scalePx, 0.0001);
float a = clamp(0.5 - dAtlas / band, 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);
}

View file

@ -139,6 +139,19 @@ bool uiResolveScreenPixel(UIDispatchHeader hdr, out ivec2 screenPx) {
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.

View file

@ -0,0 +1,203 @@
/*
Crafter®.Graphics
Copyright (C) 2026 Catcrafts®
catcrafts.net
This library is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License version 3.0 as published by the Free Software Foundation;
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public
License along with this library; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
*/
// Regression test for issue #47: the fused UI uber-kernel
// (shaders/ui-fused.comp.glsl) and its C++ push-constant mirror
// (Crafter::UIFusedHeader). The fused kernel is large and hand-written, and
// its push-constant block must stay byte-for-byte identical to UIFusedHeader
// or DispatchFused silently corrupts every fused frame. This test:
//
// 1. compiles the real ui-fused.comp.glsl with glslang (same target-env the
// engine builds it with), proving the uber-kernel still compiles and its
// GL_GOOGLE_include of ui-shared.glsl resolves;
// 2. runs spirv-val over the result under the engine's block-layout flags;
// 3. asserts exactly one push-constant variable survives (a well-formed
// single block, like every other UI shader);
// 4. parses the push-constant struct's member Offset decorations and asserts
// they are exactly {0,16,32,48,64,80,96,112} — the layout of
// UIFusedHeader (four uvec4 headers at 0/16/32/48 then four vec4 clip
// rects at 64/80/96/112, 128 bytes total). Any add/remove/reorder of a
// member shifts these offsets and fails here, catching a GLSL/C++ drift
// that the C++-side static_assert(sizeof==128) alone cannot.
//
// glslang and spirv-val are invoked at runtime, so the test declares them as
// required tools (see project.cpp). It needs no GPU device.
#include <cstdlib>
import Crafter.Graphics; // for the static_assert(sizeof(UIFusedHeader)==128)
import std;
using namespace Crafter;
namespace {
namespace fs = std::filesystem;
int RunCommand(const std::string& cmd) {
int status = std::system(cmd.c_str());
if (status == -1) return -1;
return (status & 0x7f) == 0 ? ((status >> 8) & 0xff) : 128 + (status & 0x7f);
}
std::vector<std::uint32_t> ReadSpirv(const fs::path& p) {
std::ifstream f(p, std::ios::binary | std::ios::ate);
if (!f) return {};
std::streamsize size = f.tellg();
f.seekg(0);
std::vector<std::uint32_t> words(static_cast<std::size_t>(size) / sizeof(std::uint32_t));
f.read(reinterpret_cast<char*>(words.data()), size);
return words;
}
// Count OpVariable instructions in the PushConstant storage class (SC == 9).
int CountPushConstantVariables(const std::vector<std::uint32_t>& words) {
constexpr std::uint32_t OpVariable = 59;
constexpr std::uint32_t StorageClassPushConstant = 9;
int count = 0;
for (std::size_t i = 5; i < words.size();) {
std::uint32_t len = words[i] >> 16;
if (len == 0 || i + len > words.size()) break;
if ((words[i] & 0xFFFFu) == OpVariable && len >= 4 && words[i + 3] == StorageClassPushConstant)
++count;
i += len;
}
return count;
}
// The single PushConstant struct's member byte offsets, sorted ascending.
// Finds the OpTypePointer in the PushConstant storage class (SC == 9), takes
// the struct type it points at, and collects every Offset (decoration 35)
// declared on that struct via OpMemberDecorate.
std::vector<std::uint32_t> PushConstantMemberOffsets(const std::vector<std::uint32_t>& words) {
constexpr std::uint32_t OpTypePointer = 32;
constexpr std::uint32_t OpMemberDecorate = 72;
constexpr std::uint32_t StorageClassPushConstant = 9;
constexpr std::uint32_t DecorationOffset = 35;
std::uint32_t structId = 0;
for (std::size_t i = 5; i < words.size();) {
std::uint32_t len = words[i] >> 16;
if (len == 0 || i + len > words.size()) break;
// OpTypePointer: [op][result id][storage class][pointee type id]
if ((words[i] & 0xFFFFu) == OpTypePointer && len >= 4 &&
words[i + 2] == StorageClassPushConstant) {
structId = words[i + 3];
break;
}
i += len;
}
if (structId == 0) return {};
std::vector<std::uint32_t> offsets;
for (std::size_t i = 5; i < words.size();) {
std::uint32_t len = words[i] >> 16;
if (len == 0 || i + len > words.size()) break;
// OpMemberDecorate: [op][struct id][member][decoration][operands...]
if ((words[i] & 0xFFFFu) == OpMemberDecorate && len >= 5 &&
words[i + 1] == structId && words[i + 3] == DecorationOffset) {
offsets.push_back(words[i + 4]);
}
i += len;
}
std::ranges::sort(offsets);
offsets.erase(std::ranges::unique(offsets).begin(), offsets.end());
return offsets;
}
// Locate shaders/ui-fused.comp.glsl whether the test runs from the build
// output dir or the project root (mirrors ShapeTextCache's font probing).
fs::path LocateShader() {
const std::array<fs::path, 4> candidates = {
"shaders/ui-fused.comp.glsl",
"../shaders/ui-fused.comp.glsl",
"../../shaders/ui-fused.comp.glsl",
"../../../shaders/ui-fused.comp.glsl",
};
for (const auto& c : candidates) {
std::error_code ec;
if (fs::exists(c, ec)) return c;
}
return {};
}
} // namespace
int main() {
// Sanity: the C++ mirror is the size the shader layout assumes.
static_assert(sizeof(UIFusedHeader) == 128);
const fs::path src = LocateShader();
if (src.empty()) {
std::println(std::cerr, "could not locate shaders/ui-fused.comp.glsl from {}",
fs::current_path().string());
return 1;
}
const fs::path dir = fs::temp_directory_path() / "crafter-uifused-test";
std::error_code ec;
fs::create_directories(dir, ec);
const fs::path spv = dir / "ui-fused.comp.spv";
// 1. Compile the real uber-kernel. GL_GOOGLE_include resolves
// ui-shared.glsl relative to the source directory.
std::string compile = "glslang --target-env vulkan1.4 -V -S comp \""
+ src.string() + "\" -o \"" + spv.string() + "\" > /dev/null";
if (RunCommand(compile) != 0) {
std::println(std::cerr, "glslang failed to compile ui-fused.comp.glsl");
return 1;
}
std::vector<std::uint32_t> words = ReadSpirv(spv);
if (words.size() < 5) {
std::println(std::cerr, "could not read compiled SPIR-V");
return 1;
}
// 2. spirv-val under the engine's relaxed block-layout flags.
std::string validate = "spirv-val \"" + spv.string()
+ "\" --relax-block-layout --scalar-block-layout --target-env vulkan1.4";
if (RunCommand(validate) != 0) {
std::println(std::cerr, "spirv-val rejected ui-fused.comp.spv");
return 1;
}
// 3. Exactly one push-constant block.
int pcVars = CountPushConstantVariables(words);
if (pcVars != 1) {
std::println(std::cerr, "expected exactly 1 push-constant variable, found {}", pcVars);
return 1;
}
// 4. Member offsets must match UIFusedHeader's layout exactly.
const std::vector<std::uint32_t> expected = {0, 16, 32, 48, 64, 80, 96, 112};
std::vector<std::uint32_t> offsets = PushConstantMemberOffsets(words);
if (offsets != expected) {
std::print(std::cerr, "push-constant member offsets mismatch — expected {{");
for (auto o : expected) std::print(std::cerr, "{} ", o);
std::print(std::cerr, "}}, got {{");
for (auto o : offsets) std::print(std::cerr, "{} ", o);
std::println(std::cerr, "}}");
return 1;
}
std::println(std::cout,
"ui-fused shader ok (push-constant vars: {}, member offsets pinned to UIFusedHeader, 128 bytes)",
pcVars);
return 0;
}