perf(ui): additive DispatchFused to collapse consecutive UI passes (#47) #93
8 changed files with 720 additions and 8 deletions
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>
commit
574242dd30
|
|
@ -170,14 +170,20 @@ int main() {
|
||||||
0, 0, 0, 0,
|
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) {
|
if (qc > 0) {
|
||||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||||
quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
|
quadsBuf.FlushDevice(cmd, VK_ACCESS_SHADER_READ_BIT, VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
|
||||||
#else
|
#else
|
||||||
quadsBuf.FlushDevice();
|
quadsBuf.FlushDevice();
|
||||||
#endif
|
#endif
|
||||||
ui.DispatchQuads(cmd, quadsSlot, qc);
|
|
||||||
}
|
}
|
||||||
if (cc > 0) {
|
if (cc > 0) {
|
||||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||||
|
|
@ -185,7 +191,6 @@ int main() {
|
||||||
#else
|
#else
|
||||||
circlesBuf.FlushDevice();
|
circlesBuf.FlushDevice();
|
||||||
#endif
|
#endif
|
||||||
ui.DispatchCircles(cmd, circlesSlot, cc);
|
|
||||||
}
|
}
|
||||||
if (gc > 0) {
|
if (gc > 0) {
|
||||||
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
|
||||||
|
|
@ -193,8 +198,12 @@ int main() {
|
||||||
#else
|
#else
|
||||||
glyphsBuf.FlushDevice();
|
glyphsBuf.FlushDevice();
|
||||||
#endif
|
#endif
|
||||||
ui.DispatchText(cmd, glyphsSlot, gc);
|
|
||||||
}
|
}
|
||||||
|
ui.DispatchFused(cmd,
|
||||||
|
{quadsSlot, qc},
|
||||||
|
{circlesSlot, cc},
|
||||||
|
{}, // no images this frame
|
||||||
|
{glyphsSlot, gc});
|
||||||
});
|
});
|
||||||
|
|
||||||
window.FinishInit();
|
window.FinishInit();
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, Graphi
|
||||||
std::filesystem::path /*quadsSpv*/,
|
std::filesystem::path /*quadsSpv*/,
|
||||||
std::filesystem::path /*circlesSpv*/,
|
std::filesystem::path /*circlesSpv*/,
|
||||||
std::filesystem::path /*imagesSpv*/,
|
std::filesystem::path /*imagesSpv*/,
|
||||||
std::filesystem::path /*textSpv*/) {
|
std::filesystem::path /*textSpv*/,
|
||||||
|
std::filesystem::path /*fusedSpv*/) {
|
||||||
(void)initCmd;
|
(void)initCmd;
|
||||||
window_ = &window;
|
window_ = &window;
|
||||||
heap_ = &heap;
|
heap_ = &heap;
|
||||||
|
|
@ -144,6 +145,25 @@ void UIRenderer::DispatchText(GraphicsCommandBuffer /*cmd*/, std::uint32_t buffe
|
||||||
texHandle, sampHandle);
|
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() {
|
SamplerSlot UIRenderer::RegisterLinearClampSampler() {
|
||||||
auto range = heap_->AllocateSamplerSlots(1);
|
auto range = heap_->AllocateSamplerSlots(1);
|
||||||
heap_->samplerTable[range.firstElement] = WebGPU::wgpuCreateLinearClampSampler();
|
heap_->samplerTable[range.firstElement] = WebGPU::wgpuCreateLinearClampSampler();
|
||||||
|
|
|
||||||
|
|
@ -39,15 +39,17 @@ void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, Graphi
|
||||||
std::filesystem::path quadsSpv,
|
std::filesystem::path quadsSpv,
|
||||||
std::filesystem::path circlesSpv,
|
std::filesystem::path circlesSpv,
|
||||||
std::filesystem::path imagesSpv,
|
std::filesystem::path imagesSpv,
|
||||||
std::filesystem::path textSpv) {
|
std::filesystem::path textSpv,
|
||||||
|
std::filesystem::path fusedSpv) {
|
||||||
window_ = &window;
|
window_ = &window;
|
||||||
heap_ = &heap;
|
heap_ = &heap;
|
||||||
|
|
||||||
// Load the four standard pipelines.
|
// Load the four standard pipelines plus the fused uber-kernel (issue #47).
|
||||||
drawQuads.Load(quadsSpv);
|
drawQuads.Load(quadsSpv);
|
||||||
drawCircles.Load(circlesSpv);
|
drawCircles.Load(circlesSpv);
|
||||||
drawImages.Load(imagesSpv);
|
drawImages.Load(imagesSpv);
|
||||||
drawText.Load(textSpv);
|
drawText.Load(textSpv);
|
||||||
|
drawFused.Load(fusedSpv);
|
||||||
|
|
||||||
// Allocate one image slot for the swapchain output. Each per-frame heap
|
// Allocate one image slot for the swapchain output. Each per-frame heap
|
||||||
// copy will hold ITS frame's image at this slot.
|
// 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);
|
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) ────────────────────────────────────
|
// ─── generic Dispatch (with barrier) ────────────────────────────────────
|
||||||
|
|
||||||
void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader& shader,
|
void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader& shader,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,39 @@ export namespace Crafter {
|
||||||
};
|
};
|
||||||
static_assert(sizeof(UIDispatchHeader) == 48);
|
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) ─────────────────────────
|
// ─── standard item PODs (match GLSL std430) ─────────────────────────
|
||||||
struct QuadItem {
|
struct QuadItem {
|
||||||
float x, y, w, h;
|
float x, y, w, h;
|
||||||
|
|
@ -143,6 +176,7 @@ export namespace Crafter {
|
||||||
GraphicsComputeShader drawCircles;
|
GraphicsComputeShader drawCircles;
|
||||||
GraphicsComputeShader drawImages;
|
GraphicsComputeShader drawImages;
|
||||||
GraphicsComputeShader drawText;
|
GraphicsComputeShader drawText;
|
||||||
|
GraphicsComputeShader drawFused;
|
||||||
|
|
||||||
FontAtlas* fontAtlas = nullptr;
|
FontAtlas* fontAtlas = nullptr;
|
||||||
|
|
||||||
|
|
@ -156,7 +190,8 @@ export namespace Crafter {
|
||||||
std::filesystem::path quadsSpv = "ui-quads.comp.spv",
|
std::filesystem::path quadsSpv = "ui-quads.comp.spv",
|
||||||
std::filesystem::path circlesSpv = "ui-circles.comp.spv",
|
std::filesystem::path circlesSpv = "ui-circles.comp.spv",
|
||||||
std::filesystem::path imagesSpv = "ui-images.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;
|
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,
|
void DispatchText(GraphicsCommandBuffer cmd, std::uint32_t bufferSlot, std::uint32_t itemCount,
|
||||||
std::array<float,4> clipRectPx = {0.0f, 0.0f, 1e9f, 1e9f});
|
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
|
// Generic dispatch for user-authored shaders. On Vulkan, `shader` is
|
||||||
// a SPIR-V compute pipeline (bindless via VK_EXT_descriptor_heap, so
|
// a SPIR-V compute pipeline (bindless via VK_EXT_descriptor_heap, so
|
||||||
// any resource indices baked into push data resolve through the
|
// any resource indices baked into push data resolve through the
|
||||||
|
|
|
||||||
30
project.cpp
30
project.cpp
|
|
@ -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-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-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-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"));
|
cfg.buildFiles.emplace_back(fs::path("shaders/ui-shared.glsl"));
|
||||||
|
|
||||||
// Regression test for issue #18: drive the NVIDIA descriptor-heap
|
// Regression test for issue #18: drive the NVIDIA descriptor-heap
|
||||||
|
|
@ -413,6 +414,35 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
||||||
memImpls.emplace_back("tests/MemoryTypeFallback/main");
|
memImpls.emplace_back("tests/MemoryTypeFallback/main");
|
||||||
mc.GetInterfacesAndImplementations(ifaces, memImpls);
|
mc.GetInterfacesAndImplementations(ifaces, memImpls);
|
||||||
cfg.tests.push_back(std::move(memTest));
|
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<fs::path> 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;
|
return cfg;
|
||||||
|
|
|
||||||
319
shaders/ui-fused.comp.glsl
Normal file
319
shaders/ui-fused.comp.glsl
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -128,6 +128,19 @@ bool uiResolveScreenPixel(UIDispatchHeader hdr, out ivec2 screenPx) {
|
||||||
return true;
|
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
|
// 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
|
// straight-alpha vec4. Use this when iterating items in a loop with a local
|
||||||
// accumulator.
|
// accumulator.
|
||||||
|
|
|
||||||
203
tests/UIFusedShader/main.cpp
Normal file
203
tests/UIFusedShader/main.cpp
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue