diff --git a/examples/HelloUI/main.cpp b/examples/HelloUI/main.cpp index 1a73824..48948f8 100644 --- a/examples/HelloUI/main.cpp +++ b/examples/HelloUI/main.cpp @@ -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(); diff --git a/implementations/Crafter.Graphics-UI-WebGPU.cpp b/implementations/Crafter.Graphics-UI-WebGPU.cpp index ef3d90e..e7cac71 100644 --- a/implementations/Crafter.Graphics-UI-WebGPU.cpp +++ b/implementations/Crafter.Graphics-UI-WebGPU.cpp @@ -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(); diff --git a/implementations/Crafter.Graphics-UI.cpp b/implementations/Crafter.Graphics-UI.cpp index e8ce871..3ca9ff5 100644 --- a/implementations/Crafter.Graphics-UI.cpp +++ b/implementations/Crafter.Graphics-UI.cpp @@ -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,61 @@ 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_; + pc.flags = 0; + 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, diff --git a/interfaces/Crafter.Graphics-UI.cppm b/interfaces/Crafter.Graphics-UI.cppm index b5760f9..a0d904d 100644 --- a/interfaces/Crafter.Graphics-UI.cppm +++ b/interfaces/Crafter.Graphics-UI.cppm @@ -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 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 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 diff --git a/project.cpp b/project.cpp index d678456..fe5f67e 100644 --- a/project.cpp +++ b/project.cpp @@ -204,6 +204,7 @@ extern "C" Configuration CrafterBuildProject(std::span 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 @@ -413,6 +414,35 @@ extern "C" Configuration CrafterBuildProject(std::span a memImpls.emplace_back("tests/MemoryTypeFallback/main"); 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& fc = fusedTest.config; + fc.path = cfg.path; + fc.name = "UIFusedShader"; + fc.outputName = "UIFusedShader"; + fc.type = ConfigurationType::Executable; + fc.target = cfg.target; + fc.march = cfg.march; + fc.mtune = cfg.mtune; + fc.debug = cfg.debug; + fc.sysroot = cfg.sysroot; + fc.dependencies = cfg.dependencies; + fc.externalDependencies = cfg.externalDependencies; + fc.compileFlags = cfg.compileFlags; + fc.linkFlags = cfg.linkFlags; + fc.defines = cfg.defines; + fc.cFiles = cfg.cFiles; + std::vector fusedImpls(impls.begin(), impls.end()); + fusedImpls.emplace_back("tests/UIFusedShader/main"); + fc.GetInterfacesAndImplementations(ifaces, fusedImpls); + fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" }; + cfg.tests.push_back(std::move(fusedTest)); } return cfg; diff --git a/shaders/ui-fused.comp.glsl b/shaders/ui-fused.comp.glsl new file mode 100644 index 0000000..f43bbf2 --- /dev/null +++ b/shaders/ui-fused.comp.glsl @@ -0,0 +1,319 @@ +#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; + +// 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 && 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 && 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 && 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 && 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); +} diff --git a/shaders/ui-shared.glsl b/shaders/ui-shared.glsl index 52e29b3..18c4c15 100644 --- a/shaders/ui-shared.glsl +++ b/shaders/ui-shared.glsl @@ -128,6 +128,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. diff --git a/tests/UIFusedShader/main.cpp b/tests/UIFusedShader/main.cpp new file mode 100644 index 0000000..9bfa40d --- /dev/null +++ b/tests/UIFusedShader/main.cpp @@ -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 + +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 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 words(static_cast(size) / sizeof(std::uint32_t)); + f.read(reinterpret_cast(words.data()), size); + return words; +} + +// Count OpVariable instructions in the PushConstant storage class (SC == 9). +int CountPushConstantVariables(const std::vector& 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 PushConstantMemberOffsets(const std::vector& 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 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 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 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 expected = {0, 16, 32, 48, 64, 80, 96, 112}; + std::vector 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; +}