diff --git a/README.md b/README.md index bea28ec..c4cafac 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,8 @@ shaded through an intersection shader with an any-hit cut-out. > in a closest-hit, including different descriptors in neighbouring lanes of > a wave. The rest of this note applies to the older drivers. A **constant / spec-constant** > index is fine (that's why [Sponza](examples/Sponza/README.md)'s -> closest-hit reads `albedo[albedoSlot]` through a spec constant), and +> closest-hit reads `textures[albedoSlot]` and `samplers[samplerSlot]` +> through spec constants), and > the identical dynamic pattern works in fragment shaders (the UI > renderer indexes `uiTextures[]` by per-item runtime slots) — so this > is **RT-stage-specific**, not a general heap problem. Unlike the diff --git a/examples/Sponza/README.md b/examples/Sponza/README.md index f7a198d..e5de9e1 100644 --- a/examples/Sponza/README.md +++ b/examples/Sponza/README.md @@ -14,9 +14,16 @@ it via ray tracing on both Vulkan (native) and WebGPU (wasm). Same at the barycentric attribs as UVs — proof-of-binding, not visually accurate. Per-vertex UV interpolation is the next step. -The closest-hit reads its texture through a **spec constant** -(`albedo[albedoSlot]`), not a runtime index. That is deliberate — see -below. +The closest-hit reads its texture and its sampler through **spec +constants** (`textures[albedoSlot]`, `samplers[samplerSlot]`), not a +runtime index. That is deliberate — see below. + +On Vulkan the texture and the sampler are separate descriptors: a sampled +image in the resource heap and a sampler in the sampler heap, joined where +the closest-hit samples them, +`texture(sampler2D(textures[albedoSlot], samplers[samplerSlot]), uv)`. +The descriptor heap has no combined image samplers, and glslang rejects a +`descriptor_heap` `sampler2D` (#164). ## Native RT limitation: dynamic `descriptor_heap` indexing in hit shaders @@ -44,7 +51,8 @@ closest-hit: - A closest-hit that reads only `lightHeap[lightSlot]` where `lightSlot` is a **spec constant** survives indefinitely. ✅ (This example's - `albedo[albedoSlot]` is exactly this case.) + `textures[albedoSlot]` and `samplers[samplerSlot]` are exactly this + case.) - Reading `indexHeap[assetIndexStart + gl_InstanceCustomIndexEXT]` / `vertexHeap[...]` — a heap index offset by a **runtime** value — device-losts on the first geometry hit. ❌ @@ -116,11 +124,14 @@ indexing the heap: slot and index by **layer**: ```glsl - // ✅ one array texture (spec-constant slot), dynamic layer index + // ✅ one array texture (spec-constant slots), dynamic layer index layout(constant_id = 1) const uint16_t materialArraySlot = 0us; - layout(descriptor_heap) uniform sampler2DArray materials[]; + layout(constant_id = 2) const uint16_t samplerSlot = 0us; + layout(descriptor_heap) uniform texture2DArray materials[]; + layout(descriptor_heap) uniform sampler samplers[]; uint layer = materialLayer[gl_InstanceCustomIndexEXT]; // from a bound SSBO - vec3 albedo = texture(materials[materialArraySlot], vec3(uv, layer)).rgb; + vec3 albedo = texture(sampler2DArray(materials[materialArraySlot], samplers[samplerSlot]), + vec3(uv, layer)).rgb; ``` This is precisely what the WebGPU path already does — bucketed texture diff --git a/examples/Sponza/closesthit.glsl b/examples/Sponza/closesthit.glsl index 1d2de90..66bc0c5 100644 --- a/examples/Sponza/closesthit.glsl +++ b/examples/Sponza/closesthit.glsl @@ -8,19 +8,24 @@ #extension GL_EXT_descriptor_heap : enable #extension GL_EXT_nonuniform_qualifier : enable -// Specialization constant: descriptor-heap slot of the albedo texture. -// Set from descriptorHeap.bufferStartElement + the slot allocated for -// the Image2D on the host side. Sampling uses gl_HitAttributeEXT -// barycentrics as UVs — proof-of-binding rather than UV-correct shading. -// Per-vertex UV interpolation lands when Mesh on Vulkan exposes the -// data-region buffer. +// Specialization constants: the albedo's slot in the resource heap and its +// sampler's slot in the sampler heap, as the host allocated them (image and +// sampler slots index their heaps directly; only buffer slots are offset by +// bufferStartElement). The descriptor heap has no combined image samplers, +// and glslang rejects a descriptor_heap sampler2D (#164), so the texture +// and the sampler are separate descriptors, joined where they are sampled. +// Sampling uses gl_HitAttributeEXT barycentrics as UVs — proof-of-binding +// rather than UV-correct shading. Per-vertex UV interpolation lands when +// Mesh on Vulkan exposes the data-region buffer. layout(constant_id = 0) const uint16_t albedoSlot = 0us; -layout(descriptor_heap) uniform sampler2D albedo[]; +layout(constant_id = 1) const uint16_t samplerSlot = 0us; +layout(descriptor_heap) uniform texture2D textures[]; +layout(descriptor_heap) uniform sampler samplers[]; hitAttributeEXT vec2 hitAttrs; layout(location = 0) rayPayloadInEXT vec3 hitValue; void main() { vec2 bary = vec2(hitAttrs.x, hitAttrs.y); - hitValue = texture(albedo[albedoSlot], bary).rgb; + hitValue = texture(sampler2D(textures[albedoSlot], samplers[samplerSlot]), bary).rgb; } diff --git a/examples/Sponza/main.cpp b/examples/Sponza/main.cpp index afd8a87..9e906c3 100644 --- a/examples/Sponza/main.cpp +++ b/examples/Sponza/main.cpp @@ -79,26 +79,32 @@ int main() { VkCommandBuffer cmd = window.StartInit(); DescriptorHeapVulkan descriptorHeap; - descriptorHeap.Initialize(/*images*/ 2, /*buffers*/ 1, /*samplers*/ 0); + descriptorHeap.Initialize(/*images*/ 2, /*buffers*/ 1, /*samplers*/ 1); - // Two specialization constants: the TLAS slot offset (shared with - // VulkanTriangle pattern) and the albedo slot index for closesthit. + // Specialization constants: the TLAS slot offset for raygen (shared + // with the VulkanTriangle pattern), and the albedo's image and sampler + // slots for closesthit. VkSpecializationMapEntry raygenEntry = { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) }; VkSpecializationInfo raygenSpec = { .mapEntryCount = 1, .pMapEntries = &raygenEntry, .dataSize = sizeof(std::uint16_t), .pData = &descriptorHeap.bufferStartElement, }; - // Allocate the albedo slot first so its index is known when we + // Allocate the albedo's slots first so their indices are known when we // compile closesthit.spv. - auto imgSlots = descriptorHeap.AllocateImageSlots(2); - auto bufSlots = descriptorHeap.AllocateBufferSlots(1); + auto imgSlots = descriptorHeap.AllocateImageSlots(2); + auto bufSlots = descriptorHeap.AllocateBufferSlots(1); + auto samplerSlots = descriptorHeap.AllocateSamplerSlots(1); std::uint16_t albedoHeapSlot = static_cast(imgSlots.firstElement + 1); - VkSpecializationMapEntry hitEntry = { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) }; + const std::array hitConstants { albedoHeapSlot, samplerSlots.firstElement }; + const std::array hitEntries {{ + { .constantID = 0, .offset = 0, .size = sizeof(std::uint16_t) }, + { .constantID = 1, .offset = sizeof(std::uint16_t), .size = sizeof(std::uint16_t) }, + }}; VkSpecializationInfo hitSpec = { - .mapEntryCount = 1, .pMapEntries = &hitEntry, - .dataSize = sizeof(std::uint16_t), .pData = &albedoHeapSlot, + .mapEntryCount = static_cast(hitEntries.size()), .pMapEntries = hitEntries.data(), + .dataSize = sizeof(hitConstants), .pData = hitConstants.data(), }; std::array shaders {{ @@ -140,7 +146,6 @@ int main() { VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); albedo.Update(loadedTex, cmd, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); - SamplerVulkan sampler; static RenderingElement3D renderer; renderer.instance = { @@ -161,13 +166,26 @@ int main() { window.FinishInit(); // Write descriptors: TLAS at bufSlots[0], output image at imgSlots[0], - // albedo (combined image+sampler) at imgSlots[1]. Per-frame replicated. + // albedo (a sampled image) at imgSlots[1], and its sampler at + // samplerSlots[0] in the sampler heap. Per-frame replicated. VkDeviceAddressRangeKHR tlasRanges[Window::numFrames]; VkImageDescriptorInfoEXT outImgInfos[Window::numFrames]; - VkDescriptorImageInfo albedoInfo { - .sampler = sampler.textureSampler, - .imageView = albedo.imageView, - .imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + // The heap takes an image view's create-info, not a VkImageView. + const VkImageViewCreateInfo albedoView { + .sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO, + .image = albedo.image, + .viewType = VK_IMAGE_VIEW_TYPE_2D, + .format = VK_FORMAT_R8G8B8A8_UNORM, + .subresourceRange = { + .aspectMask = VK_IMAGE_ASPECT_COLOR_BIT, + .levelCount = albedo.mipLevels, + .layerCount = 1, + }, + }; + const VkImageDescriptorInfoEXT albedoInfo { + .sType = VK_STRUCTURE_TYPE_IMAGE_DESCRIPTOR_INFO_EXT, + .pView = &albedoView, + .layout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, }; for (std::uint32_t f = 0; f < Window::numFrames; ++f) { tlasRanges[f] = { .address = RenderingElement3D::tlases[f].address }; @@ -205,8 +223,8 @@ int main() { }); resources.push_back({ .sType = VK_STRUCTURE_TYPE_RESOURCE_DESCRIPTOR_INFO_EXT, - .type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, - .data = { .pCombinedImageSampler = &albedoInfo }, + .type = VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE, + .data = { .pImage = &albedoInfo }, }); destinations.push_back({ .address = descriptorHeap.resourceHeap[f].value @@ -221,6 +239,34 @@ int main() { descriptorHeap.resourceHeap[f].FlushDevice(); } + // The sampler heap takes a sampler's create-info, as + // UIRenderer::RegisterSampler writes it. + const VkSamplerCreateInfo albedoSamplerInfo { + .sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO, + .magFilter = VK_FILTER_LINEAR, + .minFilter = VK_FILTER_LINEAR, + .mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR, + .addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT, + .addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT, + .addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT, + .maxLod = VK_LOD_CLAMP_NONE, + }; + VkSamplerCreateInfo samplerInfos[Window::numFrames]; + VkHostAddressRangeEXT samplerDestinations[Window::numFrames]; + for (std::uint32_t f = 0; f < Window::numFrames; ++f) { + samplerInfos[f] = albedoSamplerInfo; + samplerDestinations[f] = { + .address = descriptorHeap.samplerHeap[f].value + + descriptorHeap.SamplerByteOffset(samplerSlots.firstElement), + .size = Device::descriptorHeapProperties.samplerDescriptorSize, + }; + } + Device::vkWriteSamplerDescriptorsEXT(Device::device, Window::numFrames, + samplerInfos, samplerDestinations); + for (std::uint32_t f = 0; f < Window::numFrames; ++f) { + descriptorHeap.samplerHeap[f].FlushDevice(); + } + window.descriptorHeap = &descriptorHeap; RTPass rtPass(&pipeline); window.passes.push_back(&rtPass); diff --git a/examples/SponzaBench/README.md b/examples/SponzaBench/README.md index 1b79677..918f46a 100644 --- a/examples/SponzaBench/README.md +++ b/examples/SponzaBench/README.md @@ -119,13 +119,6 @@ scene for the dynamic-upload PRs) would be needed. ## Caveats / notes -* The stock `examples/Sponza` **native** path does not currently compile - against the (unpinned) Vulkan-Headers `main`: the - `VkResourceDescriptorDataEXT` union no longer has the - `pCombinedImageSampler` member the example uses. SponzaBench sidesteps - this by not binding a combined image+sampler. (Pre-existing, unrelated to - #155 — worth a follow-up to port Sponza to the split sampled-image + - sampler-heap model the engine's own UI renderer already uses.) * Numbers are CPU/GPU specific. Re-run `run-bench.sh` locally for your hardware; the *deltas* between revisions are the point, not the absolute figures. diff --git a/project.cpp b/project.cpp index cee0a3d..59a8f56 100644 --- a/project.cpp +++ b/project.cpp @@ -549,6 +549,15 @@ extern "C" Configuration CrafterBuildProject(std::span a fusedTest.requires_ = { "tool:glslang", "tool:spirv-val" }; cfg.tests.push_back(std::move(fusedTest)); + // Issue #164: every GLSL shader under examples/ compiles. Nothing here + // builds the examples, so when glslang began rejecting Sponza's + // descriptor_heap sampler2D nothing noticed. Compiles each one as + // crafter-build would for its example, the descriptor-set build too + // where it has one. No GPU; glslang is a required tool. + Test exampleShadersTest = LibraryTest("ExampleShaders"); + exampleShadersTest.requires_ = { "tool:glslang" }; + cfg.tests.push_back(std::move(exampleShadersTest)); + // Issue #60: VulkanBuffer::FlushDevice / FlushHost now record the chosen // memory type's propertyFlags at Create time and skip the // flush/invalidate when the memory is HOST_COHERENT. The gate is pure diff --git a/tests/ExampleShaders/main.cpp b/tests/ExampleShaders/main.cpp new file mode 100644 index 0000000..966d00d --- /dev/null +++ b/tests/ExampleShaders/main.cpp @@ -0,0 +1,125 @@ +//SPDX-License-Identifier: LGPL-3.0-only +//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Every GLSL shader the examples ship still compiles. The examples are +// projects of their own that nothing in this one builds, and crafter-build +// compiles their shaders with whichever glslang it links, unpinned. When +// glslang began rejecting a descriptor_heap combined image sampler (upstream +// c8253623), examples/Sponza stopped building and nothing noticed until it +// was built by hand (#164). This test compiles each examples//*.glsl as +// crafter-build does (Vulkan rules, SPIR-V 1.4, shaders/ on the include +// path), with the stage taken from the file name, and a shader that includes +// ui-shared.glsl a second time for the descriptor-set build its example also +// registers. A file whose name gives no stage fails rather than being +// skipped, so a new shader is never silently left out. +// +// Needs glslang on the PATH (a required tool in project.cpp), which need not +// be the glslang crafter-build links, and the source tree, which it finds +// from the build output directory. No GPU. + +#include + +import std; + +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; +} + +int RunCommand(const std::string& cmd) { + int status = std::system(cmd.c_str()); + if (status == -1) return -1; + return (status & 0x7f) == 0 ? ((status >> 8) & 0xff) : 128 + (status & 0x7f); +} + +std::string ReadFile(const fs::path& path) { + std::ifstream in(path, std::ios::binary); + return {std::istreambuf_iterator(in), std::istreambuf_iterator()}; +} + +// glslang's -S name for the stage, from the file names the examples use: +// raygen.glsl, closesthit.glsl, ..., .comp.glsl. Empty when unknown. +std::string_view StageOf(const fs::path& shader) { + const std::string stem = shader.stem().string(); + if (stem.ends_with(".comp")) return "comp"; + constexpr std::pair stages[] = { + {"raygen", "rgen"}, {"intersection", "rint"}, {"anyhit", "rahit"}, + {"closesthit", "rchit"}, {"miss", "rmiss"}, + }; + for (const auto& [name, stage] : stages) { + if (stem == name) return stage; + } + return {}; +} + +// The directory holding examples/ and shaders/, whether the test runs from +// its build output directory or the project root, as UIFusedShader finds its +// shader. +fs::path LocateRoot() { + for (const fs::path candidate : {".", "..", "../..", "../../.."}) { + std::error_code ec; + if (fs::is_directory(candidate / "examples", ec) + && fs::is_regular_file(candidate / "shaders" / "ui-shared.glsl", ec)) { + return candidate; + } + } + return {}; +} + +} // namespace + +int main() { + const fs::path root = LocateRoot(); + if (root.empty()) { + std::println(std::cerr, "could not find examples/ and shaders/ from {}", fs::current_path().string()); + return EXIT_FAILURE; + } + const fs::path out = fs::temp_directory_path() / "crafter-example-shaders-test"; + std::error_code ec; + fs::create_directories(out, ec); + + // Each example's own directory only: its bin/ and build/ hold outputs. + std::vector shaders; + for (const fs::directory_entry& example : fs::directory_iterator(root / "examples")) { + if (!example.is_directory()) continue; + for (const fs::directory_entry& file : fs::directory_iterator(example.path())) { + if (file.is_regular_file() && file.path().extension() == ".glsl") shaders.push_back(file.path()); + } + } + std::ranges::sort(shaders); + Check(!shaders.empty(), std::format("found the examples' shaders ({})", shaders.size())); + + for (const fs::path& shader : shaders) { + const std::string name = (shader.parent_path().filename() / shader.filename()).generic_string(); + const std::string_view stage = StageOf(shader); + if (stage.empty()) { + Check(false, std::format("{}: no stage known for this file name; name it after its stage or teach StageOf", name)); + continue; + } + // A shader on ui-shared.glsl's UI_HEAP_* macros is registered twice + // by its example, the second build for drivers without the heap. + const bool setsBuild = ReadFile(shader).contains("ui-shared.glsl"); + for (const bool sets : {false, true}) { + if (sets && !setsBuild) continue; + const std::string base = std::format("{}-{}{}", shader.parent_path().filename().string(), + shader.stem().string(), sets ? ".sets" : ""); + const fs::path log = out / (base + ".log"); + const std::string compile = std::format( + "glslang -V --target-env spirv1.4 -S {} {}-I\"{}\" -o \"{}\" \"{}\" > \"{}\" 2>&1", + stage, sets ? "-DCRAFTER_GRAPHICS_DESCRIPTOR_SETS " : "", (root / "shaders").string(), + (out / (base + ".spv")).string(), shader.string(), log.string()); + const bool compiled = RunCommand(compile) == 0; + if (!compiled) std::print("{}", ReadFile(log)); + Check(compiled, std::format("{} compiles{}", name, sets ? " (descriptor-set build)" : "")); + } + } + + std::println("{}", failures == 0 ? "all checks passed" : "check(s) failed"); + return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE; +}