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>
This commit is contained in:
catbot 2026-06-16 17:03:24 +00:00
commit 574242dd30
8 changed files with 720 additions and 8 deletions

View file

@ -59,6 +59,39 @@ export namespace Crafter {
};
static_assert(sizeof(UIDispatchHeader) == 48);
// ─── 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; // reserved — keep zeroed
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;
@ -143,6 +176,7 @@ export namespace Crafter {
GraphicsComputeShader drawCircles;
GraphicsComputeShader drawImages;
GraphicsComputeShader drawText;
GraphicsComputeShader drawFused;
FontAtlas* fontAtlas = nullptr;
@ -156,7 +190,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;
@ -186,6 +221,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