diff --git a/project.cpp b/project.cpp index c4a4cec..92643c0 100644 --- a/project.cpp +++ b/project.cpp @@ -373,6 +373,40 @@ extern "C" Configuration CrafterBuildProject(std::span a setupReuseImpls.emplace_back("tests/SetupCmdBufferReuse/main"); sr.GetInterfacesAndImplementations(ifaces, setupReuseImpls); cfg.tests.push_back(std::move(setupReuseTest)); + + // The UI path has two binding models: VK_EXT_descriptor_heap, and + // a bindless descriptor set for drivers without it (Mesa Turnip). + // The test renders one scene through each — the second by + // re-running itself with CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP — and + // compares the frames, so the fallback cannot drift from the heap + // path unnoticed on a machine that never takes it. Unlike the + // other tests it loads the real UI kernels, so it carries the + // library's shaders (both variants) and a font into its own bin + // dir. Needs the Wayland backend + a real compositor. + Test parityTest; + Configuration& up = parityTest.config; + up.path = cfg.path; + up.name = "UIFallbackParity"; + up.outputName = "UIFallbackParity"; + up.type = ConfigurationType::Executable; + up.target = cfg.target; + up.march = cfg.march; + up.mtune = cfg.mtune; + up.debug = cfg.debug; + up.sysroot = cfg.sysroot; + up.dependencies = cfg.dependencies; + up.externalDependencies = cfg.externalDependencies; + up.compileFlags = cfg.compileFlags; + up.linkFlags = cfg.linkFlags; + up.defines = cfg.defines; + up.cFiles = cfg.cFiles; + up.shaders = cfg.shaders; + up.buildFiles = cfg.buildFiles; + up.files.emplace_back(fs::path("examples/HelloUI/font.ttf")); + std::vector parityImpls(impls.begin(), impls.end()); + parityImpls.emplace_back("tests/UIFallbackParity/main"); + up.GetInterfacesAndImplementations(ifaces, parityImpls); + cfg.tests.push_back(std::move(parityTest)); } // Issue #36: BLAS build options. Drives the real hardware AS-build diff --git a/tests/UIFallbackParity/main.cpp b/tests/UIFallbackParity/main.cpp new file mode 100644 index 0000000..0f03bdc --- /dev/null +++ b/tests/UIFallbackParity/main.cpp @@ -0,0 +1,423 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// The UI path has two binding models. Where the driver has +// VK_EXT_descriptor_heap, shaders index the heap directly; where it does not +// (Mesa Turnip on Adreno), the same slots live in a bindless descriptor set +// and the shaders are a second build of the same source (.sets.spv, +// CRAFTER_GRAPHICS_DESCRIPTOR_SETS). Nothing but the binding model may differ +// between the two, so this test renders one fixed scene through each and +// compares the frames pixel by pixel. +// +// The scene is chosen to reach everything the descriptor-set path implements +// and HelloUI does not exercise: +// +// - the four standalone kernels (DispatchQuads / Circles / Images / Text) +// and the fused kernel with all four categories non-empty; +// - a sampled image and two samplers indexed per item (bindings 1 and 2, +// nonuniform indices), next to the font atlas; +// - slot reuse: an image slot and a sampler slot are each freed and +// registered again before the first frame, so the second write has to +// replace the first one's view / sampler. A stale descriptor shows the +// wrong texture and fails the comparison; +// - a resize, which rewrites the per-frame swapchain descriptors; +// - an image created with ImageVulkan::Create's default consumer stage, +// which has to be resolved to a stage the device actually has; +// - a user-style ui.Dispatch whose push payload is not a whole number of +// words, which the descriptor-set path pads (the heap run pushes the +// whole header and must draw the same quad). +// +// How it runs: with no arguments the test is the driver. It starts itself +// twice with `--render `, once as is and once with +// CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP set, then decodes and compares the two +// PNGs. A child also fails on any Vulkan validation error raised while +// rendering, which only means something when CRAFTER_GRAPHICS_VALIDATION is +// set for the run. +// +// On a driver that has no descriptor heap at all, both children take the +// descriptor-set path. There is then nothing to compare against, and the test +// passes on the strength of the scene rendering cleanly — which is the run +// that matters on such a device. `--render ` can also be used directly to +// produce a frame for inspection. +// +// Needs a live Wayland compositor and a Vulkan device, like FrameLoopSync. + +#include "vulkan/vulkan.h" +#include + +// PNG decode for the comparison. The library only compiles the writer +// (Window::SaveFrame); STB_IMAGE_STATIC keeps the reader private to this file. +#define STB_IMAGE_IMPLEMENTATION +#define STB_IMAGE_STATIC +#define STBI_ONLY_PNG +#include "../../lib/stb_image.h" + +import Crafter.Graphics; +import Crafter.Event; +import std; + +using namespace Crafter; +namespace fs = std::filesystem; + +namespace { + +int failures = 0; + +void Check(bool ok, std::string_view what) { + std::println("{} {}", ok ? "PASS" : "FAIL", what); + if (!ok) ++failures; +} + +// The test binary's own directory: the build places the compiled shaders +// (both variants) and font.ttf next to it, and the test runner's working +// directory is not that directory. +fs::path ExeDir() { + return fs::read_symlink("/proc/self/exe").parent_path(); +} + +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); +} + +// RGBA8 texel as a 4-byte word, matching VK_FORMAT_R8G8B8A8_UNORM. +using Texel = std::uint32_t; +constexpr std::uint16_t kTexSize = 8; + +constexpr std::uint32_t kWidth = 640; +constexpr std::uint32_t kHeight = 480; +// The size after the mid-run resize. Smaller than the first one so the scene, +// laid out in absolute pixels, still fits. +constexpr std::uint32_t kResizedWidth = 600; +constexpr std::uint32_t kResizedHeight = 440; + +template +void CreateItemBuffer(GraphicsBuffer& buffer, std::uint32_t count) { + buffer.Create( + VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_SHADER_DEVICE_ADDRESS_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, count); +} + +void CreateTexture(ImageVulkan& image, VkCommandBuffer cmd, auto&& texelAt) { + image.Create(kTexSize, kTexSize, /*mipLevels*/ 1, cmd, + VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT); + for (std::uint32_t y = 0; y < kTexSize; ++y) { + for (std::uint32_t x = 0; x < kTexSize; ++x) { + image.buffer.value[y * kTexSize + x] = texelAt(x, y); + } + } + image.Update(cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); +} + +VkSamplerCreateInfo SamplerInfo(VkFilter filter) { + return { + .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, + .magFilter = filter, + .minFilter = filter, + .mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST, + .addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE, + .maxAnisotropy = 1.0f, + .maxLod = VK_LOD_CLAMP_NONE, + }; +} + +// Renders the scene and writes the last frame to `out`. Also writes +// `.mode` holding "heap" or "sets", so the driver can tell which binding +// model this process actually ran with. +int RenderScene(const fs::path& out) { + const fs::path dir = ExeDir(); + + Device::Initialize(); + Window window(kWidth, kHeight, "UIFallbackParity test"); + VkCommandBuffer init = window.StartInit(); + + GraphicsDescriptorHeap heap; + heap.Initialize(/*images*/ 8, /*buffers*/ 16, /*samplers*/ 4); + window.descriptorHeap = &heap; + + Font font(dir / "font.ttf"); + FontAtlas atlas; + atlas.Initialize(init); + + UIRenderer ui; + ui.fontAtlas = &atlas; + ui.Initialize(window, heap, init, + dir / "ui-quads.comp.spv", dir / "ui-circles.comp.spv", + dir / "ui-images.comp.spv", dir / "ui-text.comp.spv", + dir / "ui-fused.comp.spv"); + window.passes.push_back(&ui); + + // Two textures. `red` is registered first and its slot freed again, so + // `checker` lands on the same slot: its descriptor (in sets mode, its + // view) must replace red's. If the write were lost the image quads below + // would come out red in one model and checkered in the other. + ImageVulkan red; + ImageVulkan checker; + CreateTexture(red, init, [](std::uint32_t, std::uint32_t) { return 0xFF0000FFu; }); + CreateTexture(checker, init, [](std::uint32_t x, std::uint32_t y) { + return ((x + y) & 1u) != 0 ? 0xFFFFC040u : 0xFF204080u; + }); + + // A third image, created with Create's default consumer stage and never + // sampled. That default names the ray tracing stage, which is not a valid + // barrier stage on a device without ray tracing, so Create has to resolve + // it for both the member and the transition it records itself. Only the + // validation layer can see a wrong stage here. + ImageVulkan defaultStage; + defaultStage.Create(kTexSize, kTexSize, /*mipLevels*/ 1, init, + VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + std::fill_n(defaultStage.buffer.value, kTexSize * kTexSize, Texel{0}); + defaultStage.Update(init, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + + ImageSlot imageSlot = ui.RegisterImage(red, VK_FORMAT_R8G8B8A8_UNORM); + const std::uint16_t firstImageSlot = imageSlot; + imageSlot.Reset(); + imageSlot = ui.RegisterImage(checker, VK_FORMAT_R8G8B8A8_UNORM); + Check(static_cast(imageSlot) == firstImageSlot, + "the freed image slot is handed out again"); + + // Same for a sampler slot: linear first, then nearest on the reused slot. + // The 8x8 checker is magnified ~12x, so the two filters differ in almost + // every pixel of the quad. + SamplerSlot nearestSlot = ui.RegisterSampler(SamplerInfo(VK_FILTER_LINEAR)); + const std::uint16_t firstSamplerSlot = nearestSlot; + nearestSlot.Reset(); + nearestSlot = ui.RegisterSampler(SamplerInfo(VK_FILTER_NEAREST)); + Check(static_cast(nearestSlot) == firstSamplerSlot, + "the freed sampler slot is handed out again"); + SamplerSlot linearSlot = ui.RegisterLinearClampSampler(); + + // One set of item buffers for the standalone dispatches (left column) and + // one for the fused dispatch (right column). + GraphicsBuffer quadsA, quadsB; + GraphicsBuffer circlesA, circlesB; + GraphicsBuffer imagesA, imagesB; + GraphicsBuffer glyphsA, glyphsB; + CreateItemBuffer(quadsA, 8); CreateItemBuffer(quadsB, 8); + CreateItemBuffer(circlesA, 8); CreateItemBuffer(circlesB, 8); + CreateItemBuffer(imagesA, 8); CreateItemBuffer(imagesB, 8); + CreateItemBuffer(glyphsA, 64); CreateItemBuffer(glyphsB, 64); + BufferSlot quadsASlot = ui.RegisterBuffer(quadsA); + BufferSlot circlesASlot = ui.RegisterBuffer(circlesA); + BufferSlot imagesASlot = ui.RegisterBuffer(imagesA); + BufferSlot glyphsASlot = ui.RegisterBuffer(glyphsA); + BufferSlot quadsBSlot = ui.RegisterBuffer(quadsB); + BufferSlot circlesBSlot = ui.RegisterBuffer(circlesB); + BufferSlot imagesBSlot = ui.RegisterBuffer(imagesB); + BufferSlot glyphsBSlot = ui.RegisterBuffer(glyphsB); + + // A single quad for the odd-sized generic dispatch below. + GraphicsBuffer quadsOdd; + CreateItemBuffer(quadsOdd, 1); + BufferSlot quadsOddSlot = ui.RegisterBuffer(quadsOdd); + + // Everything is laid out in absolute pixels so both runs draw the same + // thing whatever size the compositor settles on. + auto fillColumn = [&](float ox, GraphicsBuffer& quads, + GraphicsBuffer& circles, + GraphicsBuffer& images, + GraphicsBuffer& glyphs, + bool withBackground, std::string_view label, + std::array& counts) { + std::uint32_t q = 0; + if (withBackground) { + quads.value[q++] = QuadItem{ 0, 0, 4096, 4096, 0.08f, 0.09f, 0.12f, 1.0f, 0, 0, 0, 0, 0, 0, 0, 0 }; + } + quads.value[q++] = QuadItem{ ox, 20, 220, 70, 0.20f, 0.45f, 0.85f, 1.0f, 4, 12, 20, 28, 3, 1.0f, 0.8f, 0.2f }; + quads.value[q++] = QuadItem{ ox + 40, 60, 140, 50, 0.90f, 0.30f, 0.30f, 0.6f, 0, 0, 0, 0, 0, 0, 0, 0 }; + + std::uint32_t c = 0; + circles.value[c++] = CircleItem{ ox + 50, 150, 34, 0, 0.30f, 0.85f, 0.45f, 1.0f, 0, 0, 0, 0 }; + circles.value[c++] = CircleItem{ ox + 150, 150, 30, 0, 0.0f, 0.0f, 0.0f, 0.0f, 5, 0.95f, 0.95f, 1.0f }; + + std::uint32_t i = 0; + images.value[i++] = ImageItem{ ox, 200, 96, 96, 0, 0, 1, 1, 1, 1, 1, 1, + imageSlot, nearestSlot, 0, 0 }; + images.value[i++] = ImageItem{ ox + 110, 200, 96, 96, 0, 0, 1, 1, 1.0f, 0.8f, 0.8f, 1.0f, + imageSlot, linearSlot, 0, 0 }; + + std::uint32_t g = ui.ShapeText(font, 22.0f, ox, 340.0f, label, + {1.0f, 1.0f, 1.0f, 1.0f}, glyphs.value, 64); + counts = { q, c, i, g }; + }; + + std::array countsA{}; + std::array countsB{}; + EventListener buildSub(&ui.onBuild, [&](UIBuildArgs a) { + VkCommandBuffer cmd = a.cmd; + fillColumn(30.0f, quadsA, circlesA, imagesA, glyphsA, /*withBackground*/ true, "standalone", countsA); + fillColumn(330.0f, quadsB, circlesB, imagesB, glyphsB, /*withBackground*/ false, "fused", countsB); + + constexpr VkAccessFlags read = VK_ACCESS_SHADER_READ_BIT; + constexpr VkPipelineStageFlags stage = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT; + quadsA.FlushDevice(cmd, read, stage); quadsB.FlushDevice(cmd, read, stage); + circlesA.FlushDevice(cmd, read, stage); circlesB.FlushDevice(cmd, read, stage); + imagesA.FlushDevice(cmd, read, stage); imagesB.FlushDevice(cmd, read, stage); + glyphsA.FlushDevice(cmd, read, stage); glyphsB.FlushDevice(cmd, read, stage); + + ui.DispatchQuads(cmd, quadsASlot, countsA[0]); + ui.DispatchCircles(cmd, circlesASlot, countsA[1]); + ui.DispatchImages(cmd, imagesASlot, countsA[2]); + ui.DispatchText(cmd, glyphsASlot, countsA[3]); + + ui.DispatchFused(cmd, + {quadsBSlot, countsB[0]}, + {circlesBSlot, countsB[1]}, + {imagesBSlot, countsB[2]}, + {glyphsBSlot, countsB[3]}); + + // One more quad through the generic Dispatch a custom shader would + // use. On the descriptor-set path the last three bytes of the + // header's reserved `_pad` are left off, so ComputeShader::Dispatch + // has to pad the payload for vkCmdPushConstants. The heap run pushes + // the whole header (a ragged size is a validation error there and + // nothing pads it), and both must draw the same quad. + quadsOdd.value[0] = QuadItem{ 30, 370, 180, 30, 0.85f, 0.65f, 0.15f, 1.0f, 6, 6, 6, 6, 0, 0, 0, 0 }; + quadsOdd.FlushDevice(cmd, read, stage); + const UIDispatchHeader oddHeader = ui.FillHeader(quadsOddSlot, 1); + const std::uint32_t oddBytes = Device::descriptorHeapSupported + ? static_cast(sizeof(oddHeader)) + : static_cast(sizeof(oddHeader) - 3); + ui.Dispatch(cmd, ui.drawQuads, &oddHeader, oddBytes, + (window.width + 7) / 8, (window.height + 7) / 8); + }); + + window.FinishInit(); + + // More frames than there are in flight, so every per-frame heap copy / + // descriptor set is used; then a resize, which recreates the swapchain + // and rewrites its descriptors; then enough frames for the glyphs shaped + // on the first frame to have reached the atlas texture. + for (int i = 0; i < Window::numFrames * 2; ++i) window.Render(); + window.Resize(kResizedWidth, kResizedHeight); + for (int i = 0; i < Window::numFrames * 3; ++i) window.Render(); + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + + // Counted before the capture: SaveFrame reads back an image that has + // already been handed to the presentation engine, which the validation + // layer reports in either binding model. What is under test here is the + // rendering above. + const std::uint32_t renderErrors = Device::validationErrorCount; + Check(renderErrors == 0, + std::format("no Vulkan validation errors while rendering ({} seen)", renderErrors)); + + window.SaveFrame(out); + std::ofstream(fs::path(out).concat(".mode")) << (Device::descriptorHeapSupported ? "heap" : "sets"); + Check(fs::exists(out), "the frame was written"); + + // The buffers, images and heap go out of scope here while the last + // frames may still be presenting; settle the queue first. + Device::CheckVkResult(vkQueueWaitIdle(Device::queue)); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +struct Frame { + int width = 0; + int height = 0; + std::vector rgba; +}; + +Frame LoadFrame(const fs::path& path) { + Frame frame; + int channels = 0; + stbi_uc* pixels = stbi_load(path.string().c_str(), &frame.width, &frame.height, &channels, STBI_rgb_alpha); + if (pixels != nullptr) { + frame.rgba.assign(pixels, pixels + static_cast(frame.width) * frame.height * 4); + stbi_image_free(pixels); + } + return frame; +} + +std::string ReadMode(const fs::path& png) { + std::ifstream in(fs::path(png).concat(".mode")); + std::string mode; + in >> mode; + return mode; +} + +int Drive() { + const fs::path exe = fs::read_symlink("/proc/self/exe"); + const fs::path dir = fs::temp_directory_path() / "crafter-ui-fallback-parity"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + const fs::path heapPng = dir / "heap.png"; + const fs::path setsPng = dir / "sets.png"; + + // `env -u` so a CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP inherited from the + // caller's environment cannot turn the reference run into a second + // fallback run. + const int heapExit = RunCommand(std::format( + "env -u CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP '{}' --render '{}'", exe.string(), heapPng.string())); + const int setsExit = RunCommand(std::format( + "env CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP=1 '{}' --render '{}'", exe.string(), setsPng.string())); + + Check(heapExit == 0, std::format("reference run rendered the scene (exit {})", heapExit)); + Check(setsExit == 0, std::format("descriptor-set run rendered the scene (exit {})", setsExit)); + Check(ReadMode(setsPng) == "sets", "the forced run took the descriptor-set path"); + if (failures != 0) return EXIT_FAILURE; + + const Frame sets = LoadFrame(setsPng); + Check(!sets.rgba.empty(), "descriptor-set frame decodes"); + if (failures != 0) return EXIT_FAILURE; + + // A frame that is one flat colour means nothing was composited, which + // two equally broken runs would agree on. + std::set colours; + for (std::size_t i = 0; i + 3 < sets.rgba.size() && colours.size() < 16; i += 4) { + std::uint32_t texel = 0; + std::memcpy(&texel, &sets.rgba[i], 4); + colours.insert(texel); + } + Check(colours.size() >= 16, std::format("descriptor-set frame has content ({}+ distinct colours)", colours.size())); + + if (ReadMode(heapPng) != "heap") { + std::println("NOTE this driver has no VK_EXT_descriptor_heap: both runs used descriptor sets, " + "so there is no heap frame to compare against"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; + } + + const Frame heap = LoadFrame(heapPng); + Check(!heap.rgba.empty(), "descriptor-heap frame decodes"); + Check(heap.width == sets.width && heap.height == sets.height, + std::format("both frames are the same size ({}x{} vs {}x{})", + heap.width, heap.height, sets.width, sets.height)); + if (failures != 0) return EXIT_FAILURE; + + // One code value of slack per channel: both shaders are the same source, + // but the driver compiles two different SPIR-V modules and may contract + // or reorder float math differently on an anti-aliased edge. + std::size_t differing = 0; + int maxDiff = 0; + for (std::size_t i = 0; i < heap.rgba.size(); ++i) { + const int diff = std::abs(static_cast(heap.rgba[i]) - static_cast(sets.rgba[i])); + if (diff != 0) ++differing; + maxDiff = std::max(maxDiff, diff); + } + Check(maxDiff <= 1, + std::format("heap and descriptor-set frames match (max channel difference {}, {} of {} channel values differ)", + maxDiff, differing, heap.rgba.size())); + if (maxDiff > 1) { + std::println(" frames kept for inspection: {} and {}", heapPng.string(), setsPng.string()); + } + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +} + +} // namespace + +int main(int argc, char** argv) { + if (argc == 3 && std::string_view(argv[1]) == "--render") { + return RenderScene(argv[2]); + } + const int result = Drive(); + std::println("{}", result == EXIT_SUCCESS ? "all checks passed" : "check(s) failed"); + return result; +} diff --git a/tests/UIFusedShader/main.cpp b/tests/UIFusedShader/main.cpp index 9d4f1c3..77fc3c0 100644 --- a/tests/UIFusedShader/main.cpp +++ b/tests/UIFusedShader/main.cpp @@ -18,7 +18,11 @@ // 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. +// that the C++-side static_assert(sizeof==128) alone cannot; +// 5. repeats steps 1-4 for the descriptor-set build of the same source +// (-DCRAFTER_GRAPHICS_DESCRIPTOR_SETS, shipped as ui-fused.comp.sets.spv +// for drivers without VK_EXT_descriptor_heap). Both builds are fed the +// same UIFusedHeader bytes, so both must keep the same block layout. // // glslang and spirv-val are invoked at runtime, so the test declares them as // required tools (see project.cpp). It needs no GPU device. @@ -136,52 +140,61 @@ int main() { 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; + // The heap build first, then the descriptor-set build of the same source. + struct Variant { std::string_view label; std::string_view define; std::string_view file; }; + constexpr Variant variants[] = { + { "descriptor-heap", "", "ui-fused.comp.spv" }, + { "descriptor-set", "-DCRAFTER_GRAPHICS_DESCRIPTOR_SETS ", "ui-fused.comp.sets.spv" }, + }; + for (const Variant& variant : variants) { + const fs::path spv = dir / variant.file; + + // 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 " + std::string(variant.define) + "-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 ({} build)", variant.label); + 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 {}", variant.file); + 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, {} build (push-constant vars: {}, member offsets pinned to UIFusedHeader, 128 bytes)", + variant.label, pcVars); } - - 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; }