test(ui): the descriptor-set fallback draws the same frame as the heap

UIFallbackParity renders one scene through both binding models and
compares the frames. It starts itself twice, once as is and once with
CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP, so the fallback cannot drift from
the heap path on a machine that never takes it. The scene reaches what
HelloUI does not: the four standalone kernels next to the fused one, a
sampled image with two samplers indexed per item, an image slot and a
sampler slot that are freed and registered again, a resize, an image
created with ImageVulkan::Create's default consumer stage, and a
generic Dispatch whose push payload is not a whole number of words.

On a driver without the heap both runs take the fallback, and the test
then only checks that the scene renders. Validation errors raised
while rendering fail a run, which only bites when
CRAFTER_GRAPHICS_VALIDATION is set. They are counted before the frame
capture because Window::SaveFrame raises one itself (#157).

UIFusedShader now also compiles and validates the descriptor-set build
of the fused kernel and pins its push-constant layout: both builds are
fed the same UIFusedHeader bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-06 17:28:46 +02:00
commit 2b6f90b12c
3 changed files with 513 additions and 43 deletions

View file

@ -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<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 {}", 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<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, {} build (push-constant vars: {}, member offsets pinned to UIFusedHeader, 128 bytes)",
variant.label, pcVars);
}
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;
}