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.
This commit is contained in:
2026-03-25 19:00:30 +08:00
parent d1b7fda816
commit 238ebb50f4
7 changed files with 582 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
cmake_minimum_required(VERSION 3.15)
find_package(GTest REQUIRED)
enable_testing()
add_subdirectory(minimal)

View File

@@ -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 <file1.ppm> <file2.ppm> <threshold>")
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)

View File

@@ -0,0 +1,188 @@
#include "RHIIntegrationFixture.h"
#include <cstdlib>
#include <iostream>
#include <windows.h>
#include <filesystem>
#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<OpenGLDevice*>(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<uint32_t>(CommandQueueType::Direct);
mCommandQueue = mDevice->CreateCommandQueue(queueDesc);
ASSERT_NE(mCommandQueue, nullptr);
CommandListDesc cmdDesc = {};
cmdDesc.commandListType = static_cast<uint32_t>(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<int>(threshold));
int result = system(cmd.c_str());
return result == 0;
}
} // namespace Integration
} // namespace RHI
} // namespace XCEngine

View File

@@ -0,0 +1,55 @@
#pragma once
#include <gtest/gtest.h>
#include <string>
#include <windows.h>
#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<RHIType> {
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

View File

@@ -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
$<TARGET_FILE_DIR:rhi_integration_minimal>/
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_SOURCE_DIR}/tests/RHI/D3D12/integration/minimal/GT.ppm
$<TARGET_FILE_DIR:rhi_integration_minimal>/GT_D3D12.ppm
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_SOURCE_DIR}/tests/RHI/OpenGL/integration/minimal/GT.ppm
$<TARGET_FILE_DIR:rhi_integration_minimal>/GT_OpenGL.ppm
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${ENGINE_ROOT_DIR}/third_party/renderdoc/renderdoc.dll
$<TARGET_FILE_DIR:rhi_integration_minimal>/
)
include(GoogleTest)
gtest_discover_tests(rhi_integration_minimal)

View File

@@ -0,0 +1,73 @@
#include <windows.h>
#include <stdio.h>
#include <stdlib.h>
#include <gtest/gtest.h>
#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();
}

View File

@@ -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 <exe_path> <output_ppm> <gt_ppm> <threshold> <backend>"
)
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)