From 58e7129e16c20414dece8bb3cc71616f981cb36e Mon Sep 17 00:00:00 2001 From: Isaac Marovitz Date: Sat, 19 Sep 2026 15:04:40 -0400 Subject: [PATCH 1/4] Support texture readback through copyTextureRegion --- plume_d3d12.cpp | 8 +++++++- plume_metal.cpp | 31 +++++++++++++++++++++++++++++++ plume_vulkan.cpp | 21 +++++++++++++++++++++ 3 files changed, 59 insertions(+), 1 deletion(-) diff --git a/plume_d3d12.cpp b/plume_d3d12.cpp index f910f1d..5e3b2d6 100644 --- a/plume_d3d12.cpp +++ b/plume_d3d12.cpp @@ -2312,7 +2312,13 @@ namespace plume { const D3D12_TEXTURE_COPY_LOCATION copyDstLocation = toD3D12(dstLocation); const D3D12_TEXTURE_COPY_LOCATION copySrcLocation = toD3D12(srcLocation); - setSamplePositions(dstLocation.texture); + + const bool hasDestinationTexture = (dstLocation.texture != nullptr); + + if (hasDestinationTexture) { + setSamplePositions(dstLocation.texture); + } + d3d->CopyTextureRegion(©DstLocation, dstX, dstY, dstZ, ©SrcLocation, (srcBox != nullptr) ? ©Box : nullptr); resetSamplePositions(); } diff --git a/plume_metal.cpp b/plume_metal.cpp index b670ed3..06da923 100644 --- a/plume_metal.cpp +++ b/plume_metal.cpp @@ -3022,6 +3022,37 @@ namespace plume { dstOrigin ); activeBlitEncoder->popDebugGroup(); + } else if (dstLocation.type == RenderTextureCopyType::PLACED_FOOTPRINT && srcLocation.type == RenderTextureCopyType::SUBRESOURCE) { + assert(dstBuffer != nullptr); + assert(srcTexture != nullptr); + + // Calculate block size based on source texture format + const uint32_t blockWidth = RenderFormatBlockWidth(srcTexture->desc.format); + + const MTL::Size size = { dstLocation.placedFootprint.width, dstLocation.placedFootprint.height, dstLocation.placedFootprint.depth }; + + const uint32_t horizontalBlocks = (dstLocation.placedFootprint.rowWidth + blockWidth - 1) / blockWidth; + const uint32_t verticalBlocks = (dstLocation.placedFootprint.height + blockWidth - 1) / blockWidth; + const uint32_t bytesPerRow = horizontalBlocks * RenderFormatSize(srcTexture->desc.format); + const uint32_t bytesPerImage = bytesPerRow * verticalBlocks; + + const MTL::Origin srcOrigin = (srcBox != nullptr) + ? MTL::Origin { NS::UInteger(srcBox->left), NS::UInteger(srcBox->top), NS::UInteger(srcBox->front) } + : MTL::Origin { 0, 0, 0 }; + + activeBlitEncoder->pushDebugGroup(MTLSTR("CopyTextureRegion")); + activeBlitEncoder->copyFromTexture( + srcTexture->mtl, + srcLocation.subresource.arrayIndex, + srcLocation.subresource.mipLevel, + srcOrigin, + size, + dstBuffer->mtl, + dstLocation.placedFootprint.offset, + bytesPerRow, + bytesPerImage + ); + activeBlitEncoder->popDebugGroup(); } else { assert(dstTexture != nullptr); assert(srcTexture != nullptr); diff --git a/plume_vulkan.cpp b/plume_vulkan.cpp index a09dd93..5cc9296 100644 --- a/plume_vulkan.cpp +++ b/plume_vulkan.cpp @@ -3235,6 +3235,27 @@ namespace plume { imageCopy.imageExtent.depth = srcLocation.placedFootprint.depth; vkCmdCopyBufferToImage(vk, srcBuffer->vk, dstTexture->vk, toImageLayout(dstTexture->textureLayout), 1, &imageCopy); } + else if ((dstLocation.type == RenderTextureCopyType::PLACED_FOOTPRINT) && (srcLocation.type == RenderTextureCopyType::SUBRESOURCE)) { + assert(dstBuffer != nullptr); + assert(srcTexture != nullptr); + + const uint32_t blockWidth = RenderFormatBlockWidth(srcTexture->desc.format); + VkBufferImageCopy imageCopy = {}; + imageCopy.bufferOffset = dstLocation.placedFootprint.offset; + imageCopy.bufferRowLength = ((dstLocation.placedFootprint.rowWidth + blockWidth - 1) / blockWidth) * blockWidth; + imageCopy.bufferImageHeight = ((dstLocation.placedFootprint.height + blockWidth - 1) / blockWidth) * blockWidth; + imageCopy.imageSubresource.aspectMask = toAspectFlags(srcTexture->desc.format, srcTexture->desc.flags); + imageCopy.imageSubresource.baseArrayLayer = srcLocation.subresource.arrayIndex; + imageCopy.imageSubresource.layerCount = 1; + imageCopy.imageSubresource.mipLevel = srcLocation.subresource.mipLevel; + imageCopy.imageOffset.x = srcBox != nullptr ? srcBox->left : 0; + imageCopy.imageOffset.y = srcBox != nullptr ? srcBox->top : 0; + imageCopy.imageOffset.z = srcBox != nullptr ? srcBox->front : 0; + imageCopy.imageExtent.width = dstLocation.placedFootprint.width; + imageCopy.imageExtent.height = dstLocation.placedFootprint.height; + imageCopy.imageExtent.depth = dstLocation.placedFootprint.depth; + vkCmdCopyImageToBuffer(vk, srcTexture->vk, toImageLayout(srcTexture->textureLayout), dstBuffer->vk, 1, &imageCopy); + } else { VkImageCopy imageCopy = {}; imageCopy.srcSubresource.aspectMask = toAspectFlags(srcTexture->desc.format, srcTexture->desc.flags); From caffd992daae60faa9e2cfaa171e9cd471ca3a1c Mon Sep 17 00:00:00 2001 From: David Chavez Date: Mon, 12 Oct 2026 14:57:14 +0200 Subject: [PATCH 2/4] fix: honor dstX/Y/Z and srcBox size in texture readback --- plume_metal.cpp | 8 ++++++-- plume_vulkan.cpp | 12 ++++++++---- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/plume_metal.cpp b/plume_metal.cpp index 06da923..d89f793 100644 --- a/plume_metal.cpp +++ b/plume_metal.cpp @@ -3029,12 +3029,16 @@ namespace plume { // Calculate block size based on source texture format const uint32_t blockWidth = RenderFormatBlockWidth(srcTexture->desc.format); - const MTL::Size size = { dstLocation.placedFootprint.width, dstLocation.placedFootprint.height, dstLocation.placedFootprint.depth }; + const uint32_t mipLevel = srcLocation.subresource.mipLevel; + const MTL::Size size = (srcBox != nullptr) + ? MTL::Size { NS::UInteger(srcBox->right - srcBox->left), NS::UInteger(srcBox->bottom - srcBox->top), NS::UInteger(srcBox->back - srcBox->front) } + : MTL::Size { std::max(srcTexture->desc.width >> mipLevel, 1U), std::max(srcTexture->desc.height >> mipLevel, 1U), std::max(srcTexture->desc.depth >> mipLevel, 1U) }; const uint32_t horizontalBlocks = (dstLocation.placedFootprint.rowWidth + blockWidth - 1) / blockWidth; const uint32_t verticalBlocks = (dstLocation.placedFootprint.height + blockWidth - 1) / blockWidth; const uint32_t bytesPerRow = horizontalBlocks * RenderFormatSize(srcTexture->desc.format); const uint32_t bytesPerImage = bytesPerRow * verticalBlocks; + const uint64_t dstOffset = dstLocation.placedFootprint.offset + dstZ * bytesPerImage + (dstY / blockWidth) * bytesPerRow + (dstX / blockWidth) * RenderFormatSize(srcTexture->desc.format); const MTL::Origin srcOrigin = (srcBox != nullptr) ? MTL::Origin { NS::UInteger(srcBox->left), NS::UInteger(srcBox->top), NS::UInteger(srcBox->front) } @@ -3048,7 +3052,7 @@ namespace plume { srcOrigin, size, dstBuffer->mtl, - dstLocation.placedFootprint.offset, + dstOffset, bytesPerRow, bytesPerImage ); diff --git a/plume_vulkan.cpp b/plume_vulkan.cpp index 5cc9296..4fdc4bf 100644 --- a/plume_vulkan.cpp +++ b/plume_vulkan.cpp @@ -3240,8 +3240,12 @@ namespace plume { assert(srcTexture != nullptr); const uint32_t blockWidth = RenderFormatBlockWidth(srcTexture->desc.format); + const uint32_t blockSize = RenderFormatSize(srcTexture->desc.format); + const uint32_t bytesPerRow = ((dstLocation.placedFootprint.rowWidth + blockWidth - 1) / blockWidth) * blockSize; + const uint32_t bytesPerImage = bytesPerRow * ((dstLocation.placedFootprint.height + blockWidth - 1) / blockWidth); + const uint32_t mipLevel = srcLocation.subresource.mipLevel; VkBufferImageCopy imageCopy = {}; - imageCopy.bufferOffset = dstLocation.placedFootprint.offset; + imageCopy.bufferOffset = dstLocation.placedFootprint.offset + dstZ * bytesPerImage + (dstY / blockWidth) * bytesPerRow + (dstX / blockWidth) * blockSize; imageCopy.bufferRowLength = ((dstLocation.placedFootprint.rowWidth + blockWidth - 1) / blockWidth) * blockWidth; imageCopy.bufferImageHeight = ((dstLocation.placedFootprint.height + blockWidth - 1) / blockWidth) * blockWidth; imageCopy.imageSubresource.aspectMask = toAspectFlags(srcTexture->desc.format, srcTexture->desc.flags); @@ -3251,9 +3255,9 @@ namespace plume { imageCopy.imageOffset.x = srcBox != nullptr ? srcBox->left : 0; imageCopy.imageOffset.y = srcBox != nullptr ? srcBox->top : 0; imageCopy.imageOffset.z = srcBox != nullptr ? srcBox->front : 0; - imageCopy.imageExtent.width = dstLocation.placedFootprint.width; - imageCopy.imageExtent.height = dstLocation.placedFootprint.height; - imageCopy.imageExtent.depth = dstLocation.placedFootprint.depth; + imageCopy.imageExtent.width = srcBox != nullptr ? uint32_t(srcBox->right - srcBox->left) : std::max(srcTexture->desc.width >> mipLevel, 1U); + imageCopy.imageExtent.height = srcBox != nullptr ? uint32_t(srcBox->bottom - srcBox->top) : std::max(srcTexture->desc.height >> mipLevel, 1U); + imageCopy.imageExtent.depth = srcBox != nullptr ? uint32_t(srcBox->back - srcBox->front) : std::max(srcTexture->desc.depth >> mipLevel, 1U); vkCmdCopyImageToBuffer(vk, srcTexture->vk, toImageLayout(srcTexture->textureLayout), dstBuffer->vk, 1, &imageCopy); } else { From e4f880bf31e9f6d691bc2275b366ad7cbfa9ab3c Mon Sep 17 00:00:00 2001 From: David Chavez Date: Mon, 12 Oct 2026 14:57:36 +0200 Subject: [PATCH 3/4] feat: allow selecting a software d3d12 adapter by name --- plume_d3d12.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plume_d3d12.cpp b/plume_d3d12.cpp index 5e3b2d6..7caadc4 100644 --- a/plume_d3d12.cpp +++ b/plume_d3d12.cpp @@ -3660,8 +3660,10 @@ namespace plume { DXGI_ADAPTER_DESC1 adapterDesc; adapterOption->GetDesc1(&adapterDesc); - // Ignore remote or software adapters. - if (adapterDesc.Flags & (DXGI_ADAPTER_FLAG_REMOTE | DXGI_ADAPTER_FLAG_SOFTWARE)) { + // Ignore remote adapters, and software adapters unless one was requested by name. + std::string deviceName = Utf16ToUtf8(adapterDesc.Description); + bool softwareAdapter = (adapterDesc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE) != 0; + if ((adapterDesc.Flags & DXGI_ADAPTER_FLAG_REMOTE) || (softwareAdapter && (deviceName != preferredDeviceName))) { adapterOption->Release(); continue; } @@ -3744,7 +3746,6 @@ namespace plume { } // Pick this adapter and device if it has better feature support than the current one. - std::string deviceName = Utf16ToUtf8(adapterDesc.Description); bool preferOverNothing = (adapter == nullptr) || (d3d == nullptr); bool preferVideoMemory = adapterDesc.DedicatedVideoMemory > description.dedicatedVideoMemory; bool preferUserChoice = preferredDeviceName == deviceName; From 1479fb5ca8152656af3a4529abdcba62562ed2c0 Mon Sep 17 00:00:00 2001 From: David Chavez Date: Mon, 12 Oct 2026 15:06:56 +0200 Subject: [PATCH 4/4] feat: add correctness tests --- .github/workflows/validate.yml | 68 ++++ CMakeLists.txt | 7 + tests/CMakeLists.txt | 34 ++ tests/main.cpp | 584 +++++++++++++++++++++++++++++ tests/shaders/color.frag.hlsl | 8 + tests/shaders/color.vert.hlsl | 16 + tests/shaders/fullscreen.vert.hlsl | 11 + tests/shaders/texture.frag.hlsl | 11 + 8 files changed, 739 insertions(+) create mode 100644 tests/CMakeLists.txt create mode 100644 tests/main.cpp create mode 100644 tests/shaders/color.frag.hlsl create mode 100644 tests/shaders/color.vert.hlsl create mode 100644 tests/shaders/fullscreen.vert.hlsl create mode 100644 tests/shaders/texture.frag.hlsl diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 89a3391..b3b081d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -118,3 +118,71 @@ jobs: name: plume-macos-${{ matrix.type }}-${{ matrix.os }} path: | cmake-build/bin/plume_triangle + + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, windows-latest, macos-15] + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: ccache + uses: hendrikmuhs/ccache-action@v1.2 + with: + key: ${{ runner.os }}-plume-tests-ccache + + - name: Install Windows Dependencies + if: runner.os == 'Windows' + run: | + choco install ninja + Remove-Item -Path "C:\ProgramData\Chocolatey\bin\ccache.exe" -Force -ErrorAction SilentlyContinue + + # mesa-vulkan-drivers brings lavapipe, a software Vulkan driver. + - name: Install Linux Dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libx11-dev mesa-vulkan-drivers + + - name: Install macOS Dependencies + if: runner.os == 'macOS' + run: | + brew install ninja + + - name: Configure Developer Command Prompt + if: runner.os == 'Windows' + uses: ilammy/msvc-dev-cmd@v1 + + - name: Build Tests (Unix) + if: runner.os != 'Windows' + run: |- + # enable ccache + export PATH="/usr/lib/ccache:/usr/local/opt/ccache/libexec:$PATH" + + cmake -DCMAKE_BUILD_TYPE=Debug -DPLUME_BUILD_TESTS=ON -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_MAKE_PROGRAM=ninja -G Ninja -S . -B cmake-build + cmake --build cmake-build --config Debug --target plume_tests + + - name: Build Tests (Windows) + if: runner.os == 'Windows' + run: |- + $cpuCores = (Get-CimInstance -ClassName Win32_Processor).NumberOfLogicalProcessors + + # remove LLVM from PATH so it doesn't overshadow the one provided by VS + $env:PATH = ($env:PATH -split ';' | Where-Object { $_ -ne 'C:\Program Files\LLVM\bin' }) -join ';' + + cmake -DCMAKE_BUILD_TYPE=Debug -DPLUME_BUILD_TESTS=ON -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER=clang-cl -DCMAKE_C_COMPILER=clang-cl -DCMAKE_MAKE_PROGRAM=ninja -G Ninja -S . -B cmake-build + cmake --build cmake-build --config Debug --target plume_tests -j $cpuCores + + - name: Run Tests + if: runner.os != 'Windows' + run: ./cmake-build/bin/plume_tests + + # There is no GPU on the runners, so D3D12 runs on WARP. + - name: Run Tests (Windows) + if: runner.os == 'Windows' + run: ./cmake-build/bin/plume_tests.exe --device "Microsoft Basic Render Driver" diff --git a/CMakeLists.txt b/CMakeLists.txt index a7c5018..c59ab87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -22,6 +22,7 @@ cmake_dependent_option(PLUME_SDL3_VULKAN_ENABLED "Enable SDL3 Vulkan integration cmake_dependent_option(PLUME_D3D12_AGILITY_SDK_ENABLED "Enable D3D12 Agility SDK" OFF WIN32 OFF) cmake_dependent_option(PLUME_APPLE_RETINA_ENABLED "Enable Apple Retina display support" ON APPLE ON) option(PLUME_BUILD_EXAMPLES "Build example applications" OFF) +option(PLUME_BUILD_TESTS "Build correctness tests" OFF) # Windows-specific definitions if(WIN32) @@ -59,6 +60,7 @@ message(STATUS "Plume - SDL2 Vulkan integration: ${PLUME_SDL2_VULKAN_ENABLED}") message(STATUS "Plume - SDL3 Vulkan integration: ${PLUME_SDL3_VULKAN_ENABLED}") message(STATUS "Plume - D3D12 Agility SDK: ${PLUME_D3D12_AGILITY_SDK_ENABLED}") message(STATUS "Plume - Building examples: ${PLUME_BUILD_EXAMPLES}") +message(STATUS "Plume - Building tests: ${PLUME_BUILD_TESTS}") # Basic source files that are always included set(PLUME_SOURCES @@ -128,3 +130,8 @@ endif() if(PLUME_BUILD_EXAMPLES) add_subdirectory(examples) endif() + +# Add tests if requested +if(PLUME_BUILD_TESTS) + add_subdirectory(tests) +endif() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..4253fb8 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,34 @@ +if(PLUME_SDL2_VULKAN_ENABLED OR PLUME_SDL3_VULKAN_ENABLED) + message(FATAL_ERROR "Plume - The tests create the Vulkan interface without a window, which requires SDL Vulkan integration to be disabled") +endif() + +include(${CMAKE_SOURCE_DIR}/examples/cmake/PlumeShaders.cmake) +plume_shaders_init() + +add_executable(plume_tests main.cpp) + +target_link_libraries(plume_tests PRIVATE plume) + +plume_compile_vertex_shader(plume_tests "${CMAKE_CURRENT_SOURCE_DIR}/shaders/color.vert.hlsl" "testColorVert" "VSMain") +plume_compile_pixel_shader(plume_tests "${CMAKE_CURRENT_SOURCE_DIR}/shaders/color.frag.hlsl" "testColorFrag" "PSMain") +plume_compile_vertex_shader(plume_tests "${CMAKE_CURRENT_SOURCE_DIR}/shaders/fullscreen.vert.hlsl" "testFullscreenVert" "VSMain") +plume_compile_pixel_shader(plume_tests "${CMAKE_CURRENT_SOURCE_DIR}/shaders/texture.frag.hlsl" "testTextureFrag" "PSMain") + +if(APPLE) + target_link_libraries(plume_tests PRIVATE "-framework Metal -framework QuartzCore -framework CoreGraphics -framework Foundation -framework IOKit") +elseif(WIN32) + target_link_libraries(plume_tests PRIVATE d3d12 dxgi) +elseif(CMAKE_SYSTEM_NAME MATCHES "Linux") + find_package(X11 REQUIRED) + target_include_directories(plume_tests PRIVATE ${X11_INCLUDE_DIR}) + target_link_libraries(plume_tests PRIVATE ${X11_LIBRARIES}) +endif() + +target_include_directories(plume_tests PRIVATE + ${CMAKE_SOURCE_DIR} + ${CMAKE_BINARY_DIR} +) + +set_target_properties(plume_tests PROPERTIES + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin +) diff --git a/tests/main.cpp b/tests/main.cpp new file mode 100644 index 0000000..cdf6dce --- /dev/null +++ b/tests/main.cpp @@ -0,0 +1,584 @@ +// +// plume +// + +#include "plume_render_interface.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#include "shaders/testColorVert.hlsl.dxil.h" +#include "shaders/testColorFrag.hlsl.dxil.h" +#include "shaders/testFullscreenVert.hlsl.dxil.h" +#include "shaders/testTextureFrag.hlsl.dxil.h" +#endif +#include "shaders/testColorVert.hlsl.spirv.h" +#include "shaders/testColorFrag.hlsl.spirv.h" +#include "shaders/testFullscreenVert.hlsl.spirv.h" +#include "shaders/testTextureFrag.hlsl.spirv.h" +#ifdef __APPLE__ +#include "shaders/testColorVert.hlsl.metal.h" +#include "shaders/testColorFrag.hlsl.metal.h" +#include "shaders/testFullscreenVert.hlsl.metal.h" +#include "shaders/testTextureFrag.hlsl.metal.h" +#endif + +namespace plume { +#ifdef __APPLE__ + extern std::unique_ptr CreateMetalInterface(); +#endif +#ifdef _WIN32 + extern std::unique_ptr CreateD3D12Interface(); +#endif + extern std::unique_ptr CreateVulkanInterface(); +} + +#ifdef __APPLE__ +#define MSL_BLOB(name) name##BlobMSL, sizeof(name##BlobMSL) +#else +#define MSL_BLOB(name) nullptr, 0 +#endif + +#ifdef _WIN32 +#define DXIL_BLOB(name) name##BlobDXIL, sizeof(name##BlobDXIL) +#else +#define DXIL_BLOB(name) nullptr, 0 +#endif + +#define SHADER_BLOBS(name) name##BlobSPIRV, sizeof(name##BlobSPIRV), MSL_BLOB(name), DXIL_BLOB(name) + +using namespace plume; + +static const RenderFormat ColorFormat = RenderFormat::R8G8B8A8_UNORM; +static const uint32_t TargetSize = 16; + +// D3D12 requires 256-byte aligned rows for texture copies. +static const uint32_t RowPitch = 256; + +struct Pixel { + uint8_t r, g, b, a; +}; + +static const Pixel Untouched = { 0xEE, 0xEE, 0xEE, 0xEE }; + +struct TestContext { + RenderDevice *device = nullptr; + RenderCommandQueue *queue = nullptr; + RenderShaderFormat shaderFormat = RenderShaderFormat::UNKNOWN; +}; + +static void submit(TestContext &ctx, const std::function &record) { + std::unique_ptr commandList = ctx.queue->createCommandList(); + std::unique_ptr fence = ctx.device->createCommandFence(); + commandList->begin(); + record(commandList.get()); + commandList->end(); + + const RenderCommandList *commandLists[] = { commandList.get() }; + ctx.queue->executeCommandLists(commandLists, 1, nullptr, 0, nullptr, 0, fence.get()); + ctx.queue->waitForCommandFence(fence.get()); +} + +static std::unique_ptr createShader(TestContext &ctx, const char *entryPoint, const void *spirv, uint64_t spirvSize, const void *msl, uint64_t mslSize, const void *dxil, uint64_t dxilSize) { + switch (ctx.shaderFormat) { + case RenderShaderFormat::SPIRV: + return ctx.device->createShader(spirv, spirvSize, entryPoint, ctx.shaderFormat); + case RenderShaderFormat::METAL: + return ctx.device->createShader(msl, mslSize, entryPoint, ctx.shaderFormat); + case RenderShaderFormat::DXIL: + return ctx.device->createShader(dxil, dxilSize, entryPoint, ctx.shaderFormat); + default: + return nullptr; + } +} + +static void uploadTexture(TestContext &ctx, RenderTexture *texture, uint32_t mipLevel, uint32_t width, uint32_t height, const std::vector &texels) { + std::unique_ptr buffer = ctx.device->createBuffer(RenderBufferDesc::UploadBuffer(RowPitch * height)); + uint8_t *mapped = static_cast(buffer->map()); + for (uint32_t y = 0; y < height; y++) { + memcpy(mapped + y * RowPitch, &texels[y * width], width * sizeof(Pixel)); + } + + buffer->unmap(); + + submit(ctx, [&](RenderCommandList *commandList) { + commandList->barriers(RenderBarrierStage::COPY, RenderTextureBarrier(texture, RenderTextureLayout::COPY_DEST)); + commandList->copyTextureRegion( + RenderTextureCopyLocation::Subresource(texture, mipLevel), + RenderTextureCopyLocation::PlacedFootprint(buffer.get(), ColorFormat, width, height, 1, RowPitch / sizeof(Pixel))); + commandList->barriers(RenderBarrierStage::GRAPHICS, RenderTextureBarrier(texture, RenderTextureLayout::SHADER_READ)); + }); +} + +static std::vector readTexture(TestContext &ctx, RenderTexture *texture, uint32_t mipLevel, uint32_t width, uint32_t height, uint32_t dstX = 0, uint32_t dstY = 0, const RenderBox *srcBox = nullptr) { + std::unique_ptr buffer = ctx.device->createBuffer(RenderBufferDesc::ReadbackBuffer(RowPitch * height)); + Pixel *mapped = static_cast(buffer->map()); + std::fill(mapped, mapped + (RowPitch / sizeof(Pixel)) * height, Untouched); + buffer->unmap(); + + submit(ctx, [&](RenderCommandList *commandList) { + commandList->barriers(RenderBarrierStage::COPY, RenderTextureBarrier(texture, RenderTextureLayout::COPY_SOURCE)); + commandList->copyTextureRegion( + RenderTextureCopyLocation::PlacedFootprint(buffer.get(), ColorFormat, width, height, 1, RowPitch / sizeof(Pixel)), + RenderTextureCopyLocation::Subresource(texture, mipLevel), dstX, dstY, 0, srcBox); + }); + + std::vector pixels(width * height); + const uint8_t *readback = static_cast(buffer->map()); + for (uint32_t y = 0; y < height; y++) { + memcpy(&pixels[y * width], readback + y * RowPitch, width * sizeof(Pixel)); + } + + buffer->unmap(); + return pixels; +} + +static std::string checkPixel(const std::vector &pixels, uint32_t width, uint32_t x, uint32_t y, Pixel expected, int tolerance = 1) { + const Pixel &actual = pixels[y * width + x]; + auto matches = [tolerance](uint8_t a, uint8_t b) { return std::abs(int(a) - int(b)) <= tolerance; }; + if (matches(actual.r, expected.r) && matches(actual.g, expected.g) && matches(actual.b, expected.b) && matches(actual.a, expected.a)) { + return {}; + } + + char error[128]; + snprintf(error, sizeof(error), "pixel (%u, %u) is (%u, %u, %u, %u), expected (%u, %u, %u, %u)", x, y, actual.r, actual.g, actual.b, actual.a, expected.r, expected.g, expected.b, expected.a); + return error; +} + +static std::string checkPixels(const std::vector &pixels, uint32_t width, uint32_t height, const std::function &expected, int tolerance = 1) { + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + std::string error = checkPixel(pixels, width, x, y, expected(x, y), tolerance); + if (!error.empty()) { + return error; + } + } + } + + return {}; +} + +static Pixel patternTexel(uint32_t x, uint32_t y, uint32_t mipLevel) { + return { uint8_t(x * 16 + 1), uint8_t(y * 16 + 2), uint8_t(mipLevel * 64 + 3), 0xFF }; +} + +static std::vector patternTexels(uint32_t width, uint32_t height, uint32_t mipLevel) { + std::vector texels(width * height); + for (uint32_t y = 0; y < height; y++) { + for (uint32_t x = 0; x < width; x++) { + texels[y * width + x] = patternTexel(x, y, mipLevel); + } + } + + return texels; +} + +struct Target { + std::unique_ptr color; + std::unique_ptr depth; + std::unique_ptr framebuffer; + bool stencil = false; + + Target(TestContext &ctx, RenderFormat depthFormat = RenderFormat::UNKNOWN) { + color = ctx.device->createTexture(RenderTextureDesc::ColorTarget(TargetSize, TargetSize, ColorFormat)); + if (depthFormat != RenderFormat::UNKNOWN) { + depth = ctx.device->createTexture(RenderTextureDesc::DepthTarget(TargetSize, TargetSize, depthFormat)); + stencil = (depthFormat == RenderFormat::D32_FLOAT_S8_UINT); + } + + const RenderTexture *colorTexture = color.get(); + framebuffer = ctx.device->createFramebuffer(RenderFramebufferDesc(&colorTexture, 1, depth.get())); + } + + void begin(RenderCommandList *commandList, RenderColor clearColor) { + commandList->barriers(RenderBarrierStage::GRAPHICS, RenderTextureBarrier(color.get(), RenderTextureLayout::COLOR_WRITE)); + if (depth != nullptr) { + commandList->barriers(RenderBarrierStage::GRAPHICS, RenderTextureBarrier(depth.get(), RenderTextureLayout::DEPTH_WRITE)); + } + + commandList->setFramebuffer(framebuffer.get()); + commandList->setViewports(RenderViewport(0.0f, 0.0f, float(TargetSize), float(TargetSize))); + commandList->setScissors(RenderRect(0, 0, TargetSize, TargetSize)); + commandList->clearColor(0, clearColor); + if (depth != nullptr) { + commandList->clearDepthStencil(true, stencil, 1.0f, 0); + } + } + + std::vector read(TestContext &ctx) { + return readTexture(ctx, color.get(), 0, TargetSize, TargetSize); + } +}; + +struct Vertex { + float position[3]; + float color[4]; +}; + +static const float Fullscreen[6] = { -1.0f, -1.0f, -1.0f, 3.0f, 3.0f, -1.0f }; +static const float LowerLeftHalf[6] = { -1.0f, -1.0f, -1.0f, 1.0f, 1.0f, -1.0f }; + +static const float FullscreenCounterClockwise[6] = { -1.0f, -1.0f, 3.0f, -1.0f, -1.0f, 3.0f }; + +static void addTriangle(std::vector &vertices, const float (&points)[6], float depth, RenderColor color) { + for (uint32_t i = 0; i < 3; i++) { + vertices.push_back({ { points[i * 2], points[i * 2 + 1], depth }, { color.r, color.g, color.b, color.a } }); + } +} + +struct ColorDraw { + std::unique_ptr vertexShader; + std::unique_ptr pixelShader; + std::unique_ptr layout; + std::unique_ptr vertexBuffer; + RenderInputSlot inputSlot = RenderInputSlot(0, sizeof(Vertex)); + RenderInputElement inputElements[2] = { + RenderInputElement("POSITION", 0, 0, RenderFormat::R32G32B32_FLOAT, 0, offsetof(Vertex, position)), + RenderInputElement("COLOR", 0, 1, RenderFormat::R32G32B32A32_FLOAT, 0, offsetof(Vertex, color)) + }; + + uint32_t vertexCount = 0; + + ColorDraw(TestContext &ctx, const std::vector &vertices) { + vertexShader = createShader(ctx, "VSMain", SHADER_BLOBS(testColorVert)); + pixelShader = createShader(ctx, "PSMain", SHADER_BLOBS(testColorFrag)); + + RenderPipelineLayoutDesc layoutDesc; + layoutDesc.allowInputLayout = true; + layout = ctx.device->createPipelineLayout(layoutDesc); + + vertexCount = uint32_t(vertices.size()); + vertexBuffer = ctx.device->createBuffer(RenderBufferDesc::VertexBuffer(vertexCount * sizeof(Vertex), RenderHeapType::UPLOAD)); + memcpy(vertexBuffer->map(), vertices.data(), vertexCount * sizeof(Vertex)); + vertexBuffer->unmap(); + } + + RenderGraphicsPipelineDesc pipelineDesc() const { + RenderGraphicsPipelineDesc desc; + desc.inputSlots = &inputSlot; + desc.inputSlotsCount = 1; + desc.inputElements = inputElements; + desc.inputElementsCount = 2; + desc.pipelineLayout = layout.get(); + desc.vertexShader = vertexShader.get(); + desc.pixelShader = pixelShader.get(); + desc.renderTargetFormat[0] = ColorFormat; + desc.renderTargetBlend[0] = RenderBlendDesc::Copy(); + desc.renderTargetCount = 1; + desc.primitiveTopology = RenderPrimitiveTopology::TRIANGLE_LIST; + return desc; + } + + void draw(RenderCommandList *commandList, const RenderPipeline *pipeline, uint32_t firstVertex, uint32_t count) { + const RenderVertexBufferView view(vertexBuffer.get(), vertexCount * sizeof(Vertex)); + commandList->setGraphicsPipelineLayout(layout.get()); + commandList->setPipeline(pipeline); + commandList->setVertexBuffers(0, &view, 1, &inputSlot); + commandList->drawInstanced(count, 1, firstVertex, 0); + } +}; + +static const RenderColor Black(0.0f, 0.0f, 0.0f, 1.0f); +static const RenderColor White(1.0f, 1.0f, 1.0f, 1.0f); +static const RenderColor Red(1.0f, 0.0f, 0.0f, 1.0f); +static const RenderColor Green(0.0f, 1.0f, 0.0f, 1.0f); +static const RenderColor Blue(0.0f, 0.0f, 1.0f, 1.0f); + +static std::string testClearColor(TestContext &ctx) { + Target target(ctx); + submit(ctx, [&](RenderCommandList *commandList) { + target.begin(commandList, RenderColor(0.25f, 0.5f, 0.75f, 1.0f)); + }); + + return checkPixels(target.read(ctx), TargetSize, TargetSize, [](uint32_t, uint32_t) { return Pixel{ 64, 128, 191, 255 }; }); +} + +static std::string testTextureReadbackRegion(TestContext &ctx) { + const uint32_t width = 16; + const uint32_t height = 16; + std::unique_ptr texture = ctx.device->createTexture(RenderTextureDesc::Texture2D(width, height, 1, ColorFormat)); + uploadTexture(ctx, texture.get(), 0, width, height, patternTexels(width, height, 0)); + + const RenderBox box(8, 4, 12, 6); + const uint32_t dstX = 3; + const uint32_t dstY = 2; + const uint32_t footprintWidth = 16; + const uint32_t footprintHeight = 8; + std::vector pixels = readTexture(ctx, texture.get(), 0, footprintWidth, footprintHeight, dstX, dstY, &box); + return checkPixels(pixels, footprintWidth, footprintHeight, [&](uint32_t x, uint32_t y) { + const bool copied = (x >= dstX) && (x < dstX + 4) && (y >= dstY) && (y < dstY + 2); + return copied ? patternTexel(box.left + x - dstX, box.top + y - dstY, 0) : Untouched; + }, 0); +} + +static std::string testTextureReadbackMip(TestContext &ctx) { + std::unique_ptr texture = ctx.device->createTexture(RenderTextureDesc::Texture2D(16, 8, 2, ColorFormat)); + uploadTexture(ctx, texture.get(), 1, 8, 4, patternTexels(8, 4, 1)); + + std::vector pixels = readTexture(ctx, texture.get(), 1, 16, 8); + return checkPixels(pixels, 16, 8, [](uint32_t x, uint32_t y) { + return ((x < 8) && (y < 4)) ? patternTexel(x, y, 1) : Untouched; + }, 0); +} + +static std::string testDepthTest(TestContext &ctx) { + std::vector vertices; + addTriangle(vertices, Fullscreen, 0.25f, Blue); + addTriangle(vertices, Fullscreen, 0.75f, Red); + ColorDraw colorDraw(ctx, vertices); + + RenderGraphicsPipelineDesc desc = colorDraw.pipelineDesc(); + desc.depthEnabled = true; + desc.depthWriteEnabled = true; + desc.depthFunction = RenderComparisonFunction::LESS; + desc.depthTargetFormat = RenderFormat::D32_FLOAT; + std::unique_ptr pipeline = ctx.device->createGraphicsPipeline(desc); + + Target target(ctx, RenderFormat::D32_FLOAT); + submit(ctx, [&](RenderCommandList *commandList) { + target.begin(commandList, Black); + colorDraw.draw(commandList, pipeline.get(), 0, 6); + }); + + return checkPixels(target.read(ctx), TargetSize, TargetSize, [](uint32_t, uint32_t) { return Pixel{ 0, 0, 255, 255 }; }); +} + +static std::string testStencilReference(TestContext &ctx) { + std::vector vertices; + addTriangle(vertices, LowerLeftHalf, 0.0f, Black); + addTriangle(vertices, Fullscreen, 0.0f, White); + ColorDraw colorDraw(ctx, vertices); + + RenderGraphicsPipelineDesc desc = colorDraw.pipelineDesc(); + desc.depthTargetFormat = RenderFormat::D32_FLOAT_S8_UINT; + desc.stencilEnabled = true; + desc.stencilReference = 1; + + RenderGraphicsPipelineDesc markDesc = desc; + markDesc.stencilFrontFace.compareFunction = RenderComparisonFunction::ALWAYS; + markDesc.stencilFrontFace.passOp = RenderStencilOp::REPLACE; + markDesc.stencilBackFace = markDesc.stencilFrontFace; + std::unique_ptr markPipeline = ctx.device->createGraphicsPipeline(markDesc); + + RenderGraphicsPipelineDesc fillDesc = desc; + fillDesc.stencilWriteMask = 0; + fillDesc.stencilFrontFace.compareFunction = RenderComparisonFunction::EQUAL; + fillDesc.stencilBackFace = fillDesc.stencilFrontFace; + std::unique_ptr fillPipeline = ctx.device->createGraphicsPipeline(fillDesc); + + Target target(ctx, RenderFormat::D32_FLOAT_S8_UINT); + submit(ctx, [&](RenderCommandList *commandList) { + target.begin(commandList, Black); + colorDraw.draw(commandList, markPipeline.get(), 0, 3); + colorDraw.draw(commandList, fillPipeline.get(), 3, 3); + }); + + // The diagonal sits on the edge of the marked triangle. + std::vector pixels = target.read(ctx); + for (uint32_t y = 0; y < TargetSize; y++) { + for (uint32_t x = 0; x < TargetSize; x++) { + if (x == y) { + continue; + } + + std::string error = checkPixel(pixels, TargetSize, x, y, (x < y) ? Pixel{ 255, 255, 255, 255 } : Pixel{ 0, 0, 0, 255 }); + if (!error.empty()) { + return error; + } + } + } + + return {}; +} + +static std::string testBlendAlpha(TestContext &ctx) { + std::vector vertices; + addTriangle(vertices, Fullscreen, 0.0f, RenderColor(1.0f, 0.0f, 0.0f, 0.25f)); + ColorDraw colorDraw(ctx, vertices); + + RenderGraphicsPipelineDesc desc = colorDraw.pipelineDesc(); + desc.renderTargetBlend[0] = RenderBlendDesc::AlphaBlend(); + std::unique_ptr pipeline = ctx.device->createGraphicsPipeline(desc); + + Target target(ctx); + submit(ctx, [&](RenderCommandList *commandList) { + target.begin(commandList, RenderColor(0.0f, 0.0f, 1.0f, 0.5f)); + colorDraw.draw(commandList, pipeline.get(), 0, 3); + }); + + return checkPixels(target.read(ctx), TargetSize, TargetSize, [](uint32_t, uint32_t) { return Pixel{ 64, 0, 191, 159 }; }); +} + +static std::string testCullBackFaces(TestContext &ctx) { + std::vector vertices; + addTriangle(vertices, Fullscreen, 0.0f, Green); + addTriangle(vertices, FullscreenCounterClockwise, 0.0f, Red); + ColorDraw colorDraw(ctx, vertices); + + RenderGraphicsPipelineDesc desc = colorDraw.pipelineDesc(); + desc.cullMode = RenderCullMode::BACK; + desc.frontFace = RenderFrontFace::CLOCKWISE; + std::unique_ptr pipeline = ctx.device->createGraphicsPipeline(desc); + + Target target(ctx); + submit(ctx, [&](RenderCommandList *commandList) { + target.begin(commandList, Black); + colorDraw.draw(commandList, pipeline.get(), 0, 6); + }); + + return checkPixels(target.read(ctx), TargetSize, TargetSize, [](uint32_t, uint32_t) { return Pixel{ 0, 255, 0, 255 }; }); +} + +static std::string testSamplerLinear(TestContext &ctx) { + // Linear filtering ramps between the texel centers. + std::unique_ptr texture = ctx.device->createTexture(RenderTextureDesc::Texture2D(2, 1, 1, ColorFormat)); + uploadTexture(ctx, texture.get(), 0, 2, 1, { { 0, 0, 0, 255 }, { 255, 255, 255, 255 } }); + + RenderSamplerDesc samplerDesc; + samplerDesc.minFilter = RenderFilter::LINEAR; + samplerDesc.magFilter = RenderFilter::LINEAR; + samplerDesc.addressU = RenderTextureAddressMode::CLAMP; + samplerDesc.addressV = RenderTextureAddressMode::CLAMP; + std::unique_ptr sampler = ctx.device->createSampler(samplerDesc); + + RenderDescriptorSetBuilder setBuilder; + setBuilder.begin(); + setBuilder.addTexture(0); + setBuilder.addSampler(1); + setBuilder.end(); + std::unique_ptr descriptorSet = setBuilder.create(ctx.device); + descriptorSet->setTexture(0, texture.get(), RenderTextureLayout::SHADER_READ); + descriptorSet->setSampler(1, sampler.get()); + + RenderPipelineLayoutBuilder layoutBuilder; + layoutBuilder.begin(); + layoutBuilder.addDescriptorSet(setBuilder); + layoutBuilder.end(); + std::unique_ptr layout = layoutBuilder.create(ctx.device); + + std::unique_ptr vertexShader = createShader(ctx, "VSMain", SHADER_BLOBS(testFullscreenVert)); + std::unique_ptr pixelShader = createShader(ctx, "PSMain", SHADER_BLOBS(testTextureFrag)); + RenderGraphicsPipelineDesc desc; + desc.pipelineLayout = layout.get(); + desc.vertexShader = vertexShader.get(); + desc.pixelShader = pixelShader.get(); + desc.renderTargetFormat[0] = ColorFormat; + desc.renderTargetBlend[0] = RenderBlendDesc::Copy(); + desc.renderTargetCount = 1; + desc.primitiveTopology = RenderPrimitiveTopology::TRIANGLE_LIST; + std::unique_ptr pipeline = ctx.device->createGraphicsPipeline(desc); + + Target target(ctx); + submit(ctx, [&](RenderCommandList *commandList) { + target.begin(commandList, Black); + commandList->setGraphicsPipelineLayout(layout.get()); + commandList->setPipeline(pipeline.get()); + commandList->setGraphicsDescriptorSet(descriptorSet.get(), 0); + commandList->drawInstanced(3, 1, 0, 0); + }); + + return checkPixels(target.read(ctx), TargetSize, TargetSize, [](uint32_t x, uint32_t) { + const float u = (x + 0.5f) / TargetSize; + const float value = std::clamp(u * 2.0f - 0.5f, 0.0f, 1.0f); + const uint8_t gray = uint8_t(value * 255.0f + 0.5f); + return Pixel{ gray, gray, gray, 255 }; + }, 2); +} + +struct Test { + const char *name; + std::string (*run)(TestContext &ctx); +}; + +static const Test Tests[] = { + { "clear_color", testClearColor }, + { "texture_readback_region", testTextureReadbackRegion }, + { "texture_readback_mip", testTextureReadbackMip }, + { "depth_test", testDepthTest }, + { "stencil_reference", testStencilReference }, + { "blend_alpha", testBlendAlpha }, + { "cull_back_faces", testCullBackFaces }, + { "sampler_linear", testSamplerLinear }, +}; + +static std::unique_ptr createInterface(const std::string &backend) { +#ifdef __APPLE__ + if (backend.empty() || (backend == "metal")) { + return CreateMetalInterface(); + } +#endif +#ifdef _WIN32 + if (backend.empty() || (backend == "d3d12")) { + return CreateD3D12Interface(); + } +#endif + if (backend.empty() || (backend == "vulkan")) { + return CreateVulkanInterface(); + } + + return nullptr; +} + +int main(int argc, char *argv[]) { + std::string backend; + std::string deviceName; + for (int i = 1; i < argc; i++) { + if ((strcmp(argv[i], "--backend") == 0) && (i + 1 < argc)) { + backend = argv[++i]; + } + else if ((strcmp(argv[i], "--device") == 0) && (i + 1 < argc)) { + deviceName = argv[++i]; + } + else { + fprintf(stderr, "Usage: plume_tests [--backend metal|vulkan|d3d12] [--device name]\n"); + return 1; + } + } + + std::unique_ptr renderInterface = createInterface(backend); + if (renderInterface == nullptr) { + fprintf(stderr, "Unable to create the render interface.\n"); + return 1; + } + + std::unique_ptr device = renderInterface->createDevice(deviceName); + if (device == nullptr) { + fprintf(stderr, "Unable to create a render device.\n"); + return 1; + } + + // The requested device is only a preference. + const std::string &name = device->getDescription().name; + if (!deviceName.empty() && (name != deviceName)) { + fprintf(stderr, "Requested device \"%s\" but got \"%s\".\n", deviceName.c_str(), name.c_str()); + return 1; + } + + std::unique_ptr queue = device->createCommandQueue(RenderCommandListType::DIRECT); + TestContext ctx = { device.get(), queue.get(), renderInterface->getCapabilities().shaderFormat }; + printf("Device: %s\n", name.c_str()); + + int failures = 0; + for (const Test &test : Tests) { + std::string error = test.run(ctx); + if (error.empty()) { + printf("PASS %s\n", test.name); + } + else { + printf("FAIL %s: %s\n", test.name, error.c_str()); + failures++; + } + } + + printf("%d of %d tests failed\n", failures, int(std::size(Tests))); + return (failures == 0) ? 0 : 1; +} diff --git a/tests/shaders/color.frag.hlsl b/tests/shaders/color.frag.hlsl new file mode 100644 index 0000000..f02fba2 --- /dev/null +++ b/tests/shaders/color.frag.hlsl @@ -0,0 +1,8 @@ +struct PSInput { + float4 position : SV_POSITION; + float4 color : COLOR; +}; + +float4 PSMain(PSInput input) : SV_TARGET { + return input.color; +} diff --git a/tests/shaders/color.vert.hlsl b/tests/shaders/color.vert.hlsl new file mode 100644 index 0000000..e995aa9 --- /dev/null +++ b/tests/shaders/color.vert.hlsl @@ -0,0 +1,16 @@ +struct VSInput { + float3 position : POSITION; + float4 color : COLOR; +}; + +struct VSOutput { + float4 position : SV_POSITION; + float4 color : COLOR; +}; + +VSOutput VSMain(VSInput input) { + VSOutput output; + output.position = float4(input.position, 1.0); + output.color = input.color; + return output; +} diff --git a/tests/shaders/fullscreen.vert.hlsl b/tests/shaders/fullscreen.vert.hlsl new file mode 100644 index 0000000..39197fc --- /dev/null +++ b/tests/shaders/fullscreen.vert.hlsl @@ -0,0 +1,11 @@ +struct VSOutput { + float4 position : SV_POSITION; + float2 uv : TEXCOORD0; +}; + +VSOutput VSMain(uint vertexId : SV_VertexID) { + VSOutput output; + output.uv = float2((vertexId << 1) & 2, vertexId & 2); + output.position = float4(output.uv * float2(2.0, -2.0) + float2(-1.0, 1.0), 0.0, 1.0); + return output; +} diff --git a/tests/shaders/texture.frag.hlsl b/tests/shaders/texture.frag.hlsl new file mode 100644 index 0000000..afab949 --- /dev/null +++ b/tests/shaders/texture.frag.hlsl @@ -0,0 +1,11 @@ +[[vk::binding(0, 0)]] Texture2D colorTexture : register(t0); +[[vk::binding(1, 0)]] SamplerState colorSampler : register(s1); + +struct PSInput { + float4 position : SV_POSITION; + float2 uv : TEXCOORD0; +}; + +float4 PSMain(PSInput input) : SV_TARGET { + return colorTexture.SampleLevel(colorSampler, input.uv, 0.0); +}