Compare commits

..
24 changed files with 153 additions and 1425 deletions

View file

@ -3,9 +3,7 @@
Vulkan + WebGPU graphics library built around C++20 modules and
bindless heaps. Provides window management, ray tracing, and a
compute-shader-driven UI on a single, opinionated stack. Native
builds use Vulkan with `VK_EXT_descriptor_heap` (the compute/UI path
also runs without it, see
[below](#drivers-without-vk_ext_descriptor_heap)); `wasm32-*` builds
builds use Vulkan with `VK_EXT_descriptor_heap`; `wasm32-*` builds
target the browser via WebGPU and a DOM window backend.
## Backends
@ -81,10 +79,7 @@ shaded through an intersection shader with an any-hit cut-out.
`VK_EXT_descriptor_heap`; pipelines are created with
`VK_PIPELINE_CREATE_2_DESCRIPTOR_HEAP_BIT_EXT` so there are no
descriptor-set layouts and push constants travel via
`vkCmdPushDataEXT`. The heap, ray tracing and device-fault extensions
are optional groups, detected at start-up
(`Device::descriptorHeapSupported`, `rayTracingSupported`,
`deviceFaultSupported`).
`vkCmdPushDataEXT`.
- **DescriptorHeapVulkan / DescriptorHeapWebGPU** — bindless slot
allocators. Vulkan side allocates image/buffer/sampler slots in a
`VK_EXT_descriptor_heap`; WebGPU side resolves slots to JS-side
@ -113,13 +108,12 @@ shaded through an intersection shader with an any-hit cut-out.
[WAVEFRONT-DESIGN.md](WAVEFRONT-DESIGN.md).
- **ComputeShader / WebGPUComputeShader** — Tier 1 wrapper used by the
UI system. Vulkan loads a `.spv` and dispatches with
`vkCmdPushDataEXT` (or the `.sets.spv` build and `vkCmdPushConstants`
without the heap); WebGPU loads a user-supplied `.wgsl` blob at
`vkCmdPushDataEXT`; WebGPU loads a user-supplied `.wgsl` blob at
runtime via `wgpuLoadCustomShader`. Use it directly for any custom
compute.
- **UI** — three-tier UI system; see below. The standard shaders (four
kernels plus the fused one) ship as `.spv` / `.sets.spv` pairs on
native and as WGSL strings baked into the WebGPU dispatcher.
- **UI** — three-tier UI system; see below. The standard shaders ship
as four `.spv` blobs on native and four WGSL strings baked into the
WebGPU dispatcher.
- **FontAtlas** — single-channel SDF atlas (1024×1024, 32pt base,
shelf-packed, lazy `Ensure` per codepoint, dirty-flush via `Update`).
Backend-agnostic.
@ -174,51 +168,6 @@ overhead. If you need to render thousands of UI items, you want a
different shader (tile binning, per-item-list resolve), not more
dispatches.
### Drivers without `VK_EXT_descriptor_heap`
The compute/UI path also runs on Vulkan drivers that lack
`VK_EXT_descriptor_heap` (Mesa Turnip on Qualcomm Adreno, for one).
`Device::Initialize` detects this and sets
`Device::descriptorHeapSupported = false`. The same slots then live in
one bindless descriptor set per frame instead of the heap, and push
constants go through `vkCmdPushConstants`. Slot handles, item structs,
`UIDispatchHeader` and the `UIRenderer` API are unchanged, so
application code does not branch on it. Ray tracing is heap-only: on
such a driver `Device::rayTracingSupported` is false and the RT entry
points throw.
Each compute shader is shipped in two builds of the same source:
`<name>.spv` for the heap and `<name>.sets.spv` for descriptor sets.
`ComputeShader::Load(path, heap)` takes the `.spv` path and picks the
right file itself. For a custom shader that means three things:
- Declare bindless resources with the `UI_HEAP_*` macros from
[shaders/ui-shared.glsl](shaders/ui-shared.glsl), not with a literal
`descriptor_heap`:
```glsl
layout(UI_HEAP_BUFFERS, std430) readonly buffer MyBuf { MyItem items[]; } myHeap[];
```
- Register the shader twice in `project.cpp`, the second time with the
define and the variant name:
```cpp
cfg.shaders.emplace_back(fs::path("my.comp.glsl"), std::string("main"), ShaderType::Compute);
Shader& sets = cfg.shaders.emplace_back(fs::path("my.comp.glsl"), std::string("main"), ShaderType::Compute);
sets.defines = {"CRAFTER_GRAPHICS_DESCRIPTOR_SETS"};
sets.variant = "sets";
```
- Keep the push-constant block within the device's
`maxPushConstantsSize` (256 bytes on common drivers). Without the
heap a larger payload throws in `ComputeShader::Dispatch`.
Set the environment variable `CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP` to
force the descriptor-set path on a driver that does have the heap; the
`UIFallbackParity` test uses it to compare the two paths frame for
frame.
## Build
The repository is built with `crafter-build` (a project-config based

View file

@ -21,10 +21,7 @@ struct InverseCircleItem {
vec4 centerRadius;
};
// UI_HEAP_BUFFERS (ui-shared.glsl) instead of a literal `descriptor_heap`:
// this file is compiled twice, and the macro picks the heap or the
// descriptor-set binding to match.
layout(UI_HEAP_BUFFERS, std430) readonly buffer InvCircleBuf {
layout(descriptor_heap, std430) readonly buffer InvCircleBuf {
InverseCircleItem items[];
} invCircleHeap[];

View file

@ -54,11 +54,7 @@ int main() {
// read from `hdr.itemBuffer` in the push data.
GraphicsComputeShader inverseCircle;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
// The heap argument ties the pipeline to this heap's binding model. On a
// driver without VK_EXT_descriptor_heap, Load picks
// inverse-circle.comp.sets.spv (built from the same source, see
// project.cpp) on its own.
inverseCircle.Load("inverse-circle.comp.spv", heap);
inverseCircle.Load("inverse-circle.comp.spv");
#else
UICustomBinding invBindings[] = {
{ .group = 2,

View file

@ -47,13 +47,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
} else {
cfg.shaders.emplace_back(fs::path("inverse-circle.comp.glsl"),
std::string("main"), ShaderType::Compute);
// Second build of the same source for drivers without
// VK_EXT_descriptor_heap: inverse-circle.comp.sets.spv. ComputeShader::Load
// picks it at runtime, so a custom shader needs both to run everywhere.
Shader& sets = cfg.shaders.emplace_back(fs::path("inverse-circle.comp.glsl"),
std::string("main"), ShaderType::Compute);
sets.defines = {"CRAFTER_GRAPHICS_DESCRIPTOR_SETS"};
sets.variant = "sets";
}
return cfg;
}

View file

@ -125,7 +125,7 @@ int main() {
}
ComputeShader aabbWriter;
aabbWriter.Load("aabbs.comp.spv", descriptorHeap);
aabbWriter.Load("aabbs.comp.spv");
struct AabbPush { VkDeviceAddress boxes; std::uint32_t count; float time; } push {
aabbDevice.address, kBoxCount, 0.0f };
aabbWriter.Dispatch(cmd, &push, sizeof(push), (kBoxCount + 63) / 64);

View file

@ -7,14 +7,12 @@ module Crafter.Graphics:ComputeShader_impl;
import :ComputeShader;
import :ShaderVulkan;
import :Device;
import :DescriptorHeapVulkan;
import std;
using namespace Crafter;
ComputeShader::ComputeShader(ComputeShader&& other) noexcept
: pipeline(other.pipeline),
layout(other.layout),
workaroundNeedsTlas(other.workaroundNeedsTlas),
workaroundTlasPushOffset(other.workaroundTlasPushOffset) {
other.pipeline = VK_NULL_HANDLE;
@ -26,7 +24,6 @@ ComputeShader& ComputeShader::operator=(ComputeShader&& other) noexcept {
vkDestroyPipeline(Device::device, pipeline, nullptr);
}
pipeline = other.pipeline;
layout = other.layout;
workaroundNeedsTlas = other.workaroundNeedsTlas;
workaroundTlasPushOffset = other.workaroundTlasPushOffset;
other.pipeline = VK_NULL_HANDLE;
@ -41,44 +38,7 @@ ComputeShader::~ComputeShader() {
}
}
void ComputeShader::Load(const std::filesystem::path& spvPath, DescriptorHeapVulkan& heap) {
if (!Device::descriptorHeapSupported) {
// Sets mode. The heap build of the shader uses SPV_EXT_descriptor_heap
// and untyped pointers, which this device cannot consume, so load the
// descriptor-set build compiled from the same source instead.
std::filesystem::path setsPath = spvPath;
setsPath.replace_extension("sets.spv");
// VulkanShader only prints when a file is missing and then creates an
// empty module, which surfaces as an unrelated driver error. A missing
// variant is the likely mistake when a bundle is copied to a device
// without the heap, so name it.
if (!std::filesystem::exists(setsPath)) {
throw std::runtime_error(std::format(
"ComputeShader::Load: this device has no VK_EXT_descriptor_heap, so the descriptor-set build "
"of the shader is required, but {} does not exist. Build the shader a second time with "
"CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined and ship it next to {}.",
setsPath.string(), spvPath.string()));
}
if (heap.pipelineLayout == VK_NULL_HANDLE) {
throw std::runtime_error("ComputeShader::Load: the descriptor heap must be Initialize()d before a shader is loaded against it");
}
VulkanShader shader(setsPath, "main", VK_SHADER_STAGE_COMPUTE_BIT, nullptr);
layout = heap.pipelineLayout;
VkComputePipelineCreateInfo info {
.sType = VK_STRUCTURE_TYPE_COMPUTE_PIPELINE_CREATE_INFO,
.stage = {
.sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO,
.stage = VK_SHADER_STAGE_COMPUTE_BIT,
.module = shader.shader,
.pName = "main",
},
.layout = layout,
};
Device::CheckVkResult(vkCreateComputePipelines(
Device::device, Device::pipelineCache, 1, &info, nullptr, &pipeline));
return;
}
void ComputeShader::Load(const std::filesystem::path& spvPath) {
VulkanShader shader(spvPath, "main", VK_SHADER_STAGE_COMPUTE_BIT, nullptr);
// NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): remember
@ -118,38 +78,12 @@ void ComputeShader::Dispatch(VkCommandBuffer cmd,
VkDeviceAddress tlasAddress) const {
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline);
if (push != nullptr && pushBytes > 0) {
if (!Device::descriptorHeapSupported) {
// Sets mode: the same bytes go through the shared layout's
// push-constant range, which DescriptorHeapVulkan sized to the
// device limit rounded down to whole words.
const std::uint32_t limit = Device::deviceProperties.limits.maxPushConstantsSize & ~3u;
const std::uint32_t wholeWords = (pushBytes + 3u) & ~3u;
if (wholeWords > limit) {
throw std::length_error(std::format(
"ComputeShader::Dispatch: {} bytes of push constants exceed this device's limit of {}",
pushBytes, limit));
}
if (wholeWords == pushBytes) {
vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, pushBytes, push);
} else {
// vkCmdPushConstants only takes whole words, and the caller's
// buffer cannot be read past its end, so push a padded copy.
// vkCmdPushDataEXT has the same rule, but nothing enforces it
// on the heap path and drivers accept a ragged size there;
// padding keeps such a payload from failing only on a device
// without the heap.
std::vector<std::byte> padded(wholeWords);
std::memcpy(padded.data(), push, pushBytes);
vkCmdPushConstants(cmd, layout, VK_SHADER_STAGE_COMPUTE_BIT, 0, wholeWords, padded.data());
}
} else {
VkPushDataInfoEXT pushInfo {
.sType = VK_STRUCTURE_TYPE_PUSH_DATA_INFO_EXT,
.offset = 0,
.data = { .address = const_cast<void*>(push), .size = pushBytes },
};
Device::vkCmdPushDataEXT(cmd, &pushInfo);
}
VkPushDataInfoEXT pushInfo {
.sType = VK_STRUCTURE_TYPE_PUSH_DATA_INFO_EXT,
.offset = 0,
.data = { .address = const_cast<void*>(push), .size = pushBytes },
};
Device::vkCmdPushDataEXT(cmd, &pushInfo);
}
// NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): if this shader
// ray-queries the TLAS through the heap it was rewritten to read the TLAS

View file

@ -41,31 +41,19 @@ const char* const instanceExtensionNames[] = {
"VK_KHR_wayland_surface"
#endif
};
// Device extensions are split by what breaks without them. Only the first
// group is needed to put UI pixels on screen; Device::Initialize enables each
// of the others only when the driver advertises all of its members, and
// records the outcome in Device::descriptorHeapSupported / rayTracingSupported
// / deviceFaultSupported. Mesa Turnip (Adreno), for one, has none of the heap,
// ray tracing or fault extensions.
const char* const requiredDeviceExtensionNames[] = {
const char* const deviceExtensionNames[] = {
"VK_KHR_swapchain",
"VK_KHR_spirv_1_4",
"VK_KHR_shader_float_controls",
"VK_KHR_maintenance5",
};
// Without these, descriptors come from a bindless descriptor set instead
// (DescriptorHeapVulkan's sets mode) and shaders load their .sets.spv build.
const char* const descriptorHeapExtensionNames[] = {
"VK_EXT_descriptor_heap",
"VK_KHR_shader_untyped_pointers",
};
// Ray tracing additionally needs the descriptor heap: every RT pipeline and
// RT shader in this library is heap-only.
const char* const rayTracingExtensionNames[] = {
"VK_KHR_acceleration_structure",
"VK_KHR_ray_tracing_pipeline",
"VK_KHR_ray_query",
"VK_EXT_shader_atomic_float",
"VK_EXT_descriptor_heap",
"VK_KHR_deferred_host_operations",
"VK_KHR_maintenance5",
"VK_KHR_shader_untyped_pointers",
"VK_EXT_device_fault"
};
const char* const layerNames[] = {
"VK_LAYER_KHRONOS_validation"
@ -75,9 +63,7 @@ const char* const layerNames[] = {
void Device::CheckVkResult(VkResult result) {
if (result != VK_SUCCESS)
{
// The fault report needs VK_EXT_device_fault; without it the entry
// point below is not callable and the bare VkResult is all there is.
if(result == VK_ERROR_DEVICE_LOST && deviceFaultSupported) {
if(result == VK_ERROR_DEVICE_LOST) {
VkDeviceFaultCountsEXT faultCounts = {
.sType = VK_STRUCTURE_TYPE_DEVICE_FAULT_COUNTS_EXT,
.pNext = NULL,
@ -133,26 +119,6 @@ void Device::CheckVkResult(VkResult result) {
}
}
void Device::RequireRayTracing() {
if (rayTracingSupported) {
return;
}
// Thrown at the first ray tracing entry point (an RT-stage shader module,
// a BLAS/TLAS build, the SBT, the pipeline) so the failure names the
// cause. Letting the call through would hand heap-only SPIR-V or an
// unloaded entry point to a driver that never enabled the feature.
if (descriptorHeapSupported) {
throw std::runtime_error(
"Crafter.Graphics: ray tracing is unavailable: the Vulkan driver lacks one of "
"VK_KHR_acceleration_structure, VK_KHR_ray_tracing_pipeline, VK_KHR_ray_query, "
"VK_KHR_deferred_host_operations");
}
throw std::runtime_error(
"Crafter.Graphics: ray tracing is unavailable: it needs VK_EXT_descriptor_heap and "
"VK_KHR_shader_untyped_pointers, which this driver lacks (or "
"CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP is set). Only the compute/UI path runs here.");
}
bool Device::PipelineCacheDataCompatible(std::span<const std::byte> data) {
// VkPipelineCacheHeaderVersionOne is a fixed 32-byte little-endian header
// (Vulkan spec 16.5.2): u32 headerSize, u32 headerVersion, u32 vendorID,
@ -685,130 +651,36 @@ void Device::Initialize() {
}
}
// Enumerate the driver's device extensions once; every optional capability
// below is decided from this set. Drivers without
// VK_EXT_memory_decompression (AMD, Intel as of early 2026) get the
// CPU-decode fallback; drivers without the descriptor heap (Mesa Turnip)
// get the descriptor-set path and no ray tracing.
std::unordered_set<std::string> availableExtensions;
// Enumerate available device extensions so we can opt into
// VK_EXT_memory_decompression when the driver advertises it. Drivers
// without it (AMD, Intel as of early 2026) get the CPU-decode fallback.
{
std::uint32_t extCount = 0;
vkEnumerateDeviceExtensionProperties(physDevice, nullptr, &extCount, nullptr);
std::vector<VkExtensionProperties> exts(extCount);
vkEnumerateDeviceExtensionProperties(physDevice, nullptr, &extCount, exts.data());
for (const VkExtensionProperties& e : exts) {
availableExtensions.emplace(e.extensionName);
}
}
const auto hasAll = [&availableExtensions](std::span<const char* const> names) {
return std::ranges::all_of(names, [&](const char* name) { return availableExtensions.contains(name); });
};
// Fail here, naming the gap, rather than in vkCreateDevice with a bare
// VK_ERROR_EXTENSION_NOT_PRESENT.
{
std::string missing;
for (const char* name : requiredDeviceExtensionNames) {
if (!availableExtensions.contains(name)) {
missing += ' ';
missing += name;
if (std::strcmp(e.extensionName, VK_EXT_MEMORY_DECOMPRESSION_EXTENSION_NAME) == 0) {
memoryDecompressionSupported = true;
break;
}
}
if (!missing.empty()) {
throw std::runtime_error(std::format("Crafter.Graphics: the Vulkan driver lacks required device extension(s):{}", missing));
}
}
// CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the descriptor-set path on a
// driver that does have the heap, so the fallback can be exercised on a
// desktop GPU. It takes ray tracing down with it, exactly as on a driver
// that really lacks the extension.
//
// A listed extension is not the whole answer: a driver may list one whose
// feature bit is still off. Query the feature bits of the optional groups
// whose extensions are present (only those structs are chained) and
// require both, so such a driver takes the fallback instead of failing
// in vkCreateDevice.
const bool heapExtensions = hasAll(descriptorHeapExtensionNames);
const bool rayTracingExtensions = hasAll(rayTracingExtensionNames);
const bool faultExtension = availableExtensions.contains(VK_EXT_DEVICE_FAULT_EXTENSION_NAME);
VkPhysicalDeviceDescriptorHeapFeaturesEXT heapQuery {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_FEATURES_EXT,
};
VkPhysicalDeviceShaderUntypedPointersFeaturesKHR untypedPointersQuery {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_UNTYPED_POINTERS_FEATURES_KHR,
};
VkPhysicalDeviceAccelerationStructureFeaturesKHR accelerationStructureQuery {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_FEATURES_KHR,
};
VkPhysicalDeviceRayTracingPipelineFeaturesKHR rayTracingPipelineQuery {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_FEATURES_KHR,
};
VkPhysicalDeviceRayQueryFeaturesKHR rayQueryQuery {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_QUERY_FEATURES_KHR,
};
VkPhysicalDeviceFaultFeaturesEXT faultQuery {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FAULT_FEATURES_EXT,
};
VkPhysicalDeviceFeatures2 featureQuery {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
};
const auto ask = [&featureQuery](auto& features) {
features.pNext = featureQuery.pNext;
featureQuery.pNext = &features;
};
if (heapExtensions) {
ask(heapQuery);
ask(untypedPointersQuery);
}
if (rayTracingExtensions) {
ask(accelerationStructureQuery);
ask(rayTracingPipelineQuery);
ask(rayQueryQuery);
}
if (faultExtension) {
ask(faultQuery);
}
vkGetPhysicalDeviceFeatures2(physDevice, &featureQuery);
descriptorHeapSupported = heapExtensions
&& heapQuery.descriptorHeap && untypedPointersQuery.shaderUntypedPointers
&& std::getenv("CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP") == nullptr;
rayTracingSupported = descriptorHeapSupported && rayTracingExtensions
&& accelerationStructureQuery.accelerationStructure
&& rayTracingPipelineQuery.rayTracingPipeline
&& rayQueryQuery.rayQuery;
deviceFaultSupported = faultExtension && faultQuery.deviceFault;
memoryDecompressionSupported = availableExtensions.contains(VK_EXT_MEMORY_DECOMPRESSION_EXTENSION_NAME);
const bool atomicFloatSupported = availableExtensions.contains(VK_EXT_SHADER_ATOMIC_FLOAT_EXTENSION_NAME);
// RAY_TRACING_SHADER is not a valid pipeline stage unless the ray tracing
// pipeline feature is enabled; barrier code masks its stage flags with this.
stageMask = rayTracingSupported
? ~VkPipelineStageFlags{0}
: ~VkPipelineStageFlags{VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR};
// Properties query, built back to front. A struct is chained only when its
// extension is being used: an unrecognized sType is a validation error on
// drivers that don't expose the extension, and the heap properties must
// stay zero when the heap is forced off. Driver properties are core and
// always queried; they identify the NVIDIA proprietary driver for the
// descriptor-heap AS-read workaround (issue #15 / #7).
void* propertiesChain = &driverProperties;
if (descriptorHeapSupported) {
descriptorHeapProperties.pNext = propertiesChain;
propertiesChain = &descriptorHeapProperties;
}
if (rayTracingSupported) {
rayTracingProperties.pNext = propertiesChain;
propertiesChain = &rayTracingProperties;
}
// Properties query: chain memory-decompression props only when supported,
// otherwise sType validation flags it as an unrecognized struct on
// drivers that don't expose the extension.
if (memoryDecompressionSupported) {
memoryDecompressionProperties.pNext = propertiesChain;
propertiesChain = &memoryDecompressionProperties;
memoryDecompressionProperties.pNext = const_cast<void*>(rayTracingProperties.pNext);
rayTracingProperties.pNext = &memoryDecompressionProperties;
}
// Chain driver properties onto the tail of the query so we can detect
// the NVIDIA proprietary driver for the descriptor-heap AS-read
// workaround (issue #15 / #7).
descriptorHeapProperties.pNext = &driverProperties;
VkPhysicalDeviceProperties2 properties2 {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,
.pNext = propertiesChain
.pNext = &rayTracingProperties
};
vkGetPhysicalDeviceProperties2(physDevice, &properties2);
// Keep the core properties around for the pipeline-cache identity check
@ -822,13 +694,8 @@ void Device::Initialize() {
// NVIDIA's brand-new VK_EXT_descriptor_heap acceleration-structure read
// path faults (see #7); enable the SPIR-V rewrite workaround there. Other
// drivers (and any future fixed NVIDIA driver, once this gate is removed)
// take the normal heap-bound AS path unchanged. The workaround is about
// the heap, so it is off whenever the heap is: otherwise a forced-fallback
// run on NVIDIA would still rewrite SPIR-V, enable shaderInt64 and push the
// TLAS address through vkCmdPushDataEXT, none of which a real heap-less
// driver does.
workaroundDescriptorHeapAS = descriptorHeapSupported
&& driverProperties.driverID == VK_DRIVER_ID_NVIDIA_PROPRIETARY;
// take the normal heap-bound AS path unchanged.
workaroundDescriptorHeapAS = (driverProperties.driverID == VK_DRIVER_ID_NVIDIA_PROPRIETARY);
// Sanity-gate: GDeflate 1.0 must actually be in the supported method set.
if (memoryDecompressionSupported &&
@ -860,18 +727,24 @@ void Device::Initialize() {
queueCreateInfo.queueCount = 1;
queueCreateInfo.pQueuePriorities = &priority;
// Feature structs below are declared without a pNext and linked into one
// chain afterwards, each only when its extension is being enabled: a
// feature struct for an extension that is not enabled is invalid, and
// which ones are enabled now depends on the driver.
VkPhysicalDeviceFaultFeaturesEXT faultFeatures2 = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FAULT_FEATURES_EXT,
};
VkPhysicalDeviceFeatures2 features22 = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
.pNext = &faultFeatures2,
};
vkGetPhysicalDeviceFeatures2(physDevice, &features22);
VkPhysicalDeviceFaultFeaturesEXT faultFeatures = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FAULT_FEATURES_EXT,
.deviceFault = VK_TRUE,
.deviceFaultVendorBinary = faultQuery.deviceFaultVendorBinary,
.deviceFaultVendorBinary = faultFeatures2.deviceFaultVendorBinary,
};
VkPhysicalDeviceShaderUntypedPointersFeaturesKHR untypedPointersFeatures {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_SHADER_UNTYPED_POINTERS_FEATURES_KHR,
.pNext = &faultFeatures,
.shaderUntypedPointers = VK_TRUE,
};
@ -879,38 +752,37 @@ void Device::Initialize() {
// VK_ACCESS_2_*) — required for VK_EXT_memory_decompression's sync tokens.
VkPhysicalDeviceVulkan13Features features13 {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_3_FEATURES,
.pNext = &untypedPointersFeatures,
.synchronization2 = VK_TRUE,
};
VkPhysicalDeviceMemoryDecompressionFeaturesEXT memoryDecompressionFeatures {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_DECOMPRESSION_FEATURES_EXT,
.pNext = &features13,
.memoryDecompression = VK_TRUE,
};
void* postDecompressChain = memoryDecompressionSupported
? static_cast<void*>(&memoryDecompressionFeatures)
: static_cast<void*>(&features13);
VkPhysicalDeviceDescriptorHeapFeaturesEXT desciptorHeapFeatures {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_FEATURES_EXT,
.pNext = postDecompressChain,
.descriptorHeap = VK_TRUE,
};
// Every VulkanBuffer is created with usage = 0 plus a
// VkBufferUsageFlags2CreateInfo, and heap pipelines carry a
// VkPipelineCreateFlags2CreateInfo. Both are maintenance5 functionality,
// which needs the feature bit as well as the extension name.
VkPhysicalDeviceMaintenance5Features maintenance5Features {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MAINTENANCE_5_FEATURES,
.maintenance5 = VK_TRUE,
};
VkPhysicalDevice16BitStorageFeatures bit16 {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_16BIT_STORAGE_FEATURES,
.pNext = &desciptorHeapFeatures,
.storageBuffer16BitAccess = VK_TRUE,
};
VkPhysicalDeviceVulkan12Features features12 {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_VULKAN_1_2_FEATURES,
.pNext = &bit16,
.shaderFloat16 = VK_TRUE,
// Bindless / runtime descriptor array indexing — needed by both
// binding models (heap and descriptor sets).
// Bindless / runtime descriptor array indexing — needed for the
// descriptor_heap shader path.
.shaderUniformBufferArrayNonUniformIndexing = VK_TRUE,
.shaderSampledImageArrayNonUniformIndexing = VK_TRUE,
.shaderStorageBufferArrayNonUniformIndexing = VK_TRUE,
@ -919,64 +791,28 @@ void Device::Initialize() {
.scalarBlockLayout = VK_TRUE,
.bufferDeviceAddress = VK_TRUE
};
if (!descriptorHeapSupported) {
// Descriptor-set path (DescriptorHeapVulkan sets mode): one bindless
// set whose slots are written while earlier frames may still be
// executing with it bound, and where most slots are never written.
// The sampled-image bit also covers SAMPLER descriptors.
features12.descriptorIndexing = VK_TRUE;
features12.descriptorBindingSampledImageUpdateAfterBind = VK_TRUE;
features12.descriptorBindingStorageImageUpdateAfterBind = VK_TRUE;
features12.descriptorBindingStorageBufferUpdateAfterBind = VK_TRUE;
features12.descriptorBindingUpdateUnusedWhilePending = VK_TRUE;
features12.descriptorBindingPartiallyBound = VK_TRUE;
}
VkPhysicalDeviceRayQueryFeaturesKHR physicalDeviceRayQueryFeatures{
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_QUERY_FEATURES_KHR,
.pNext = &features12,
.rayQuery = VK_TRUE
};
VkPhysicalDeviceRayTracingPipelineFeaturesKHR physicalDeviceRayTracingPipelineFeatures{
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_FEATURES_KHR,
.pNext = &physicalDeviceRayQueryFeatures,
.rayTracingPipeline = VK_TRUE
};
VkPhysicalDeviceAccelerationStructureFeaturesKHR deviceAccelerationStructureFeature = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_ACCELERATION_STRUCTURE_FEATURES_KHR,
.pNext = &physicalDeviceRayTracingPipelineFeatures,
.accelerationStructure = VK_TRUE
};
void* featureChain = nullptr;
const auto link = [&featureChain](auto& features) {
features.pNext = featureChain;
featureChain = &features;
};
if (deviceFaultSupported) {
link(faultFeatures);
}
if (descriptorHeapSupported) {
link(untypedPointersFeatures);
}
link(features13);
if (memoryDecompressionSupported) {
link(memoryDecompressionFeatures);
}
if (descriptorHeapSupported) {
link(desciptorHeapFeatures);
}
link(maintenance5Features);
link(bit16);
link(features12);
if (rayTracingSupported) {
link(physicalDeviceRayQueryFeatures);
link(physicalDeviceRayTracingPipelineFeatures);
link(deviceAccelerationStructureFeature);
}
VkPhysicalDeviceFeatures2 physical_features2 = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2,
.pNext = featureChain,
.pNext = &deviceAccelerationStructureFeature,
.features = {
// Order matches VkPhysicalDeviceFeatures declaration so the
// designated-initializer-order warning stays quiet.
@ -997,23 +833,12 @@ void Device::Initialize() {
}
};
// The enabled-extension list is the required set plus each optional group
// the driver advertises in full (decided above from the enumeration).
// Build the enabled-extension list dynamically so we can append the
// optional VK_EXT_memory_decompression entry only when the driver
// advertises it.
std::vector<const char*> enabledDeviceExtensions(
std::begin(requiredDeviceExtensionNames),
std::end(requiredDeviceExtensionNames));
if (descriptorHeapSupported) {
enabledDeviceExtensions.append_range(descriptorHeapExtensionNames);
}
if (rayTracingSupported) {
enabledDeviceExtensions.append_range(rayTracingExtensionNames);
}
if (deviceFaultSupported) {
enabledDeviceExtensions.push_back(VK_EXT_DEVICE_FAULT_EXTENSION_NAME);
}
if (atomicFloatSupported) {
enabledDeviceExtensions.push_back(VK_EXT_SHADER_ATOMIC_FLOAT_EXTENSION_NAME);
}
std::begin(deviceExtensionNames),
std::end(deviceExtensionNames));
if (memoryDecompressionSupported) {
enabledDeviceExtensions.push_back(VK_EXT_MEMORY_DECOMPRESSION_EXTENSION_NAME);
}

View file

@ -191,9 +191,6 @@ namespace {
}
void Mesh::Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32_t> indicies, VkCommandBuffer cmd, RTBuildOptions options) {
// Every Build* records an acceleration-structure build; without ray
// tracing those entry points are not loaded. Fail with the reason.
Device::RequireRayTracing();
// Place both inputs in device-local memory (direct map on ReBAR/UMA,
// staged copy otherwise — UploadDeviceLocal decides) and barrier the
// upload before the BLAS build reads them. Replaces the previous
@ -208,7 +205,6 @@ void Mesh::Build(std::span<Vector<float, 3, 3>> verticies, std::span<std::uint32
}
void Mesh::Build(const CompressedMeshAsset& asset, VkCommandBuffer cmd, RTBuildOptions options) {
Device::RequireRayTracing();
if (!Device::memoryDecompressionSupported) {
// CPU fallback: decompress into temporary host vectors, then take
// the existing uncompressed path. The data region is decompressed
@ -337,7 +333,6 @@ namespace {
}
void Mesh::BuildProcedural(std::span<const RTAabb> aabbs, bool opaque, VkCommandBuffer cmd, RTBuildOptions options) {
Device::RequireRayTracing();
this->opaque = opaque;
allowUpdate = options.allowUpdate;
builtInputCount = static_cast<std::uint32_t>(aabbs.size());
@ -346,7 +341,6 @@ void Mesh::BuildProcedural(std::span<const RTAabb> aabbs, bool opaque, VkCommand
}
void Mesh::BuildProcedural(VkDeviceAddress aabbAddress, std::uint32_t count, bool opaque, VkCommandBuffer cmd, RTBuildOptions options, std::uint32_t stride) {
Device::RequireRayTracing();
// Zero-copy procedural build: the AABBs already live in `aabbAddress`
// (a device buffer a GPU compute pass wrote). Nothing touches
// self.aabbBuffer — the build input is the producer's buffer directly.

View file

@ -32,7 +32,6 @@ void RenderingElement3D::Remove(RenderingElement3D* e) {
}
void RenderingElement3D::BuildTLAS(VkCommandBuffer cmd, std::uint32_t index, RTBuildPreference preference) {
Device::RequireRayTracing();
auto& tlas = tlases[index];
const std::uint32_t primitiveCount = static_cast<std::uint32_t>(elements.size());

View file

@ -30,11 +30,11 @@ void UIRenderer::Initialize(Window& window, GraphicsDescriptorHeap& heap, Graphi
heap_ = &heap;
// Load the four standard pipelines plus the fused uber-kernel (issue #47).
drawQuads.Load(quadsSpv, heap);
drawCircles.Load(circlesSpv, heap);
drawImages.Load(imagesSpv, heap);
drawText.Load(textSpv, heap);
drawFused.Load(fusedSpv, heap);
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.
@ -275,16 +275,6 @@ void UIRenderer::Dispatch(GraphicsCommandBuffer cmd, const GraphicsComputeShader
// ─── descriptor writes ─────────────────────────────────────────────────
void UIRenderer::WriteSwapchainDescriptors() {
if (!Device::descriptorHeapSupported) {
// Sets mode: each frame's descriptor set holds ITS swapchain image at
// outImageSlot_, through a view the heap object creates and owns.
for (std::uint8_t f = 0; f < Window::numFrames; ++f) {
heap_->WriteImageSet(f, outImageSlot_, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE,
window_->imageViews[f], VK_IMAGE_LAYOUT_GENERAL);
}
return;
}
// Each per-frame heap holds ITS swapchain image at outImageSlot_.
std::array<VkImageDescriptorInfoEXT, Window::numFrames> infos{};
std::array<VkResourceDescriptorInfoEXT, Window::numFrames> resources{};
@ -344,11 +334,6 @@ void UIRenderer::WriteFontAtlasDescriptor() {
void UIRenderer::WriteSampledImageDescriptor(std::uint16_t slot,
const VkImageViewCreateInfo& viewInfo,
VkImageLayout layout) {
if (!Device::descriptorHeapSupported) {
heap_->WriteImageSets(slot, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, viewInfo, layout);
return;
}
std::array<VkImageDescriptorInfoEXT, Window::numFrames> infos{};
std::array<VkResourceDescriptorInfoEXT, Window::numFrames> resources{};
std::array<VkHostAddressRangeEXT, Window::numFrames> destinations{};
@ -379,14 +364,7 @@ void UIRenderer::WriteSampledImageDescriptor(std::uint16_t slot,
for (auto& h : heap_->resourceHeap) h.FlushDevice(off, sz);
}
void UIRenderer::WriteBufferDescriptor(std::uint16_t slot, VkBuffer buffer, VkDeviceAddress address, std::uint32_t size) {
if (!Device::descriptorHeapSupported) {
// A descriptor set names the buffer by handle; the heap below names
// it by device address.
heap_->WriteBufferSets(slot, buffer);
return;
}
void UIRenderer::WriteBufferDescriptor(std::uint16_t slot, VkDeviceAddress address, std::uint32_t size) {
std::array<VkDeviceAddressRangeEXT, Window::numFrames> ranges{};
std::array<VkResourceDescriptorInfoEXT, Window::numFrames> resources{};
std::array<VkHostAddressRangeEXT, Window::numFrames> destinations{};
@ -415,14 +393,6 @@ void UIRenderer::WriteBufferDescriptor(std::uint16_t slot, VkBuffer buffer, VkDe
SamplerSlot UIRenderer::RegisterSampler(const VkSamplerCreateInfo& info) {
auto range = heap_->AllocateSamplerSlots(1);
if (!Device::descriptorHeapSupported) {
// The handle owns the slot before the sampler is created, so a
// failed vkCreateSampler returns it to the freelist.
SamplerSlot slot{heap_, range.firstElement};
heap_->WriteSamplerSets(range.firstElement, info);
return slot;
}
std::array<VkSamplerCreateInfo, Window::numFrames> infos{};
std::array<VkHostAddressRangeEXT, Window::numFrames> destinations{};
for (std::uint32_t f = 0; f < Window::numFrames; ++f) {

View file

@ -833,11 +833,7 @@ void Window::Render() {
// whole pipeline, so the frame-edge barriers wait on exactly those stages
// instead of ALL_COMMANDS. Derived once here and reused for the present
// barrier below (passes don't change within a Render()).
// Device::stageMask drops RAY_TRACING_SHADER on devices without ray
// tracing: the no-pass fallback and any un-narrowed pass report the
// conservative writer union, which names that stage, and a stage whose
// feature is not enabled is invalid in a barrier.
const VkPipelineStageFlags swapWriterStages = SwapchainStageUnion(passes) & Device::stageMask;
const VkPipelineStageFlags swapWriterStages = SwapchainStageUnion(passes);
// dstAccessMask must only contain access flags supported by the dst stage
// mask (VUID-vkCmdPipelineBarrier-pImageMemoryBarriers-02820). Derive it
@ -860,15 +856,7 @@ void Window::Render() {
// buffer dictates GPU execution order, so the bind must come first.
// Pass-side dispatches still run with the same heaps bound — moving
// the bind earlier doesn't change anything for them.
if (descriptorHeap && !Device::descriptorHeapSupported) {
// Sets mode: the heap object holds one bindless descriptor set per
// frame instead of heap buffers. Every compute pipeline loaded against
// this heap shares its pipeline layout, so one bind here stays valid
// across all of them, exactly like the heap bind below.
vkCmdBindDescriptorSets(drawCmdBuffers[currentBuffer], VK_PIPELINE_BIND_POINT_COMPUTE,
descriptorHeap->pipelineLayout, 0, 1,
&descriptorHeap->sets[currentBuffer], 0, nullptr);
} else if (descriptorHeap) {
if (descriptorHeap) {
// Rebuild the cached per-frame bind structs only when the heap changes.
// The address/size of each frame's heap and the reservedRangeOffset
// bitmask are stable for a given heap, so this runs once per assignment
@ -930,11 +918,8 @@ void Window::Render() {
// serialises COMPUTE_SHADER while an RT pass on either side pulls in
// RAY_TRACING_SHADER — never the ALL_COMMANDS full-pipeline stall.
VkImageMemoryBarrier ib = BuildSwapchainInterPassBarrier(images[currentBuffer]);
// Masked like swapWriterStages above: a pass that keeps the
// default stage reports a union that names RAY_TRACING_SHADER.
vkCmdPipelineBarrier(drawCmdBuffers[currentBuffer],
passes[i]->SwapchainStage() & Device::stageMask,
passes[i + 1]->SwapchainStage() & Device::stageMask,
passes[i]->SwapchainStage(), passes[i + 1]->SwapchainStage(),
0, 0, nullptr, 0, nullptr, 1, &ib);
}
}
@ -1115,13 +1100,6 @@ void Window::CreateSwapchain()
}
uint32_t imageCount{ 0 };
Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, nullptr));
// images[] and every other per-frame array hold exactly numFrames
// entries. A driver may create more images than minImageCount asked for
// (a surface whose minimum is above numFrames must); writing them into
// images[] would run off its end, so refuse instead.
if (imageCount != numFrames) {
throw std::runtime_error(std::format("Window: the swapchain has {} images, but the frame loop is built for exactly {}", imageCount, numFrames));
}
// Get the swap chain images
Device::CheckVkResult(vkGetSwapchainImagesKHR(Device::device, swapChain, &imageCount, images));

View file

@ -9,29 +9,17 @@ export module Crafter.Graphics:ComputeShader;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :Device;
import :DescriptorHeapVulkan;
export namespace Crafter {
// Tier 1: thin compute-pipeline wrapper. Owns one VkPipeline. Use this to
// dispatch the four standard UI shaders and any user-authored compute
// shader that follows the ui-shared.glsl contract.
//
// The pipeline follows the device's binding model
// (Device::descriptorHeapSupported):
// heap -> created with VK_PIPELINE_CREATE_2_DESCRIPTOR_HEAP_BIT_EXT and
// no pipeline layout; the bindless heap supplies all bindings
// and push constants travel via vkCmdPushDataEXT.
// sets -> created from the shader's <name>.sets.spv build against the
// heap object's shared pipeline layout; the bindless descriptor
// set supplies the bindings and push constants travel via
// vkCmdPushConstants.
// Tier 1: thin compute-pipeline wrapper. Owns one VkPipeline created with
// VK_PIPELINE_CREATE_2_DESCRIPTOR_HEAP_BIT_EXT (no pipeline layout — the
// bindless heap supplies all bindings, push constants travel via
// vkCmdPushDataEXT). Use this to dispatch the four standard UI shaders
// and any user-authored compute shader that follows the ui-shared.glsl
// contract.
class ComputeShader {
public:
VkPipeline pipeline = VK_NULL_HANDLE;
// Sets mode only: the pipeline layout of the heap this shader was
// loaded against, needed again by vkCmdPushConstants. Not owned — it
// belongs to that DescriptorHeapVulkan. VK_NULL_HANDLE in heap mode.
VkPipelineLayout layout = VK_NULL_HANDLE;
// NVIDIA descriptor-heap AS-read workaround (issue #15 / #7): set by
// Load when this shader ray-queries the TLAS through the descriptor
@ -50,30 +38,14 @@ export namespace Crafter {
ComputeShader& operator=(ComputeShader&& other) noexcept;
~ComputeShader();
// Loads a SPIR-V compute shader from disk and creates its pipeline.
//
// `spvPath` always names the descriptor-heap build (<name>.spv). On a
// device without the heap, the <name>.sets.spv build next to it is
// loaded instead, so the caller never branches on the binding model;
// a missing .sets.spv throws, naming the file.
//
// `heap` must already be Initialize()d and must be the heap assigned
// to Window::descriptorHeap for every frame this shader is dispatched
// in: in sets mode the pipeline is built against that heap's layout,
// and Window::Render binds that heap's descriptor set.
void Load(const std::filesystem::path& spvPath, DescriptorHeapVulkan& heap);
// Loads a SPIR-V compute shader from disk and creates a pipeline that
// uses the bindless descriptor-heap binding model.
void Load(const std::filesystem::path& spvPath);
// Bind, push constants (if any), dispatch. Caller computes group counts
// and is responsible for any inter-dispatch barriers (UIRenderer::Dispatch
// wraps this with the standard write-after-write barrier).
//
// pushBytes should be a multiple of 4 (Vulkan requires it for both
// push mechanisms). On a device without the descriptor heap the
// payload travels as ordinary push constants, so it must also fit
// the device's maxPushConstantsSize (256 bytes on common drivers); a
// larger one throws std::length_error. Keep push blocks within that
// if the shader is meant to run on both binding models.
//
// tlasAddress is the NVIDIA descriptor-heap AS-read workaround hook
// (issue #15 / #7): a shader that ray-queries the TLAS through the
// descriptor heap is rewritten to read its device address from a push

View file

@ -39,95 +39,7 @@ export namespace Crafter {
std::vector<std::uint16_t> bufferFreelist;
std::vector<std::uint16_t> samplerFreelist;
// ─── Sets mode ──────────────────────────────────────────────────
// Used instead of the heap buffers above when the driver has no
// VK_EXT_descriptor_heap (Device::descriptorHeapSupported == false).
// The same slots then live in one bindless descriptor set per frame,
// mirroring the per-frame heap copies, so slot allocation, the slot
// handles and every shader-side index keep their meaning:
// binding 0 STORAGE_IMAGE[] image slots written as storage images
// binding 1 SAMPLED_IMAGE[] image slots written as sampled images
// binding 2 SAMPLER[] sampler slots
// binding 3 STORAGE_BUFFER[] buffer slots (bufferStartElement is 0)
// These numbers are the UI_HEAP_* macros in shaders/ui-shared.glsl.
// Bindings 0 and 1 share the image slot space the way the two typed
// views of the resource heap do; a slot is valid in whichever of the
// two it was written to.
//
// All bindings are PARTIALLY_BOUND | UPDATE_AFTER_BIND |
// UPDATE_UNUSED_WHILE_PENDING: most slots are never written, and a
// slot is written into every frame's set at once while earlier frames
// may still be executing with their set bound.
static constexpr std::uint32_t storageImageBinding = 0;
static constexpr std::uint32_t sampledImageBinding = 1;
static constexpr std::uint32_t samplerBinding = 2;
static constexpr std::uint32_t storageBufferBinding = 3;
VkDescriptorPool setPool = VK_NULL_HANDLE;
VkDescriptorSetLayout setLayout = VK_NULL_HANDLE;
// Shared by every compute pipeline created against this heap
// (ComputeShader::Load), so the set Window::Render binds once per
// frame stays bound across all of them. Carries one compute
// push-constant range covering the device's whole push-constant space.
VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;
VkDescriptorSet sets[Window::numFrames] {};
// Descriptor sets reference VkImageView / VkSampler objects, where the
// heap takes create-infos, so in sets mode this struct creates and
// owns them. setImageViews[frame][slot] is the view frame's set uses
// at that image slot; a view shared by all frames is stored under
// frame 0 only. setSamplers[slot] likewise.
std::vector<VkImageView> setImageViews[Window::numFrames];
std::vector<VkSampler> setSamplers;
DescriptorHeapVulkan() = default;
DescriptorHeapVulkan(const DescriptorHeapVulkan&) = delete;
DescriptorHeapVulkan& operator=(const DescriptorHeapVulkan&) = delete;
// Heap mode owns nothing beyond its VulkanBuffers, which release
// themselves. Sets mode owns the objects above. The frame loop keeps
// frames in flight and nothing waits for them at shutdown, so wait
// here: destroying a view or the pool under a still-executing command
// buffer is a use-after-free on the GPU.
~DescriptorHeapVulkan() {
if (setLayout == VK_NULL_HANDLE) {
return;
}
vkQueueWaitIdle(Device::queue);
DestroySets();
}
// Destroys every sets-mode object and returns to the un-initialized
// state. The caller guarantees nothing on the GPU still uses them.
// Safe on a partly built heap: destroying VK_NULL_HANDLE is a no-op.
void DestroySets() noexcept {
vkDestroyDescriptorPool(Device::device, setPool, nullptr);
for (std::vector<VkImageView>& views : setImageViews) {
for (VkImageView view : views) {
vkDestroyImageView(Device::device, view, nullptr);
}
views.clear();
}
for (VkSampler sampler : setSamplers) {
vkDestroySampler(Device::device, sampler, nullptr);
}
setSamplers.clear();
vkDestroyDescriptorSetLayout(Device::device, setLayout, nullptr);
vkDestroyPipelineLayout(Device::device, pipelineLayout, nullptr);
setPool = VK_NULL_HANDLE;
setLayout = VK_NULL_HANDLE;
pipelineLayout = VK_NULL_HANDLE;
std::ranges::fill(sets, VkDescriptorSet{VK_NULL_HANDLE});
}
// Call once, before any shader is loaded against this heap. In heap
// mode a second call simply re-sizes the heap buffers; in sets mode it
// throws, because the pipelines already built against the old layout
// could not be used with a new one.
void Initialize(std::uint16_t images, std::uint16_t buffers, std::uint16_t samplers) {
if (!Device::descriptorHeapSupported) {
InitializeSets(images, buffers, samplers);
return;
}
std::uint32_t descriptorRegion = images * Device::descriptorHeapProperties.imageDescriptorSize + buffers * Device::descriptorHeapProperties.bufferDescriptorSize;
std::uint32_t alignedDescriptorRegion = (descriptorRegion + Device::descriptorHeapProperties.imageDescriptorAlignment - 1) & ~(Device::descriptorHeapProperties.imageDescriptorAlignment - 1);
std::uint32_t resourceSize = alignedDescriptorRegion + Device::descriptorHeapProperties.minResourceHeapReservedRange;
@ -166,231 +78,6 @@ export namespace Crafter {
}
}
// Sets-mode half of Initialize. Kept apart so the heap sizing above,
// which divides by descriptor sizes that are zero without the
// extension, never runs here.
void InitializeSets(std::uint16_t images, std::uint16_t buffers, std::uint16_t samplers) {
// A second Initialize would leak the first pool and layouts and
// leave every pipeline created against the old layout
// incompatible with the new sets.
if (setLayout != VK_NULL_HANDLE) {
throw std::runtime_error("DescriptorHeapVulkan: Initialize called twice (descriptor-set mode cannot be re-initialized)");
}
// If a step below throws, undo the ones that succeeded. A
// half-built heap would otherwise keep setLayout set, so a retry
// would report "Initialize called twice" instead of the real
// error, and nothing would release what was created.
struct Rollback {
DescriptorHeapVulkan* heap;
~Rollback() { if (heap != nullptr) heap->DestroySets(); }
} rollback{this};
// Buffer slots are plain array indices into binding 3, so
// BufferSlot's conversion adds nothing.
bufferStartElement = 0;
bufferStartOffset = 0;
imageCapacity = images;
bufferCapacity = buffers;
samplerCapacity = samplers;
imageNext = 0;
bufferNext = 0;
samplerNext = 0;
imageFreelist.clear();
imageFreelist.reserve(images);
bufferFreelist.clear();
bufferFreelist.reserve(buffers);
samplerFreelist.clear();
samplerFreelist.reserve(samplers);
// At least one descriptor per binding even when a pool was sized
// zero (Initialize(1, 1, 0) is common): a zero-count pool size is
// invalid, and the UI shaders statically reference all four arrays.
const std::uint32_t imageCount = std::max<std::uint32_t>(images, 1);
const std::uint32_t bufferCount = std::max<std::uint32_t>(buffers, 1);
const std::uint32_t samplerCount = std::max<std::uint32_t>(samplers, 1);
const VkDescriptorSetLayoutBinding bindings[] = {
{ storageImageBinding, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, imageCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
{ sampledImageBinding, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, imageCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
{ samplerBinding, VK_DESCRIPTOR_TYPE_SAMPLER, samplerCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
{ storageBufferBinding, VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, bufferCount, VK_SHADER_STAGE_COMPUTE_BIT, nullptr },
};
constexpr VkDescriptorBindingFlags slotFlags =
VK_DESCRIPTOR_BINDING_PARTIALLY_BOUND_BIT
| VK_DESCRIPTOR_BINDING_UPDATE_AFTER_BIND_BIT
| VK_DESCRIPTOR_BINDING_UPDATE_UNUSED_WHILE_PENDING_BIT;
const VkDescriptorBindingFlags bindingFlags[] = { slotFlags, slotFlags, slotFlags, slotFlags };
const VkDescriptorSetLayoutBindingFlagsCreateInfo bindingFlagsInfo {
.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_BINDING_FLAGS_CREATE_INFO,
.bindingCount = static_cast<std::uint32_t>(std::size(bindingFlags)),
.pBindingFlags = bindingFlags,
};
const VkDescriptorSetLayoutCreateInfo layoutInfo {
.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO,
.pNext = &bindingFlagsInfo,
.flags = VK_DESCRIPTOR_SET_LAYOUT_CREATE_UPDATE_AFTER_BIND_POOL_BIT,
.bindingCount = static_cast<std::uint32_t>(std::size(bindings)),
.pBindings = bindings,
};
Device::CheckVkResult(vkCreateDescriptorSetLayout(Device::device, &layoutInfo, nullptr, &setLayout));
// One range over the whole push-constant space rather than a
// per-shader size: the layout is shared, and each Dispatch pushes
// only the bytes its shader declares.
const VkPushConstantRange pushRange {
.stageFlags = VK_SHADER_STAGE_COMPUTE_BIT,
.offset = 0,
.size = Device::deviceProperties.limits.maxPushConstantsSize & ~3u,
};
const VkPipelineLayoutCreateInfo pipelineLayoutInfo {
.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO,
.setLayoutCount = 1,
.pSetLayouts = &setLayout,
.pushConstantRangeCount = 1,
.pPushConstantRanges = &pushRange,
};
Device::CheckVkResult(vkCreatePipelineLayout(Device::device, &pipelineLayoutInfo, nullptr, &pipelineLayout));
const VkDescriptorPoolSize poolSizes[] = {
{ VK_DESCRIPTOR_TYPE_STORAGE_IMAGE, imageCount * Window::numFrames },
{ VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, imageCount * Window::numFrames },
{ VK_DESCRIPTOR_TYPE_SAMPLER, samplerCount * Window::numFrames },
{ VK_DESCRIPTOR_TYPE_STORAGE_BUFFER, bufferCount * Window::numFrames },
};
const VkDescriptorPoolCreateInfo poolInfo {
.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO,
.flags = VK_DESCRIPTOR_POOL_CREATE_UPDATE_AFTER_BIND_BIT,
.maxSets = Window::numFrames,
.poolSizeCount = static_cast<std::uint32_t>(std::size(poolSizes)),
.pPoolSizes = poolSizes,
};
Device::CheckVkResult(vkCreateDescriptorPool(Device::device, &poolInfo, nullptr, &setPool));
VkDescriptorSetLayout setLayouts[Window::numFrames];
std::ranges::fill(setLayouts, setLayout);
const VkDescriptorSetAllocateInfo allocInfo {
.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO,
.descriptorPool = setPool,
.descriptorSetCount = Window::numFrames,
.pSetLayouts = setLayouts,
};
Device::CheckVkResult(vkAllocateDescriptorSets(Device::device, &allocInfo, sets));
for (std::vector<VkImageView>& views : setImageViews) {
views.assign(imageCount, VK_NULL_HANDLE);
}
setSamplers.assign(samplerCount, VK_NULL_HANDLE);
rollback.heap = nullptr;
}
// ─── Sets-mode descriptor writes ────────────────────────────────
// The counterparts of vkWriteResourceDescriptorsEXT /
// vkWriteSamplerDescriptorsEXT into the heap buffers. Call only when
// !Device::descriptorHeapSupported. `slot` is the raw index the
// Allocate*Slots call returned.
//
// Each write replaces whatever view or sampler the slot held and
// destroys it immediately. That relies on the same contract as slot
// reuse in heap mode: no frame in flight may still reference the slot
// (true after the queue drain that precedes Window::onResize, and for
// a slot that was freed at a scene-switch barrier).
// Writes `type` (STORAGE_IMAGE or SAMPLED_IMAGE) at an image slot of
// one frame's set, for images that differ per frame: the swapchain.
// A slot written this way must be written for every frame.
void WriteImageSet(std::uint8_t frame, std::uint16_t slot, VkDescriptorType type,
const VkImageViewCreateInfo& viewInfo, VkImageLayout layout) {
VkImageView view = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateImageView(Device::device, &viewInfo, nullptr, &view));
vkDestroyImageView(Device::device, setImageViews[frame][slot], nullptr);
setImageViews[frame][slot] = view;
const VkDescriptorImageInfo imageInfo { .imageView = view, .imageLayout = layout };
const VkWriteDescriptorSet write {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = sets[frame],
.dstBinding = type == VK_DESCRIPTOR_TYPE_STORAGE_IMAGE ? storageImageBinding : sampledImageBinding,
.dstArrayElement = slot,
.descriptorCount = 1,
.descriptorType = type,
.pImageInfo = &imageInfo,
};
vkUpdateDescriptorSets(Device::device, 1, &write, 0, nullptr);
}
// Writes the same image at an image slot of every frame's set, through
// one shared view.
void WriteImageSets(std::uint16_t slot, VkDescriptorType type,
const VkImageViewCreateInfo& viewInfo, VkImageLayout layout) {
VkImageView view = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateImageView(Device::device, &viewInfo, nullptr, &view));
for (std::vector<VkImageView>& views : setImageViews) {
vkDestroyImageView(Device::device, views[slot], nullptr);
views[slot] = VK_NULL_HANDLE;
}
setImageViews[0][slot] = view;
const VkDescriptorImageInfo imageInfo { .imageView = view, .imageLayout = layout };
VkWriteDescriptorSet writes[Window::numFrames];
for (std::uint8_t f = 0; f < Window::numFrames; ++f) {
writes[f] = {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = sets[f],
.dstBinding = type == VK_DESCRIPTOR_TYPE_STORAGE_IMAGE ? storageImageBinding : sampledImageBinding,
.dstArrayElement = slot,
.descriptorCount = 1,
.descriptorType = type,
.pImageInfo = &imageInfo,
};
}
vkUpdateDescriptorSets(Device::device, Window::numFrames, writes, 0, nullptr);
}
// Writes a storage buffer at a buffer slot of every frame's set. The
// whole buffer is bound; the shader's runtime array is bounded by the
// item count the dispatch passes, as in heap mode.
void WriteBufferSets(std::uint16_t slot, VkBuffer buffer) {
const VkDescriptorBufferInfo bufferInfo { .buffer = buffer, .offset = 0, .range = VK_WHOLE_SIZE };
VkWriteDescriptorSet writes[Window::numFrames];
for (std::uint8_t f = 0; f < Window::numFrames; ++f) {
writes[f] = {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = sets[f],
.dstBinding = storageBufferBinding,
.dstArrayElement = slot,
.descriptorCount = 1,
.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER,
.pBufferInfo = &bufferInfo,
};
}
vkUpdateDescriptorSets(Device::device, Window::numFrames, writes, 0, nullptr);
}
// Creates the sampler and writes it at a sampler slot of every
// frame's set.
void WriteSamplerSets(std::uint16_t slot, const VkSamplerCreateInfo& info) {
VkSampler sampler = VK_NULL_HANDLE;
Device::CheckVkResult(vkCreateSampler(Device::device, &info, nullptr, &sampler));
vkDestroySampler(Device::device, setSamplers[slot], nullptr);
setSamplers[slot] = sampler;
const VkDescriptorImageInfo samplerInfo { .sampler = sampler };
VkWriteDescriptorSet writes[Window::numFrames];
for (std::uint8_t f = 0; f < Window::numFrames; ++f) {
writes[f] = {
.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET,
.dstSet = sets[f],
.dstBinding = samplerBinding,
.dstArrayElement = slot,
.descriptorCount = 1,
.descriptorType = VK_DESCRIPTOR_TYPE_SAMPLER,
.pImageInfo = &samplerInfo,
};
}
vkUpdateDescriptorSets(Device::device, Window::numFrames, writes, 0, nullptr);
}
ImageSlotRange AllocateImageSlots(std::uint16_t count) {
if (count == 1 && !imageFreelist.empty()) {
std::uint16_t s = imageFreelist.back();
@ -471,12 +158,7 @@ export namespace Crafter {
return firstElement * Device::descriptorHeapProperties.samplerDescriptorSize;
}
// Both are 0 in sets mode: buffer slots are plain indices into their
// own binding, and the heap descriptor sizes divided by below are zero.
inline static std::uint32_t GetBufferOffset(std::uint16_t images, std::uint16_t buffers) {
if (!Device::descriptorHeapSupported) {
return 0;
}
std::uint32_t bufferStartElement = images * Device::descriptorHeapProperties.imageDescriptorSize / Device::descriptorHeapProperties.bufferDescriptorSize;
if(images > 0 && bufferStartElement == 0) {
@ -485,9 +167,6 @@ export namespace Crafter {
return bufferStartElement * Device::descriptorHeapProperties.bufferDescriptorSize;
}
inline static std::uint16_t GetBufferOffsetElement(std::uint16_t images, std::uint16_t buffers) {
if (!Device::descriptorHeapSupported) {
return 0;
}
std::uint16_t bufferStartElement = images * Device::descriptorHeapProperties.imageDescriptorSize / Device::descriptorHeapProperties.bufferDescriptorSize;
if(images > 0 && bufferStartElement == 0) {

View file

@ -174,39 +174,12 @@ export namespace Crafter {
// relocate it.
inline static std::filesystem::path pipelineCachePath = "pipeline_cache.bin";
// ─── Optional capability groups ────────────────────────────────
// Decided once by Initialize from the extensions the driver
// advertises; false until then. Not every driver has the descriptor
// heap (Mesa Turnip on Adreno has neither it nor ray tracing), so the
// compute/UI path runs in one of two binding models:
// descriptorHeapSupported -> VK_EXT_descriptor_heap, the *.spv shaders
// !descriptorHeapSupported -> one bindless descriptor set per frame
// (DescriptorHeapVulkan sets mode), the
// *.sets.spv shaders, vkCmdPushConstants
// Setting the environment variable CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP
// forces the second model on any driver, for testing.
inline static bool descriptorHeapSupported = false;
// Ray tracing also needs the descriptor heap (RT pipelines and RT
// shaders are heap-only), so this is never true without it.
inline static bool rayTracingSupported = false;
// VK_EXT_device_fault: the VK_ERROR_DEVICE_LOST report in CheckVkResult.
inline static bool deviceFaultSupported = false;
// Pipeline stages that are valid on this device: everything, minus
// RAY_TRACING_SHADER when ray tracing is unsupported. AND it into any
// stage mask built from a constant that names the RT stage.
inline static VkPipelineStageFlags stageMask = ~VkPipelineStageFlags{0};
// Throws std::runtime_error naming the missing capability when ray
// tracing is unsupported. Called at every ray tracing entry point.
static void RequireRayTracing();
// The two property structs below are only filled in (and only chained
// into the properties query) when their capability group is supported;
// otherwise they stay zero.
inline static VkPhysicalDeviceDescriptorHeapPropertiesEXT descriptorHeapProperties = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DESCRIPTOR_HEAP_PROPERTIES_EXT
};
inline static VkPhysicalDeviceRayTracingPipelinePropertiesKHR rayTracingProperties = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_PROPERTIES_KHR
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_RAY_TRACING_PIPELINE_PROPERTIES_KHR,
.pNext = &descriptorHeapProperties
};
inline static VkPhysicalDeviceMemoryDecompressionPropertiesEXT memoryDecompressionProperties = {
.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_MEMORY_DECOMPRESSION_PROPERTIES_EXT

View file

@ -90,15 +90,6 @@ export namespace Crafter {
this->width = width;
this->height = height;
this->mipLevels = mipLevels;
// The RT default is not a valid pipeline stage on a device without
// ray tracing, where the only shader that can sample an image is a
// compute shader. Resolved here rather than in the member
// initializer, which can run before Device::Initialize. The
// parameter is corrected, not only the member: the transition at
// the end of this function is recorded with it.
if (!Device::rayTracingSupported && consumerStage == VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR) {
consumerStage = VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
}
this->consumerStage = consumerStage;
this->streamed = streamed;
buffer.Create(

View file

@ -39,7 +39,6 @@ export namespace Crafter {
// matching the simple examples) and is clamped to the device's
// maxRayRecursionDepth.
void Init(VkCommandBuffer cmd, std::span<VkRayTracingShaderGroupCreateInfoKHR> raygenGroups, std::span<VkRayTracingShaderGroupCreateInfoKHR> missGroups, std::span<VkRayTracingShaderGroupCreateInfoKHR> hitGroups, ShaderBindingTableVulkan& shaderTable, std::uint32_t maxRecursionDepth = 1) {
Device::RequireRayTracing();
workaroundNeedsTlas = shaderTable.workaroundNeedsTlas;
workaroundTlasPushOffset = shaderTable.workaroundTlasPushOffset;

View file

@ -26,7 +26,6 @@ export namespace Crafter {
bool workaroundNeedsTlas = false;
std::uint32_t workaroundTlasPushOffset = 0;
void Init(const std::span<const VulkanShader> shaders) {
Device::RequireRayTracing();
shaderStages.reserve(shaders.size());
for(const VulkanShader& shader: shaders) {
shaderStages.emplace_back(VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO, nullptr, 0, shader.stage, shader.shader, shader.entrypoint.c_str(), shader.specilizationInfo);

View file

@ -351,17 +351,6 @@ export namespace Crafter {
bool patchedAS = false;
std::uint32_t tlasPushOffset = 0;
VulkanShader(const std::filesystem::path& path, std::string entrypoint, VkShaderStageFlagBits stage, VkSpecializationInfo* specilizationInfo) : stage(stage), entrypoint(entrypoint), specilizationInfo(specilizationInfo) {
// A ray tracing stage is the first thing every RT setup creates.
// Stop here on a device without ray tracing: its shaders are
// descriptor-heap SPIR-V, which must not reach a driver that never
// enabled the heap or untyped pointers.
constexpr VkShaderStageFlags rayTracingStages =
VK_SHADER_STAGE_RAYGEN_BIT_KHR | VK_SHADER_STAGE_ANY_HIT_BIT_KHR
| VK_SHADER_STAGE_CLOSEST_HIT_BIT_KHR | VK_SHADER_STAGE_MISS_BIT_KHR
| VK_SHADER_STAGE_INTERSECTION_BIT_KHR | VK_SHADER_STAGE_CALLABLE_BIT_KHR;
if ((stage & rayTracingStages) != 0) {
Device::RequireRayTracing();
}
std::ifstream file(path, std::ios::binary);
if (!file) {
std::cerr << "Error: Could not open file " << path << std::endl;

View file

@ -260,10 +260,9 @@ export namespace Crafter {
const FusedBatch& text);
// Generic dispatch for user-authored shaders. On Vulkan, `shader` is
// a SPIR-V compute pipeline (bindless, via VK_EXT_descriptor_heap or
// the descriptor-set fallback, so any resource indices baked into
// push data resolve through the global heap either way). On DOM,
// `shader` carries a UICustomBinding list
// a SPIR-V compute pipeline (bindless via VK_EXT_descriptor_heap, so
// any resource indices baked into push data resolve through the
// global heap). On DOM, `shader` carries a UICustomBinding list
// declared at Load time; the renderer reads the listed slot uints
// out of `push`, resolves them against heap.bufferTable /
// imageTable / samplerTable, and builds the bind groups before
@ -431,7 +430,7 @@ export namespace Crafter {
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
void WriteSwapchainDescriptors();
void WriteFontAtlasDescriptor();
void WriteBufferDescriptor(std::uint16_t slot, VkBuffer buffer, VkDeviceAddress address, std::uint32_t size);
void WriteBufferDescriptor(std::uint16_t slot, VkDeviceAddress address, std::uint32_t size);
void WriteSampledImageDescriptor(std::uint16_t slot,
const VkImageViewCreateInfo& viewInfo,
VkImageLayout layout);
@ -443,7 +442,7 @@ export namespace Crafter {
BufferSlot UIRenderer::RegisterBuffer(GraphicsBuffer<T, Mapped>& buffer) {
auto range = heap_->AllocateBufferSlots(1);
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
WriteBufferDescriptor(range.firstElement, buffer.buffer, buffer.address, buffer.size);
WriteBufferDescriptor(range.firstElement, buffer.address, buffer.size);
#else
heap_->bufferTable[range.firstElement] = buffer.handle;
#endif
@ -455,10 +454,6 @@ export namespace Crafter {
ImageSlot UIRenderer::RegisterImage(ImageVulkan<Pixel>& image, VkFormat format,
VkImageLayout layout) {
auto range = heap_->AllocateImageSlots(1);
// The handle owns the slot from here on: in descriptor-set mode the
// write creates an image view, and if that throws the slot returns
// to the freelist instead of leaking.
ImageSlot slot{heap_, range.firstElement};
VkImageViewCreateInfo info {
.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO,
.image = image.image,
@ -477,7 +472,7 @@ export namespace Crafter {
},
};
WriteSampledImageDescriptor(range.firstElement, info, layout);
return slot;
return ImageSlot{heap_, range.firstElement};
}
#endif
}

View file

@ -9,7 +9,6 @@ module;
export module Crafter.Graphics:VulkanTransition;
#ifndef CRAFTER_GRAPHICS_WINDOW_DOM
import std;
import :Device;
export namespace Crafter {
#include "vulkan/vulkan.h"
@ -69,10 +68,7 @@ export namespace Crafter {
return VK_PIPELINE_STAGE_BOTTOM_OF_PIPE_BIT;
case VK_IMAGE_LAYOUT_GENERAL:
//assert(false && "Don't know how to get a meaningful VkPipelineStageFlags for VK_IMAGE_LAYOUT_GENERAL! Don't use it!");
// GENERAL images are written by RT or compute shaders; on a
// device without ray tracing the RT stage is not a valid stage.
return Device::rayTracingSupported ? VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR
: VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT;
return VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR;
default:
assert(false);
return 0;

View file

@ -236,17 +236,11 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
};
cfg.GetInterfacesAndImplementations(ifaces, impls);
// Every UI kernel is built twice from one source: <name>.comp.spv
// indexes VK_EXT_descriptor_heap, <name>.comp.sets.spv reads the same
// slots from a bindless descriptor set for drivers without the heap
// extension (Mesa Turnip). ui-shared.glsl switches on the define and
// ComputeShader::Load picks the file at runtime.
for (const char* shader : {"shaders/ui-quads.comp.glsl", "shaders/ui-circles.comp.glsl", "shaders/ui-images.comp.glsl", "shaders/ui-text.comp.glsl", "shaders/ui-fused.comp.glsl"}) {
cfg.shaders.emplace_back(fs::path(shader), std::string("main"), ShaderType::Compute);
Shader& sets = cfg.shaders.emplace_back(fs::path(shader), std::string("main"), ShaderType::Compute);
sets.defines = {"CRAFTER_GRAPHICS_DESCRIPTOR_SETS"};
sets.variant = "sets";
}
cfg.shaders.emplace_back(fs::path("shaders/ui-quads.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-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
@ -373,40 +367,6 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> 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<fs::path> 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

View file

@ -8,31 +8,7 @@
#extension GL_EXT_shader_image_load_formatted : enable
#extension GL_EXT_shader_explicit_arithmetic_types_int16 : enable
// ─── binding model ──────────────────────────────────────────────────────
// Every shader that includes this file is compiled twice from one source.
// The default build indexes VK_EXT_descriptor_heap directly. The second
// build defines CRAFTER_GRAPHICS_DESCRIPTOR_SETS and reads the same slots
// from one bindless descriptor set, for drivers without the heap extension
// (output: <name>.sets.spv, picked by ComputeShader::Load at runtime).
//
// Declare bindless resources with these macros instead of writing
// `descriptor_heap` or a set/binding pair, and both builds stay in sync.
// Slot numbers mean the same thing in both: UI_HEAP_IMAGES and
// UI_HEAP_TEXTURES share one image slot space, and any number of buffer
// blocks may alias UI_HEAP_BUFFERS.
#ifdef CRAFTER_GRAPHICS_DESCRIPTOR_SETS
#define UI_HEAP_IMAGES set = 0, binding = 0
#define UI_HEAP_TEXTURES set = 0, binding = 1
#define UI_HEAP_SAMPLERS set = 0, binding = 2
#define UI_HEAP_BUFFERS set = 0, binding = 3
#else
#extension GL_EXT_descriptor_heap : enable
#define UI_HEAP_IMAGES descriptor_heap
#define UI_HEAP_TEXTURES descriptor_heap
#define UI_HEAP_SAMPLERS descriptor_heap
#define UI_HEAP_BUFFERS descriptor_heap
#endif
#extension GL_EXT_nonuniform_qualifier : enable
#extension GL_EXT_buffer_reference : enable
@ -40,9 +16,9 @@
// The same heap slot can be read as either uiImages[] (storage image) or
// uiTextures[] (sampled image) depending on which descriptor was written
// at that slot. Samplers live in a separate sampler heap.
layout(UI_HEAP_IMAGES) uniform image2D uiImages[];
layout(UI_HEAP_TEXTURES) uniform texture2D uiTextures[];
layout(UI_HEAP_SAMPLERS) uniform sampler uiSamplers[];
layout(descriptor_heap) uniform image2D uiImages[];
layout(descriptor_heap) uniform texture2D uiTextures[];
layout(descriptor_heap) uniform sampler uiSamplers[];
// ─── push-constant header ───────────────────────────────────────────────
// Every UI dispatch's push-constant struct begins with this. User shaders
@ -94,10 +70,10 @@ struct GlyphItem { vec4 rect; vec4 uv; vec4 color; };
// ─── SSBO heap views ────────────────────────────────────────────────────
// One declaration per item type; each shader uses the one matching its
// dispatch. Indexed by hdr.itemBuffer.
layout(UI_HEAP_BUFFERS, std430) readonly buffer UIQuadBuf { QuadItem items[]; } uiQuadHeap[];
layout(UI_HEAP_BUFFERS, std430) readonly buffer UICircleBuf { CircleItem items[]; } uiCircleHeap[];
layout(UI_HEAP_BUFFERS, std430) readonly buffer UIImageBuf { ImageItem items[]; } uiImageHeap[];
layout(UI_HEAP_BUFFERS, std430) readonly buffer UIGlyphBuf { GlyphItem items[]; } uiGlyphHeap[];
layout(descriptor_heap, std430) readonly buffer UIQuadBuf { QuadItem items[]; } uiQuadHeap[];
layout(descriptor_heap, std430) readonly buffer UICircleBuf { CircleItem items[]; } uiCircleHeap[];
layout(descriptor_heap, std430) readonly buffer UIImageBuf { ImageItem items[]; } uiImageHeap[];
layout(descriptor_heap, std430) readonly buffer UIGlyphBuf { GlyphItem items[]; } uiGlyphHeap[];
// ──── Driver workaround: per-member SSBO load ────────────────────────────

View file

@ -1,423 +0,0 @@
//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 (<name>.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 <png>`, 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 <png>` 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 <cstdlib>
// 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<typename T>
void CreateItemBuffer(GraphicsBuffer<T, true>& 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<Texel>& 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
// `<out>.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<Texel> red;
ImageVulkan<Texel> 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<Texel> 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<std::uint16_t>(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<std::uint16_t>(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<QuadItem, true> quadsA, quadsB;
GraphicsBuffer<CircleItem, true> circlesA, circlesB;
GraphicsBuffer<ImageItem, true> imagesA, imagesB;
GraphicsBuffer<GlyphItem, true> 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<QuadItem, true> 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<QuadItem, true>& quads,
GraphicsBuffer<CircleItem, true>& circles,
GraphicsBuffer<ImageItem, true>& images,
GraphicsBuffer<GlyphItem, true>& glyphs,
bool withBackground, std::string_view label,
std::array<std::uint32_t, 4>& 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<std::uint32_t, 4> countsA{};
std::array<std::uint32_t, 4> countsB{};
EventListener<UIBuildArgs> 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<std::uint32_t>(sizeof(oddHeader))
: static_cast<std::uint32_t>(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<std::uint8_t> 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<std::size_t>(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<std::uint32_t> 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<int>(heap.rgba[i]) - static_cast<int>(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;
}

View file

@ -18,11 +18,7 @@
// 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;
// 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.
// 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.
@ -140,61 +136,52 @@ 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";
// 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);
// 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;
}