From 238ebb50f48a119ddea5cb507a12c6741de9acb5 Mon Sep 17 00:00:00 2001 From: ssdfasd <2156608475@qq.com> Date: Wed, 25 Mar 2026 19:00:30 +0800 Subject: [PATCH] test: Add RHI integration test framework Add integration tests for RHI module: - Add tests/RHI/integration/ directory with CMakeLists.txt - Add RHIIntegrationFixture for shared test utilities - Add minimal integration test (window creation, basic rendering) - Add compare_ppm.py for image comparison - Add run_integration_test.py test runner script These integration tests verify the complete rendering pipeline by comparing rendered output against ground truth PPM files. --- tests/RHI/integration/CMakeLists.txt | 7 + tests/RHI/integration/compare_ppm.py | 75 +++++++ .../fixtures/RHIIntegrationFixture.cpp | 188 ++++++++++++++++++ .../fixtures/RHIIntegrationFixture.h | 55 +++++ tests/RHI/integration/minimal/CMakeLists.txt | 58 ++++++ tests/RHI/integration/minimal/main.cpp | 73 +++++++ tests/RHI/integration/run_integration_test.py | 126 ++++++++++++ 7 files changed, 582 insertions(+) create mode 100644 tests/RHI/integration/CMakeLists.txt create mode 100644 tests/RHI/integration/compare_ppm.py create mode 100644 tests/RHI/integration/fixtures/RHIIntegrationFixture.cpp create mode 100644 tests/RHI/integration/fixtures/RHIIntegrationFixture.h create mode 100644 tests/RHI/integration/minimal/CMakeLists.txt create mode 100644 tests/RHI/integration/minimal/main.cpp create mode 100644 tests/RHI/integration/run_integration_test.py diff --git a/tests/RHI/integration/CMakeLists.txt b/tests/RHI/integration/CMakeLists.txt new file mode 100644 index 00000000..cab68661 --- /dev/null +++ b/tests/RHI/integration/CMakeLists.txt @@ -0,0 +1,7 @@ +cmake_minimum_required(VERSION 3.15) + +find_package(GTest REQUIRED) + +enable_testing() + +add_subdirectory(minimal) \ No newline at end of file diff --git a/tests/RHI/integration/compare_ppm.py b/tests/RHI/integration/compare_ppm.py new file mode 100644 index 00000000..fdb358da --- /dev/null +++ b/tests/RHI/integration/compare_ppm.py @@ -0,0 +1,75 @@ +import sys +import os + + +def read_ppm(filename): + with open(filename, "rb") as f: + header = f.readline() + if header != b"P6\n": + raise ValueError(f"Not a P6 PPM file: {filename}") + + while True: + line = f.readline() + if not line.startswith(b"#"): + break + + dims = line.split() + width, height = int(dims[0]), int(dims[1]) + + line = f.readline() + maxval = int(line.strip()) + + data = f.read() + return width, height, data + + +def compare_ppm(file1, file2, threshold): + w1, h1, d1 = read_ppm(file1) + w2, h2, d2 = read_ppm(file2) + + if w1 != w2 or h1 != h2: + print(f"ERROR: Size mismatch - {file1}: {w1}x{h1}, {file2}: {w2}x{h2}") + return False + + total_pixels = w1 * h1 + diff_count = 0 + + for i in range(len(d1)): + diff = abs(d1[i] - d2[i]) + if diff > threshold: + diff_count += 1 + + diff_percent = (diff_count / (total_pixels * 3)) * 100 + + print(f"Image 1: {file1} ({w1}x{h1})") + print(f"Image 2: {file2} ({w2}x{h2})") + print(f"Threshold: {threshold}") + print(f"Different pixels: {diff_count} / {total_pixels * 3} ({diff_percent:.2f}%)") + + if diff_percent <= 1.0: + print("PASS: Images match!") + return True + else: + print("FAIL: Images differ!") + return False + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: python compare_ppm.py ") + sys.exit(1) + + file1 = sys.argv[1] + file2 = sys.argv[2] + threshold = int(sys.argv[3]) + + if not os.path.exists(file1): + print(f"ERROR: File not found: {file1}") + sys.exit(1) + + if not os.path.exists(file2): + print(f"ERROR: File not found: {file2}") + sys.exit(1) + + result = compare_ppm(file1, file2, threshold) + sys.exit(0 if result else 1) \ No newline at end of file diff --git a/tests/RHI/integration/fixtures/RHIIntegrationFixture.cpp b/tests/RHI/integration/fixtures/RHIIntegrationFixture.cpp new file mode 100644 index 00000000..ec72db5a --- /dev/null +++ b/tests/RHI/integration/fixtures/RHIIntegrationFixture.cpp @@ -0,0 +1,188 @@ +#include "RHIIntegrationFixture.h" +#include +#include +#include +#include + +#include "XCEngine/RHI/D3D12/D3D12Device.h" +#include "XCEngine/RHI/D3D12/D3D12SwapChain.h" +#include "XCEngine/RHI/D3D12/D3D12Texture.h" +#include "XCEngine/RHI/D3D12/D3D12CommandList.h" +#include "XCEngine/RHI/RHIFence.h" +#include "XCEngine/RHI/RHIScreenshot.h" +#include "XCEngine/RHI/RHIEnums.h" + +namespace XCEngine { +namespace RHI { +namespace Integration { + +void RHIIntegrationFixture::SetUp() { + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(WNDCLASSEXW); + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandle(nullptr); + wc.lpszClassName = L"XCEngine_RHI_Integration_Test"; + RegisterClassExW(&wc); + + const int width = 1280; + const int height = 720; + RECT rect = { 0, 0, width, height }; + AdjustWindowRect(&rect, WS_OVERLAPPEDWINDOW, FALSE); + + mWindow = CreateWindowExW( + 0, + L"XCEngine_RHI_Integration_Test", + L"RHI Integration Test", + WS_OVERLAPPEDWINDOW, + CW_USEDEFAULT, CW_USEDEFAULT, + rect.right - rect.left, rect.bottom - rect.top, + NULL, NULL, GetModuleHandle(nullptr), NULL + ); + + ASSERT_NE(mWindow, nullptr); + + mDevice = RHIFactory::CreateRHIDevice(GetParam()); + ASSERT_NE(mDevice, nullptr); + + bool initResult = false; + if (GetParam() == RHIType::D3D12) { + RHIDeviceDesc desc = {}; + desc.enableDebugLayer = false; + desc.enableGPUValidation = false; + initResult = mDevice->Initialize(desc); + } else if (GetParam() == RHIType::OpenGL) { + auto* oglDevice = static_cast(mDevice); + initResult = oglDevice->InitializeWithExistingWindow(mWindow); + } + ASSERT_TRUE(initResult); + + SwapChainDesc swapDesc = {}; + swapDesc.windowHandle = mWindow; + swapDesc.width = width; + swapDesc.height = height; + swapDesc.bufferCount = 2; + mSwapChain = mDevice->CreateSwapChain(swapDesc); + ASSERT_NE(mSwapChain, nullptr); + + CommandQueueDesc queueDesc = {}; + queueDesc.queueType = static_cast(CommandQueueType::Direct); + mCommandQueue = mDevice->CreateCommandQueue(queueDesc); + ASSERT_NE(mCommandQueue, nullptr); + + CommandListDesc cmdDesc = {}; + cmdDesc.commandListType = static_cast(CommandQueueType::Direct); + mCommandList = mDevice->CreateCommandList(cmdDesc); + ASSERT_NE(mCommandList, nullptr); + + mScreenshot = RHIScreenshot::Create(GetParam()); + ASSERT_NE(mScreenshot, nullptr); + + ShowWindow(mWindow, SW_SHOW); + UpdateWindow(mWindow); +} + +void RHIIntegrationFixture::BeginRender() { + mCurrentBackBufferIndex = mSwapChain->GetCurrentBackBufferIndex(); +} + +void RHIIntegrationFixture::EndRender() { +} + +void RHIIntegrationFixture::TearDown() { + if (mScreenshot) { + mScreenshot->Shutdown(); + delete mScreenshot; + mScreenshot = nullptr; + } + + if (mCommandList) { + mCommandList->Shutdown(); + delete mCommandList; + mCommandList = nullptr; + } + + if (mCommandQueue) { + mCommandQueue->Shutdown(); + delete mCommandQueue; + mCommandQueue = nullptr; + } + + if (mSwapChain) { + mSwapChain->Shutdown(); + delete mSwapChain; + mSwapChain = nullptr; + } + + if (mDevice) { + mDevice->Shutdown(); + delete mDevice; + mDevice = nullptr; + } + + if (mWindow) { + DestroyWindow(mWindow); + mWindow = nullptr; + } +} + +void RHIIntegrationFixture::WaitForGPU() { + if (mDevice == nullptr || mCommandQueue == nullptr) { + return; + } + + if (GetParam() == RHIType::D3D12) { + FenceDesc fenceDesc = {}; + fenceDesc.initialValue = 0; + fenceDesc.flags = 0; + auto* fence = mDevice->CreateFence(fenceDesc); + if (fence) { + mCommandQueue->Signal(fence, 1); + fence->Wait(1); + + for (int i = 0; i < 100; i++) { + if (fence->GetCompletedValue() >= 1) { + break; + } + Sleep(10); + } + + fence->Shutdown(); + delete fence; + } + Sleep(100); + } +} + +bool RHIIntegrationFixture::TakeScreenshot(const char* filename) { + if (!mScreenshot || !mDevice || !mSwapChain) { + return false; + } + return mScreenshot->Capture(mDevice, mSwapChain, filename); +} + +bool RHIIntegrationFixture::CompareWithGoldenTemplate(const char* outputPpm, const char* gtPpm, float threshold) { + namespace fs = std::filesystem; + + fs::path exeDir = fs::current_path(); + fs::path outputPath = exeDir / outputPpm; + fs::path gtPath = exeDir / gtPpm; + + if (!fs::exists(outputPath)) { + std::cerr << "Output file not found: " << outputPath << std::endl; + return false; + } + + if (!fs::exists(gtPath)) { + std::cerr << "Golden template not found: " << gtPath << std::endl; + return false; + } + + std::string cmd = "python compare_ppm.py \"" + outputPath.string() + "\" \"" + gtPath.string() + "\" " + std::to_string(static_cast(threshold)); + int result = system(cmd.c_str()); + return result == 0; +} + +} // namespace Integration +} // namespace RHI +} // namespace XCEngine \ No newline at end of file diff --git a/tests/RHI/integration/fixtures/RHIIntegrationFixture.h b/tests/RHI/integration/fixtures/RHIIntegrationFixture.h new file mode 100644 index 00000000..0be624d4 --- /dev/null +++ b/tests/RHI/integration/fixtures/RHIIntegrationFixture.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include + +#include "XCEngine/RHI/RHIFactory.h" +#include "XCEngine/RHI/RHIDevice.h" +#include "XCEngine/RHI/RHICommandQueue.h" +#include "XCEngine/RHI/RHICommandList.h" +#include "XCEngine/RHI/RHISwapChain.h" +#include "XCEngine/RHI/RHIScreenshot.h" +#include "XCEngine/RHI/RHIEnums.h" +#include "XCEngine/RHI/OpenGL/OpenGLDevice.h" + +namespace XCEngine { +namespace RHI { +namespace Integration { + +class RHIIntegrationFixture : public ::testing::TestWithParam { +protected: + void SetUp() override; + void TearDown() override; + + void WaitForGPU(); + void BeginRender(); + void EndRender(); + + RHIDevice* GetDevice() { return mDevice; } + RHISwapChain* GetSwapChain() { return mSwapChain; } + RHICommandQueue* GetCommandQueue() { return mCommandQueue; } + RHICommandList* GetCommandList() { return mCommandList; } + RHIScreenshot* GetScreenshot() { return mScreenshot; } + RHIType GetBackendType() const { return GetParam(); } + HWND GetWindowHandle() const { return mWindow; } + int GetCurrentBackBufferIndex() const { return mCurrentBackBufferIndex; } + + virtual void RenderFrame() {} + + bool TakeScreenshot(const char* filename); + bool CompareWithGoldenTemplate(const char* outputPpm, const char* gtPpm, float threshold = 0.0f); + +private: + RHIDevice* mDevice = nullptr; + RHISwapChain* mSwapChain = nullptr; + RHICommandQueue* mCommandQueue = nullptr; + RHICommandList* mCommandList = nullptr; + RHIScreenshot* mScreenshot = nullptr; + HWND mWindow = nullptr; + int mCurrentBackBufferIndex = 0; +}; + +} // namespace Integration +} // namespace RHI +} // namespace XCEngine \ No newline at end of file diff --git a/tests/RHI/integration/minimal/CMakeLists.txt b/tests/RHI/integration/minimal/CMakeLists.txt new file mode 100644 index 00000000..6fb260b3 --- /dev/null +++ b/tests/RHI/integration/minimal/CMakeLists.txt @@ -0,0 +1,58 @@ +cmake_minimum_required(VERSION 3.15) + +set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}) + +project(rhi_integration_minimal) + +set(ENGINE_ROOT_DIR ${CMAKE_SOURCE_DIR}/engine) +set(PACKAGE_DIR ${CMAKE_SOURCE_DIR}/tests/opengl/package) + +get_filename_component(PROJECT_ROOT_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../../../.. ABSOLUTE) + +add_executable(rhi_integration_minimal + main.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/../fixtures/RHIIntegrationFixture.cpp + ${PACKAGE_DIR}/src/glad.c +) + +target_include_directories(rhi_integration_minimal PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR} + ${CMAKE_CURRENT_SOURCE_DIR}/../fixtures + ${ENGINE_ROOT_DIR}/include + ${PACKAGE_DIR}/include + ${PROJECT_ROOT_DIR}/engine/src +) + +target_link_libraries(rhi_integration_minimal PRIVATE + d3d12 + dxgi + d3dcompiler + winmm + opengl32 + XCEngine + GTest::gtest +) + +target_compile_definitions(rhi_integration_minimal PRIVATE + UNICODE + _UNICODE + XCENGINE_SUPPORT_OPENGL +) + +add_custom_command(TARGET rhi_integration_minimal POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/tests/RHI/integration/compare_ppm.py + $/ + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/tests/RHI/D3D12/integration/minimal/GT.ppm + $/GT_D3D12.ppm + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${CMAKE_SOURCE_DIR}/tests/RHI/OpenGL/integration/minimal/GT.ppm + $/GT_OpenGL.ppm + COMMAND ${CMAKE_COMMAND} -E copy_if_different + ${ENGINE_ROOT_DIR}/third_party/renderdoc/renderdoc.dll + $/ +) + +include(GoogleTest) +gtest_discover_tests(rhi_integration_minimal) \ No newline at end of file diff --git a/tests/RHI/integration/minimal/main.cpp b/tests/RHI/integration/minimal/main.cpp new file mode 100644 index 00000000..8a0a3ae4 --- /dev/null +++ b/tests/RHI/integration/minimal/main.cpp @@ -0,0 +1,73 @@ +#include +#include +#include + +#include + +#include "../fixtures/RHIIntegrationFixture.h" + +using namespace XCEngine::RHI; +using namespace XCEngine::RHI::Integration; + +namespace { + +class MinimalTest : public RHIIntegrationFixture { +protected: + void RenderFrame() override; +}; + +void MinimalTest::RenderFrame() { + RHICommandList* cmdList = GetCommandList(); + RHICommandQueue* cmdQueue = GetCommandQueue(); + + cmdList->Reset(); + + Viewport viewport = { 0.0f, 0.0f, 1280.0f, 720.0f, 0.0f, 1.0f }; + Rect scissorRect = { 0, 0, 1280, 720 }; + cmdList->SetViewport(viewport); + cmdList->SetScissorRect(scissorRect); + + float clearColor[] = { 1.0f, 0.0f, 0.0f, 1.0f }; + cmdList->Clear(clearColor[0], clearColor[1], clearColor[2], clearColor[3], 1); + + cmdList->Close(); + void* cmdLists[] = { cmdList }; + cmdQueue->ExecuteCommandLists(1, cmdLists); +} + +TEST_P(MinimalTest, RenderClear) { + RHICommandQueue* cmdQueue = GetCommandQueue(); + RHISwapChain* swapChain = GetSwapChain(); + const int targetFrameCount = 30; + + for (int frameCount = 0; frameCount <= targetFrameCount; ++frameCount) { + if (frameCount > 0) { + cmdQueue->WaitForPreviousFrame(); + } + + BeginRender(); + RenderFrame(); + EndRender(); + + if (frameCount >= targetFrameCount) { + cmdQueue->WaitForIdle(); + ASSERT_TRUE(TakeScreenshot("minimal.ppm")); + ASSERT_TRUE(CompareWithGoldenTemplate("minimal.ppm", + (GetBackendType() == RHIType::D3D12) ? "GT_D3D12.ppm" : "GT_OpenGL.ppm", + (GetBackendType() == RHIType::D3D12) ? 0.0f : 5.0f)); + break; + } + + swapChain->Present(0, 0); + } +} + +} // namespace + +INSTANTIATE_TEST_SUITE_P(D3D12, MinimalTest, ::testing::Values(RHIType::D3D12)); +INSTANTIATE_TEST_SUITE_P(OpenGL, MinimalTest, ::testing::Values(RHIType::OpenGL)); + +GTEST_API_ int main(int argc, char** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/RHI/integration/run_integration_test.py b/tests/RHI/integration/run_integration_test.py new file mode 100644 index 00000000..810b42ad --- /dev/null +++ b/tests/RHI/integration/run_integration_test.py @@ -0,0 +1,126 @@ +import sys +import os +import subprocess +import time + + +def run_integration_test(exe_path, output_ppm, gt_ppm, threshold, backend, timeout=120): + """ + Run a RHI integration test and compare output with golden template. + + Args: + exe_path: Path to the test executable + output_ppm: Filename of the output screenshot + gt_ppm: Path to the golden template PPM file + threshold: Pixel difference threshold for comparison + backend: Backend type (D3D12 or OpenGL) + timeout: Maximum time to wait for test completion (seconds) + + Returns: + 0 on success, non-zero on failure + """ + exe_dir = os.path.dirname(os.path.abspath(exe_path)) + output_path = os.path.join(exe_dir, output_ppm) + + print(f"[Integration Test] Starting: {exe_path}") + print(f"[Integration Test] Working directory: {exe_dir}") + print(f"[Integration Test] Expected output: {output_path}") + print(f"[Integration Test] Backend: {backend}") + + if not os.path.exists(exe_path): + print(f"[Integration Test] ERROR: Executable not found: {exe_path}") + return 1 + + if not os.path.exists(gt_ppm): + print(f"[Integration Test] ERROR: Golden template not found: {gt_ppm}") + return 1 + + if os.path.exists(output_path): + print(f"[Integration Test] Removing old output: {output_path}") + os.remove(output_path) + + try: + print(f"[Integration Test] Launching process...") + start_time = time.time() + process = subprocess.Popen( + [exe_path, backend], + cwd=exe_dir, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NEW_CONSOLE if os.name == "nt" else 0, + ) + + returncode = None + while time.time() - start_time < timeout: + returncode = process.poll() + if returncode is not None: + break + time.sleep(0.5) + + if returncode is None: + print(f"[Integration Test] ERROR: Process timed out after {timeout}s") + process.kill() + return 1 + + elapsed = time.time() - start_time + print( + f"[Integration Test] Process finished in {elapsed:.1f}s with exit code: {returncode}" + ) + + if returncode != 0: + print(f"[Integration Test] ERROR: Process returned non-zero exit code") + stdout, stderr = process.communicate(timeout=5) + if stdout: + print( + f"[Integration Test] STDOUT:\n{stdout.decode('utf-8', errors='replace')}" + ) + if stderr: + print( + f"[Integration Test] STDERR:\n{stderr.decode('utf-8', errors='replace')}" + ) + return 1 + + except Exception as e: + print(f"[Integration Test] ERROR: Failed to run process: {e}") + return 1 + + if not os.path.exists(output_path): + print(f"[Integration Test] ERROR: Output file not created: {output_path}") + return 1 + + print(f"[Integration Test] Running image comparison...") + script_dir = os.path.dirname(os.path.abspath(__file__)) + compare_script = os.path.join(script_dir, "compare_ppm.py") + + try: + result = subprocess.run( + [sys.executable, compare_script, output_path, gt_ppm, str(threshold)], + cwd=exe_dir, + capture_output=True, + text=True, + ) + print(result.stdout) + if result.stderr: + print(f"[Integration Test] Comparison STDERR: {result.stderr}") + return result.returncode + + except Exception as e: + print(f"[Integration Test] ERROR: Failed to run comparison: {e}") + return 1 + + +if __name__ == "__main__": + if len(sys.argv) != 6: + print( + "Usage: run_integration_test.py " + ) + sys.exit(1) + + exe_path = sys.argv[1] + output_ppm = sys.argv[2] + gt_ppm = sys.argv[3] + threshold = int(sys.argv[4]) + backend = sys.argv[5] + + exit_code = run_integration_test(exe_path, output_ppm, gt_ppm, threshold, backend) + sys.exit(exit_code)