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

@ -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();