Crafter.Graphics/tests/SwapchainBarrierScope/main.cpp
Jorijn van der Graaf 079b6b0e1f
feat: windows on their own clock, deferred deletion for every handle owner, staged re-uploads, and RTPass push data
Window
- updateInterval runs Update on the window's own clock while the
  compositor sends no frame callbacks (hidden, minimized): the Wayland loop
  is a poll() loop, and the Win32 loop sleeps until the next update. Both
  read the interval each pass.
- SetPresentMode (FIFO, Mailbox, Immediate). A mode the surface lacks, or
  whose swapchain has other than numFrames images (Mesa's Mailbox has four),
  falls back to FIFO until the mode is set again.
- AddPollFd / RemovePollFd wake the Wayland loop on the app's descriptors.
- The swapchain has TRANSFER_DST where the surface allows it.
- CaptureNextFrame replaces SaveFrame: it records the copy in the frame,
  before the present, where SaveFrame read an image already presented. A
  frame unwound past before the write still frees the readback buffer.
- Inter-pass barriers on the swapchain image carry the access masks of the
  stages the passes use.

Deferred deletion (issue #101)
- VulkanBuffer, Mesh, VulkanShader, ComputeShader and PipelineRTVulkan hand
  their handles to the deletion queue from their destructors and moves, as
  a frame in flight may still use them; a static owner goes after the
  driver has torn down. VulkanShader and PipelineRTVulkan move and can't be
  copied; re-Load and re-Init queue the old pipeline.
- The queue and its mutex are never destroyed, and the queue takes handles
  from any thread; Device::frameCounter is atomic. Both the queue and the
  staging rings count frames as one window renders them (documented).
- DrainDeletions also lets every buffer's staging ring start over: without
  a Window nothing else reclaims either, so an app without one calls it
  after waiting for its work.

Meshes and buffers
- A Mesh rebuild queues the old acceleration structure and builds into the
  same storage after a barrier, so the BLAS address stays put when it fits.
  The refit and rebuild barriers cover compute ray queries too.
- RTBuildOptions::noDuplicateAnyHit. Mesh::geometryFlags replaces opaque,
  and the refit and rebuild fallbacks keep the flags.
- A re-upload into a buffer a frame in flight may read (UploadDeviceLocal
  of the same size, UploadDeviceLocalRange) copies on the GPU, after those
  reads, through a per-frame staging ring; on ReBAR it used to write the
  buffer in place. Empty uploads record nothing.
- A compressed Mesh::Build that keeps its buffers decompresses into them
  after the reads of frames in flight, and the hit shaders and ray queries
  wait for the decompress as well as the BLAS build. Its vertex buffer
  takes a ranged refit's copies.
- Mesh move-assigns (queueing the BLAS it replaces), so it can be replaced
  in place or erased from a vector.
- VulkanBuffer::Create asks for a device address only for buffers with
  SHADER_DEVICE_ADDRESS usage, as vkGetBufferDeviceAddress requires; the
  others have address 0. UIRenderer::RegisterBuffer refuses those.
- Build inputs are synchronized as shader reads.

Other
- RTPass pushes the app's own push data and traces at an explicit size.
- DescriptorHeapVulkan hands freed slot ranges out again (they leaked), and
  keeps its free lists sorted.
- ImageVulkan's mip chain clamps each level to 1 texel: a non-square
  image's shorter side went to 0.
- VulkanShader throws on a missing file instead of failing in bad_alloc.
- CRAFTER_GRAPHICS_DEVICE picks the GPU by its number among the distinct
  GPUs Vulkan lists; anything but such a number is an error.
- The debug messenger prints info messages only with
  CRAFTER_GRAPHICS_VALIDATION set.
- Windows: the WinRT gamepad code moved to a C file (mingw-w64's WinRT
  headers don't compile as C++), -lcombase dropped, a reference leak fixed.
- project.cpp honours --local for Crafter.Event, Math and Asset, as Asset
  does for Math, and builds every test through one helper, projectArgs
  included.
- Tests: DebugMessengerSeverity, DeletionQueueThreads, DeviceSelection,
  HeapSlotReuse, MeshLifetime, MipChainNonSquare, PipelineLifetime,
  RTPassPushData, SwapchainTransferDst, UploadInFlight, VulkanBufferAddress,
  VulkanShaderLifetime, WindowUpdateClock; others extended.

Breaking: SaveFrame is CaptureNextFrame; Mesh::opaque is geometryFlags; a
buffer without SHADER_DEVICE_ADDRESS usage has no address; frameCounter is
std::atomic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:20:29 +02:00

217 lines
12 KiB
C++

//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Issue #115: the frame loop's inter-pass and frame-edge (acquire/present)
// barriers used to set BOTH stage masks to VK_PIPELINE_STAGE_ALL_COMMANDS_BIT
// and the inter-pass dependency to a queue-wide VkMemoryBarrier — flushing
// every cache and fully serialising against the whole pipeline every frame,
// when all the next pass needs is the swapchain image the previous one wrote.
//
// The fix scopes the inter-pass dependency to the swapchain image's single
// colour subresource (BuildSwapchainInterPassBarrier) and narrows the stage
// masks to the stages that actually touch that image, derived per pass via the
// polymorphic RenderPass::SwapchainStage() and unioned across the frame by
// SwapchainStageUnion. The load-bearing correctness constraint is that this
// derivation is per-pass: a compute pass writes via COMPUTE_SHADER, a ray-
// tracing pass via RAY_TRACING_SHADER, so a hardcoded compute mask would
// UNDER-synchronise an RT pass and corrupt the image. All three helpers are
// pure CPU logic over the pass list, so this test drives them directly with no
// GPU device at runtime — mirroring MipChainBarrierBatch / UploadStrategy.
#include <cstdlib>
#include "vulkan/vulkan.h"
import Crafter.Graphics;
import std;
using namespace Crafter;
namespace {
int failures = 0;
void Check(bool ok, std::string_view what) {
std::println("{} {}", ok ? "PASS" : "FAIL", what);
if (!ok) ++failures;
}
// A pass that never overrides SwapchainStage(): stands in for any future pass
// that forgets to narrow its stage, and must fall back to the conservative
// writer union rather than silently picking one stage (which could under-sync).
struct DefaultPass : RenderPass {
void Record(GraphicsCommandBuffer, std::uint32_t, Window&) override {}
};
VkImage SentinelImage() {
return reinterpret_cast<VkImage>(static_cast<std::uintptr_t>(0xC0FFEE));
}
} // namespace
int main() {
// ─── per-pass SwapchainStage() polymorphism ─────────────────────────────
// The whole reason the fix is "moderate, not trivial": these two stages
// MUST differ, or scoping an RT pass's barrier to the compute stage would
// under-synchronise it. Pin that they are distinct and each correct.
Check(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT != VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR,
"compute and ray-tracing write stages are distinct (the under-sync trap)");
UIRenderer ui; // real compute UI pass
RTPass rt(nullptr); // real RT pass (SwapchainStage doesn't touch the pipeline)
DefaultPass def; // un-overridden fallback
// Access through the base pointer — the frame loop only ever sees RenderPass*.
RenderPass* uiBase = &ui;
RenderPass* rtBase = &rt;
RenderPass* defBase = &def;
Check(uiBase->SwapchainStage() == VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
"UIRenderer reports the COMPUTE_SHADER stage");
Check(rtBase->SwapchainStage() == VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR,
"RTPass reports the RAY_TRACING_SHADER stage");
Check(defBase->SwapchainStage() == kSwapchainWriterStages,
"an un-overridden pass falls back to the conservative writer union");
// ─── the conservative writer union constant ─────────────────────────────
constexpr VkPipelineStageFlags expectedUnion =
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT
| VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR
| VK_PIPELINE_STAGE_TRANSFER_BIT;
Check(kSwapchainWriterStages == expectedUnion,
"writer union = COMPUTE_SHADER | RAY_TRACING_SHADER | TRANSFER");
// The point of the whole change: even the conservative fallback is far
// tighter than the ALL_COMMANDS mask it replaces.
Check(kSwapchainWriterStages != VK_PIPELINE_STAGE_ALL_COMMANDS_BIT,
"writer union is narrower than ALL_COMMANDS");
// ─── SwapchainStageUnion over the frame's pass list ─────────────────────
// Empty frame: no writers to derive from, so fall back to the conservative
// union (the image is still transitioned, just never written).
Check(SwapchainStageUnion(std::span<RenderPass* const>{}) == kSwapchainWriterStages,
"empty pass list falls back to the conservative writer union");
// All-compute frame: the union narrows to COMPUTE_SHADER ONLY — this is the
// real perf win, no RAY_TRACING / TRANSFER / ALL_COMMANDS dragged in.
{
std::array<RenderPass*, 2> computeOnly = {uiBase, uiBase};
VkPipelineStageFlags u = SwapchainStageUnion(computeOnly);
Check(u == VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
"all-compute frame narrows the union to COMPUTE_SHADER only");
Check((u & VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR) == 0,
"all-compute frame does NOT pull in RAY_TRACING_SHADER");
}
// RT-only frame: narrows to RAY_TRACING_SHADER only.
{
std::array<RenderPass*, 1> rtOnly = {rtBase};
Check(SwapchainStageUnion(rtOnly) == VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR,
"RT-only frame narrows the union to RAY_TRACING_SHADER only");
}
// Mixed frame: the union folds in BOTH stages — never under-syncs the RT
// pass, never over-syncs to ALL_COMMANDS.
{
std::array<RenderPass*, 2> mixed = {uiBase, rtBase};
VkPipelineStageFlags u = SwapchainStageUnion(mixed);
Check(u == (VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR),
"mixed compute+RT frame unions COMPUTE_SHADER | RAY_TRACING_SHADER");
Check((u & VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR) != 0,
"mixed frame keeps RAY_TRACING_SHADER (no RT under-sync)");
}
// A frame containing an un-overridden pass conservatively widens to the
// full writer union — safe by construction.
{
std::array<RenderPass*, 2> withDefault = {uiBase, defBase};
Check(SwapchainStageUnion(withDefault) == kSwapchainWriterStages,
"a frame with an un-overridden pass widens to the conservative union");
}
// ─── BuildSwapchainInterPassBarrier ─────────────────────────────────────
// Replaces the queue-wide VkMemoryBarrier: an image memory barrier scoped
// to the swapchain image's single colour subresource, GENERAL->GENERAL (no
// layout change — the storage image stays bound), matching the intra-pass
// UI barrier's access scope.
{
VkImageMemoryBarrier b = BuildSwapchainInterPassBarrier(SentinelImage(),
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR);
Check(b.sType == VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER, "inter-pass barrier sType is IMAGE_MEMORY_BARRIER");
Check(b.image == SentinelImage(), "inter-pass barrier carries the swapchain image handle");
Check(b.oldLayout == VK_IMAGE_LAYOUT_GENERAL && b.newLayout == VK_IMAGE_LAYOUT_GENERAL,
"inter-pass barrier is GENERAL->GENERAL (pure memory dependency, no transition)");
Check(b.srcAccessMask == VK_ACCESS_SHADER_WRITE_BIT,
"inter-pass barrier src access is shader write");
Check(b.dstAccessMask == (VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT),
"inter-pass barrier dst access is shader read|write");
Check(b.srcQueueFamilyIndex == VK_QUEUE_FAMILY_IGNORED && b.dstQueueFamilyIndex == VK_QUEUE_FAMILY_IGNORED,
"inter-pass barrier is not a queue-family transfer");
Check(b.subresourceRange.aspectMask == VK_IMAGE_ASPECT_COLOR_BIT,
"inter-pass barrier covers the colour aspect");
Check(b.subresourceRange.levelCount == 1 && b.subresourceRange.layerCount == 1,
"inter-pass barrier is scoped to the single swapchain subresource");
}
// The access masks follow the two passes' stages: a transfer pass (a
// clear or blit) writes with TRANSFER_WRITE, and SHADER_WRITE in a
// barrier whose source stage is TRANSFER is invalid
// (VUID-vkCmdPipelineBarrier-pImageMemoryBarriers-02818).
{
const VkImageMemoryBarrier clearThenTrace = BuildSwapchainInterPassBarrier(SentinelImage(),
VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR);
Check(clearThenTrace.srcAccessMask == VK_ACCESS_TRANSFER_WRITE_BIT,
"after a transfer pass the src access is transfer write");
Check(clearThenTrace.dstAccessMask == (VK_ACCESS_SHADER_READ_BIT | VK_ACCESS_SHADER_WRITE_BIT),
"before a ray-tracing pass the dst access is shader read|write");
const VkImageMemoryBarrier traceThenBlit = BuildSwapchainInterPassBarrier(SentinelImage(),
VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR, VK_PIPELINE_STAGE_TRANSFER_BIT);
Check(traceThenBlit.srcAccessMask == VK_ACCESS_SHADER_WRITE_BIT,
"after a ray-tracing pass the src access is shader write");
Check(traceThenBlit.dstAccessMask == (VK_ACCESS_TRANSFER_READ_BIT | VK_ACCESS_TRANSFER_WRITE_BIT),
"before a transfer pass the dst access is transfer read|write");
}
// ─── SwapchainWriterAccess (issue #153) ─────────────────────────────────
// The frame-edge (acquire dst / present src) barriers must set an access
// mask supported by their accompanying stage mask
// (VUID-vkCmdPipelineBarrier-pImageMemoryBarriers-02820). The old code
// hardcoded SHADER_WRITE|TRANSFER_WRITE on a barrier whose stage mask is the
// per-pass union, so an all-compute frame carried TRANSFER_WRITE into a
// COMPUTE_SHADER dst stage and fired the VUID every frame. The fix derives
// the access from the same stage union, so each stage only pulls in the
// access flags it actually supports.
{
// All-compute frame: COMPUTE_SHADER supports SHADER_WRITE only — and
// crucially NOT TRANSFER_WRITE (the bit that fired the VUID).
VkAccessFlags compute = SwapchainWriterAccess(VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT);
Check(compute == VK_ACCESS_SHADER_WRITE_BIT,
"compute-only writer access is SHADER_WRITE only");
Check((compute & VK_ACCESS_TRANSFER_WRITE_BIT) == 0,
"compute-only writer access does NOT include TRANSFER_WRITE (the VUID-02820 trap)");
// RT frame: RAY_TRACING_SHADER also writes via SHADER_WRITE, no transfer.
Check(SwapchainWriterAccess(VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR) == VK_ACCESS_SHADER_WRITE_BIT,
"RT-only writer access is SHADER_WRITE only");
// A transfer-stage writer pulls in TRANSFER_WRITE.
Check(SwapchainWriterAccess(VK_PIPELINE_STAGE_TRANSFER_BIT) == VK_ACCESS_TRANSFER_WRITE_BIT,
"transfer-only writer access is TRANSFER_WRITE only");
// The conservative writer union (un-overridden / empty frame) folds in
// both — and every bit it sets is supported by some stage in the union.
Check(SwapchainWriterAccess(kSwapchainWriterStages)
== (VK_ACCESS_SHADER_WRITE_BIT | VK_ACCESS_TRANSFER_WRITE_BIT),
"conservative writer union access is SHADER_WRITE | TRANSFER_WRITE");
// Mixed compute+RT (no transfer): still SHADER_WRITE only, no transfer.
VkAccessFlags mixed = SwapchainWriterAccess(
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT | VK_PIPELINE_STAGE_RAY_TRACING_SHADER_BIT_KHR);
Check(mixed == VK_ACCESS_SHADER_WRITE_BIT,
"compute+RT writer access is SHADER_WRITE only (no spurious TRANSFER_WRITE)");
}
if (failures != 0) {
std::println("{} check(s) failed", failures);
return EXIT_FAILURE;
}
std::println("all checks passed");
return EXIT_SUCCESS;
}