diff --git a/.github/workflows/check-rendering.yml b/.github/workflows/check-rendering.yml
new file mode 100644
index 00000000000..b951f6ece5e
--- /dev/null
+++ b/.github/workflows/check-rendering.yml
@@ -0,0 +1,213 @@
+name: Rendering Check
+
+permissions:
+ contents: read
+
+on:
+ pull_request:
+ workflow_dispatch:
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ name: Build ${{ matrix.backend }}
+ runs-on: windows-2022
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - backend: dx8
+ preset: win32
+ # bgfx compiles its shaders for one renderer. Direct3D 12 runs the Direct3D 11 build, because both
+ # read the same shaders.
+ - backend: bgfx-dx11
+ preset: win32-bgfx
+ renderer: dx11
+ - backend: bgfx-vulkan
+ preset: win32-bgfx
+ renderer: vulkan
+ steps:
+ - name: Checkout Code
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+
+ - name: Set Up VC2022 Environment
+ uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1.13.0
+ with:
+ arch: x86
+
+ - name: Build Rendering Tests
+ env:
+ RENDERER: ${{ matrix.renderer }}
+ # As the release builds of bgfx-builds.yml, because the bgfx backend uses some of its variables only to log.
+ BGFX_FLAGS: -DRTS_DEBUG_LOGGING=ON -DRTS_DEBUG_CRASHING=OFF -DRTS_DEBUG_STACKTRACE=OFF -DRTS_DEBUG_PROFILE=OFF
+ run: |
+ $PSNativeCommandUseErrorActionPreference = $true
+ $flags = '-DRTS_BUILD_GENERALS=OFF', '-DRTS_BUILD_CORE_TOOLS=OFF', '-DRTS_BUILD_ZEROHOUR_TOOLS=OFF', '-DRTS_BUILD_OPTION_TESTS=ON'
+ if ($env:RENDERER) { $flags += @("-DGGC_BGFX_RENDERER=$env:RENDERER") + ($env:BGFX_FLAGS -split ' ') }
+ cmake --preset ${{ matrix.preset }} $flags
+ cmake --build build/${{ matrix.preset }} --config Release --target z_googletest
+
+ - name: Upload Rendering Tests
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ with:
+ name: rendering-tests-${{ matrix.backend }}
+ path: build/${{ matrix.preset }}/Tests/Google/Release/z_googletest.exe
+ retention-days: 7
+ if-no-files-found: error
+
+ rendering:
+ name: Run on DX8 and bgfx
+ needs: build
+ # Runs the backends that did build.
+ if: ${{ !cancelled() }}
+ runs-on: windows-2022
+ timeout-minutes: 30
+ env:
+ # Google Test reads its flags from these as well.
+ GTEST_FILTER: RenderingTest.*
+ GTEST_FAIL_IF_NO_TEST_SELECTED: 1
+ # DX8 runs on DXVK and Vulkan on Mesa lavapipe, whose pinned versions decide the rendering. Direct3D 11 and 12
+ # run on WARP, the software renderer of Windows.
+ DXVK_VERSION: 2.6.2
+ DXVK_SHA256: 17761876556afd55736cb895d184f5a1c55d43350f1b1e3b129f8d28706d7992
+ MESA_VERSION: 26.2.4
+ MESA_SHA256: 351fc8c8b695878ffb3eaa044b3ead08672a48b1a045e3c3e3975811df0f6695
+ VULKAN_RUNTIME_VERSION: 1.4.363.0
+ VULKAN_RUNTIME_SHA256: a25a927aa8b9f0371048f1861cf88ac3b9bc9b1fb332c42d897c8ab32695769a
+ steps:
+ - name: Download Rendering Tests
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
+ with:
+ pattern: rendering-tests-*
+ path: build
+
+ - name: Install DXVK and Lavapipe
+ run: |
+ $PSNativeCommandUseErrorActionPreference = $true
+ function Get-Checked($url, $out, $sha) {
+ curl.exe --fail --location --retry 3 --output $out $url
+ if ((Get-FileHash $out SHA256).Hash -ne $sha) { throw "SHA256 mismatch for $url" }
+ }
+ $dxvk = "dxvk-$env:DXVK_VERSION"
+ Get-Checked "https://github.com/doitsujin/dxvk/releases/download/v$env:DXVK_VERSION/$dxvk.tar.gz" build/dxvk.tar.gz $env:DXVK_SHA256
+ Get-Checked "https://github.com/pal1000/mesa-dist-win/releases/download/$env:MESA_VERSION/mesa3d-$env:MESA_VERSION-release-msvc.7z" build/mesa.7z $env:MESA_SHA256
+ Get-Checked "https://sdk.lunarg.com/sdk/download/$env:VULKAN_RUNTIME_VERSION/windows/vulkan-runtime-components.zip" build/vulkan.zip $env:VULKAN_RUNTIME_SHA256
+ # The 32-bit DLLs that go beside the test exes.
+ New-Item -ItemType Directory build/dxvk | Out-Null
+ tar -C build/dxvk -xf build/dxvk.tar.gz --strip-components=2 "$dxvk/x32/d3d8.dll" "$dxvk/x32/d3d9.dll"
+ 7z e build/vulkan.zip -obuild/vulkan "VulkanRT-X64-$env:VULKAN_RUNTIME_VERSION-Components/x86/vulkan-1.dll"
+ 7z e build/mesa.7z -obuild/lavapipe x86/vulkan_lvp.dll x86/lvp_icd.x86.json
+ # The runner is elevated, so the Vulkan loader ignores VK_DRIVER_FILES. Register 32-bit lavapipe instead.
+ reg add HKLM\SOFTWARE\WOW6432Node\Khronos\Vulkan\Drivers /v "$PWD\build\lavapipe\lvp_icd.x86.json" /t REG_DWORD /d 0 /f
+ if (Test-Path build/rendering-tests-dx8) { Copy-Item build/dxvk/*, build/vulkan/* build/rendering-tests-dx8 }
+ if (Test-Path build/rendering-tests-bgfx-vulkan) { Copy-Item build/vulkan/* build/rendering-tests-bgfx-vulkan }
+ if (Test-Path build/rendering-tests-bgfx-dx11) { Copy-Item -Recurse build/rendering-tests-bgfx-dx11 build/rendering-tests-bgfx-dx12 }
+
+ # Each backend runs on, so that the report shows all of them. The check at the end fails for the ones that failed.
+ - name: Run DX8 Rendering Tests
+ id: dx8
+ continue-on-error: true
+ timeout-minutes: 5
+ working-directory: build/rendering-tests-dx8
+ run: ./z_googletest.exe
+
+ - name: Run bgfx Direct3D 11 Rendering Tests
+ id: bgfx-dx11
+ continue-on-error: true
+ timeout-minutes: 5
+ working-directory: build/rendering-tests-bgfx-dx11
+ env:
+ GGC_BGFX_RENDERER: dx11
+ run: ./z_googletest.exe
+
+ - name: Run bgfx Direct3D 12 Rendering Tests
+ id: bgfx-dx12
+ continue-on-error: true
+ timeout-minutes: 5
+ working-directory: build/rendering-tests-bgfx-dx12
+ env:
+ GGC_BGFX_RENDERER: dx12
+ run: ./z_googletest.exe
+
+ - name: Run bgfx Vulkan Rendering Tests
+ id: bgfx-vulkan
+ continue-on-error: true
+ timeout-minutes: 5
+ working-directory: build/rendering-tests-bgfx-vulkan
+ env:
+ GGC_BGFX_RENDERER: vulkan
+ run: ./z_googletest.exe
+
+ - name: Compare Rendering of the Backends
+ id: compare
+ env:
+ STEPS: ${{ toJSON(steps) }}
+ run: |
+ $backends = [ordered]@{ 'dx8' = 'DX8'; 'bgfx-dx11' = 'bgfx Direct3D 11'; 'bgfx-dx12' = 'bgfx Direct3D 12'; 'bgfx-vulkan' = 'bgfx Vulkan' }
+ function Get-Image($path) {
+ if (Test-Path $path) { "
" }
+ }
+ # DX8 is the reference. The same PNG encoder wrote every image, so equal pixels give equal files.
+ $differ = @{}
+ $rows = foreach ($name in (Get-ChildItem build/rendering-tests-*/*.png).Name | Sort-Object -Unique) {
+ $reference = Get-Image build/rendering-tests-dx8/$name
+ $cells = foreach ($backend in $backends.Keys) {
+ $image = Get-Image build/rendering-tests-$backend/$name
+ $note = if (!$image) { 'missing' } elseif ($backend -eq 'dx8') { 'reference' } elseif (!$reference) { '' } elseif ($image -eq $reference) { 'same as DX8' } else {
+ $differ[$backend] += 1
+ # The difference blends this image over DX8 and brightens the result, so only differing pixels show.
+ "differs from DX8
"
+ }
+ "$image $note | "
+ }
+ "| $name | $($cells -join '')
"
+ }
+ $steps = $env:STEPS | ConvertFrom-Json
+ $failed = ($backends.Keys | Where-Object { $steps.$_.outcome -eq 'failure' } | ForEach-Object { $backends[$_] }) -join ', '
+ $differences = ($backends.Keys | Where-Object { $differ[$_] } | ForEach-Object { "$($differ[$_]) on $($backends[$_])" }) -join ', '
+ $message = $(if ($failed) { "Tests failed on $failed. " }) + $(if ($differences) { "Images that differ from DX8: $differences" } else { 'No images differ from DX8' })
+ "failed=$failed" >> $env:GITHUB_OUTPUT
+ "message=$message" >> $env:GITHUB_OUTPUT
+ "### Rendering`n`n$message.`n" >> $env:GITHUB_STEP_SUMMARY
+ # One page with the images inline, so the browser opens the artifact without unzipping.
+ @"
+
+
+ Rendering of DX8 and bgfx
+
+ Rendering of DX8 and bgfx
+ $message.
+
+ | $(($backends.Values | ForEach-Object { "$_ | " }) -join '')
+ $($rows -join "`n")
+
+ "@ | Set-Content build/rendering-report.html
+
+ # A single file is uploaded unzipped, so its link opens it in the browser.
+ - name: Upload Rendering Report
+ id: report
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ with:
+ path: build/rendering-report.html
+ archive: false
+ retention-days: 7
+
+ # Comes after the upload, so that its message links to the report. Differences from DX8 are shown, not failed.
+ - name: Check Rendering Tests
+ env:
+ FAILED: ${{ steps.compare.outputs.failed }}
+ MESSAGE: ${{ steps.compare.outputs.message }}
+ REPORT_URL: ${{ steps.report.outputs.artifact-url }}
+ run: |
+ "[Open the rendering report]($env:REPORT_URL)" >> $env:GITHUB_STEP_SUMMARY
+ $level = if ($env:FAILED) { 'error' } else { 'notice' }
+ "::$level title=Rendering::$env:MESSAGE. Report: $env:REPORT_URL"
+ if ($env:FAILED) { exit 1 }
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6c555e96ea4..858ad793b30 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -124,5 +124,11 @@ if(RTS_BUILD_GENERALS)
add_subdirectory(Generals)
endif()
+if(RTS_BUILD_OPTION_TESTS)
+ enable_testing()
+ include(cmake/googletest.cmake)
+ add_subdirectory(Tests)
+endif()
+
feature_summary(WHAT ENABLED_FEATURES DESCRIPTION "Enabled features:")
feature_summary(WHAT DISABLED_FEATURES DESCRIPTION "Disabled features:")
diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt
new file mode 100644
index 00000000000..81cb70204e8
--- /dev/null
+++ b/Tests/CMakeLists.txt
@@ -0,0 +1 @@
+add_subdirectory(Google)
diff --git a/Tests/Google/CMakeLists.txt b/Tests/Google/CMakeLists.txt
new file mode 100644
index 00000000000..6b194423bab
--- /dev/null
+++ b/Tests/Google/CMakeLists.txt
@@ -0,0 +1,12 @@
+add_subdirectory(Core)
+
+if(RTS_BUILD_ZEROHOUR)
+ add_executable(z_googletest)
+ target_link_libraries(z_googletest PRIVATE
+ corei_googletest
+ z_gameenginedevice
+ z_wwaudio
+ zi_always
+ )
+ add_test(NAME z_googletest COMMAND z_googletest)
+endif()
diff --git a/Tests/Google/Core/CMakeLists.txt b/Tests/Google/Core/CMakeLists.txt
new file mode 100644
index 00000000000..fdf53972fff
--- /dev/null
+++ b/Tests/Google/Core/CMakeLists.txt
@@ -0,0 +1,33 @@
+# Core GameEngine code needs a game to compile, so z_googletest compiles these sources.
+set(COREI_GOOGLETEST_SRC
+ GoogleTestMain.cpp
+ Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_LaserTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_ParticlesTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_Render2DBlendingTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_Render2DShapesTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_Render2DTextureTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_TextTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_TracerTest.cpp
+ Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp
+ Rendering/RenderingTestFixture.cpp
+ Rendering/RenderingTestFixture.h
+)
+
+add_library(corei_googletest INTERFACE)
+
+target_sources(corei_googletest INTERFACE ${COREI_GOOGLETEST_SRC})
+
+target_link_libraries(corei_googletest INTERFACE
+ comctl32
+ GTest::gtest
+ imm32
+ stb
+ vfw32
+ winmm
+)
+
+# The rendering tests ask bgfx which renderer it runs.
+if(GGC_RENDER_BACKEND STREQUAL "bgfx")
+ target_link_libraries(corei_googletest INTERFACE bgfx)
+endif()
diff --git a/Tests/Google/Core/GoogleTestMain.cpp b/Tests/Google/Core/GoogleTestMain.cpp
new file mode 100644
index 00000000000..b0be5d43015
--- /dev/null
+++ b/Tests/Google/Core/GoogleTestMain.cpp
@@ -0,0 +1,92 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include
+#include
+
+#include
+
+#include "Common/CommandLine.h"
+#include "Common/CriticalSection.h"
+#include "Common/GameMemory.h"
+#include "GameClient/ClientInstance.h"
+
+// The game engine libraries expect the executable to define these.
+HINSTANCE ApplicationHInstance = nullptr;
+HWND ApplicationHWnd = nullptr;
+const char *gAppPrefix = "GoogleTest_";
+const Char *g_strFile = "data\\Generals.str";
+const Char *g_csfFile = "data\\%s\\Generals.csf";
+
+// The engine locks its memory manager, its strings and its debug log only if the executable provides the critical
+// sections for it, as the game does in WinMain. Tests that run threads need these locks.
+class EngineCriticalSections
+{
+public:
+ EngineCriticalSections()
+ {
+ TheAsciiStringCriticalSection = &m_asciiString;
+ TheUnicodeStringCriticalSection = &m_unicodeString;
+ TheDmaCriticalSection = &m_dma;
+ TheMemoryPoolCriticalSection = &m_memoryPool;
+ }
+
+ ~EngineCriticalSections()
+ {
+ TheAsciiStringCriticalSection = nullptr;
+ TheUnicodeStringCriticalSection = nullptr;
+ TheDmaCriticalSection = nullptr;
+ TheMemoryPoolCriticalSection = nullptr;
+ }
+
+private:
+ CriticalSection m_asciiString;
+ CriticalSection m_unicodeString;
+ CriticalSection m_dma;
+ CriticalSection m_memoryPool;
+};
+
+static int runTests(int argc, char **argv)
+{
+ // Is gone again when this function returns, before the static objects of the program free their memory.
+ EngineCriticalSections criticalSections;
+
+ // Google Test removes its flags from the argument array it gets, but the engine reads the process
+ // arguments through __argv and __argc, so Google Test gets a copy that includes the terminating null.
+ std::vector testArgs(argv, argv + argc + 1);
+ ::testing::InitGoogleTest(&argc, testArgs.data());
+
+ // Same startup as GameDebugInit does in logging builds, so that the tests run alike in every build.
+ initMemoryManager();
+ CommandLine::parseCommandLineForStartup();
+ rts::ClientInstance::initialize();
+
+ // The memory manager is not shut down, because Google Test frees its objects through it after main returns.
+ return RUN_ALL_TESTS();
+}
+
+int main(int argc, char **argv)
+{
+ return runTests(argc, argv);
+}
+
+// The engine refers to WinMain when it dumps exception info, so the executable must define it.
+Int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, Int nCmdShow)
+{
+ return runTests(__argc, __argv);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp
new file mode 100644
index 00000000000..1f67e37de62
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ClearTest.cpp
@@ -0,0 +1,29 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+
+// Clearing the render target fills it with the clear color from corner to corner.
+TEST_F(RenderingTest, Clear)
+{
+ const DWORD color = 0x102030;
+ clear(color);
+ capture("clear.png");
+ EXPECT_EQ(pixel(0, 0), color);
+ EXPECT_EQ(pixel(Width - 1, Height - 1), color);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_LaserTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_LaserTest.cpp
new file mode 100644
index 00000000000..b09a03dc7d3
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_LaserTest.cpp
@@ -0,0 +1,58 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include "WW3D2/segline.h"
+#include "WW3D2/texture.h"
+
+// Lasers are segmented lines with a glowing texture across their width, which W3DLaserDraw adds to the scene with
+// additive blending, so crossing beams add up.
+TEST_F(RenderingTest, Laser)
+{
+ // Brightest along the middle of the beam, dark at its edges.
+ TextureClass *glow = createTexture(32, 32, [](UINT x, UINT) -> DWORD
+ {
+ const DWORD brightness = 255 - abs(2 * static_cast(x) - 31) * 255 / 31;
+ return 0xFF000000 | brightness << 16 | brightness << 8 | brightness;
+ });
+
+ // A red beam from west to east and a blue one that crosses it on the way up.
+ Vector3 eastWest[] = {Vector3(-6.0f, 0.0f, 0.0f), Vector3(6.0f, 0.0f, 0.0f)};
+ Vector3 diagonal[] = {Vector3(-5.0f, -3.0f, 0.0f), Vector3(5.0f, 3.0f, 2.0f)};
+ SegmentedLineClass *red = NEW_REF(SegmentedLineClass, ());
+ red->Set_Points(2, eastWest);
+ red->Set_Color(Vector3(1.0f, 0.2f, 0.1f));
+ SegmentedLineClass *blue = NEW_REF(SegmentedLineClass, ());
+ blue->Set_Points(2, diagonal);
+ blue->Set_Color(Vector3(0.2f, 0.4f, 1.0f));
+ for (SegmentedLineClass *laser : {red, blue})
+ {
+ laser->Set_Texture(glow);
+ laser->Set_Shader(ShaderClass::_PresetAdditiveShader);
+ laser->Set_Width(0.8f);
+ }
+
+ render({red, blue});
+ REF_PTR_RELEASE(red);
+ REF_PTR_RELEASE(blue);
+ REF_PTR_RELEASE(glow);
+
+ capture("laser.png");
+ EXPECT_EQ(pixel(8, 8), 0x000000);
+ EXPECT_NE(pixel(Width / 2, Height / 2), 0x000000);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ParticlesTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ParticlesTest.cpp
new file mode 100644
index 00000000000..aed0a621f44
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_ParticlesTest.cpp
@@ -0,0 +1,76 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include "WW3D2/camera.h"
+#include "WW3D2/pointgr.h"
+#include "WW3D2/rinfo.h"
+#include "WW3D2/texture.h"
+#include "WW3D2/ww3d.h"
+#include "WWLib/sharebuf.h"
+
+// Particles are camera facing quads with a soft texture, which W3DParticleSystemManager draws in batches with
+// PointGroupClass and additive blending.
+TEST_F(RenderingTest, Particles)
+{
+ // A round puff that fades out towards its edge.
+ TextureClass *puff = createTexture(32, 32, [](UINT x, UINT y) -> DWORD
+ {
+ const float dx = (x - 15.5f) / 16.0f;
+ const float dy = (y - 15.5f) / 16.0f;
+ const DWORD brightness = static_cast(255.0f * max(0.0f, 1.0f - sqrtf(dx * dx + dy * dy)));
+ return 0xFF000000 | brightness << 16 | brightness << 8 | brightness;
+ });
+
+ // A ring of particles on the ground that turn from red to yellow and grow, around one in the middle.
+ constexpr int Count = 13;
+ ShareBufferClass *locations = NEW_REF(ShareBufferClass, (Count, "ParticlesTest"));
+ ShareBufferClass *colors = NEW_REF(ShareBufferClass, (Count, "ParticlesTest"));
+ ShareBufferClass *sizes = NEW_REF(ShareBufferClass, (Count, "ParticlesTest"));
+ for (int i = 0; i < Count; ++i)
+ {
+ const float angle = i * WWMATH_PI * 2.0f / (Count - 1);
+ const float radius = i == 0 ? 0.0f : 4.0f;
+ locations->Get_Array()[i] = Vector3(radius * cosf(angle), radius * sinf(angle), 0.0f);
+ colors->Get_Array()[i] = Vector4(1.0f, static_cast(i) / Count, 0.1f, 1.0f);
+ sizes->Get_Array()[i] = 1.0f + i * 0.1f;
+ }
+
+ // Set up as W3DParticleSystemManager sets up its batches.
+ PointGroupClass particles;
+ particles.Set_Texture(puff);
+ particles.Set_Shader(ShaderClass::_PresetAdditiveSpriteShader);
+ particles.Set_Flag(PointGroupClass::TRANSFORM, true);
+ particles.Set_Point_Mode(PointGroupClass::QUADS);
+ particles.Set_Billboard(true);
+ particles.Set_Arrays(locations, colors, nullptr, sizes, nullptr, nullptr, Count);
+
+ // The game draws particles from its scene, so this renders them as WW3D::Render renders a scene.
+ RenderInfoClass rinfo(*s_camera);
+ s_camera->Apply();
+ particles.Render(rinfo);
+ WW3D::Flush(rinfo);
+ REF_PTR_RELEASE(locations);
+ REF_PTR_RELEASE(colors);
+ REF_PTR_RELEASE(sizes);
+ REF_PTR_RELEASE(puff);
+
+ capture("particles.png");
+ EXPECT_EQ(pixel(8, 8), 0x000000);
+ EXPECT_NE(pixel(Width / 2, Height / 2), 0x000000);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DBlendingTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DBlendingTest.cpp
new file mode 100644
index 00000000000..e6c9175ac4b
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DBlendingTest.cpp
@@ -0,0 +1,56 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include "WW3D2/render2d.h"
+
+// The UI and the effects blend over what is below them: alpha fades windows in, additive blending makes glows and
+// multiplicative blending darkens for shadows.
+TEST_F(RenderingTest, Render2DBlending)
+{
+ const DWORD background = 0x808080;
+ clear(background);
+ Render2DClass renderer;
+ renderer.Set_Coordinate_Range(RectClass(0.0f, 0.0f, Width, Height));
+ renderer.Enable_Texturing(false);
+
+ // Half transparent red.
+ renderer.Enable_Alpha(true);
+ renderer.Add_Quad(RectClass(16.0f, 16.0f, 112.0f, 240.0f), 0x80FF0000);
+ renderer.Render();
+
+ // Green added on top, partly over the red.
+ renderer.Reset();
+ renderer.Enable_Additive(true);
+ renderer.Add_Quad(RectClass(80.0f, 16.0f, 176.0f, 240.0f), 0xFF006000);
+ renderer.Render();
+
+ // Blue multiplied with what is below, partly over the green.
+ renderer.Reset();
+ *renderer.Get_Shader() = ShaderClass::_PresetMultiplicative2DShader;
+ renderer.Enable_Texturing(false);
+ renderer.Add_Quad(RectClass(144.0f, 16.0f, 240.0f, 240.0f), 0xFF4080FF);
+ renderer.Render();
+
+ capture("render2d_blending.png");
+ // The exact blended colors depend on how the GPU rounds, so they are left to the image comparison of CI.
+ EXPECT_EQ(pixel(8, 8), background);
+ EXPECT_NE(pixel(48, 128), background);
+ EXPECT_NE(pixel(128, 128), background);
+ EXPECT_NE(pixel(208, 128), background);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DShapesTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DShapesTest.cpp
new file mode 100644
index 00000000000..4f0790c2c48
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DShapesTest.cpp
@@ -0,0 +1,43 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include "WW3D2/render2d.h"
+
+// The UI draws filled and outlined rectangles, lines and gradients with Render2DClass, as W3DDisplay::drawFillRect
+// and its siblings do.
+TEST_F(RenderingTest, Render2DShapes)
+{
+ Render2DClass renderer;
+ renderer.Set_Coordinate_Range(RectClass(0.0f, 0.0f, Width, Height));
+ renderer.Enable_Texturing(false);
+
+ // A blue rectangle with a white border, an orange outline, a green line and a red to blue gradient.
+ renderer.Add_Rect(RectClass(16.0f, 16.0f, 112.0f, 112.0f), 4.0f, 0xFFFFFFFF, 0xFF2040C0);
+ renderer.Add_Outline(RectClass(144.0f, 16.0f, 240.0f, 112.0f), 2.0f, 0xFFFFC000);
+ renderer.Add_Line(Vector2(16.0f, 136.0f), Vector2(240.0f, 184.0f), 3.0f, 0xFF40FF40);
+ renderer.Add_Quad_HGradient(RectClass(16.0f, 208.0f, 240.0f, 240.0f), 0xFFFF0000, 0xFF0000FF);
+ renderer.Render();
+
+ capture("render2d_shapes.png");
+ // The fill and the border of the rectangle, and the inside and the edge of the outline.
+ EXPECT_EQ(pixel(64, 64), 0x2040C0);
+ EXPECT_EQ(pixel(17, 64), 0xFFFFFF);
+ EXPECT_EQ(pixel(192, 64), 0x000000);
+ EXPECT_EQ(pixel(145, 64), 0xFFC000);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DTextureTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DTextureTest.cpp
new file mode 100644
index 00000000000..ed34827d4bf
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_Render2DTextureTest.cpp
@@ -0,0 +1,60 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include "WW3D2/render2d.h"
+#include "WW3D2/texture.h"
+
+// The UI draws its images as textured quads with Render2DClass. Scaled up, point filtering keeps the texels sharp while
+// linear filtering blends them, and transparent texels let the background show through.
+TEST_F(RenderingTest, Render2DTexture)
+{
+ const DWORD background = 0x204060;
+ clear(background);
+
+ // A red and white checkerboard with a transparent top left quarter.
+ const auto checkerboard = [](UINT x, UINT y) -> DWORD
+ {
+ const DWORD alpha = (x < 4 && y < 4) ? 0x00000000 : 0xFF000000;
+ return alpha | (((x ^ y) & 1) ? 0xFFFFFF : 0xC02020);
+ };
+ TextureClass *sharp = createTexture(8, 8, checkerboard);
+ sharp->Get_Filter().Set_Min_Filter(TextureFilterClass::FILTER_TYPE_NONE);
+ sharp->Get_Filter().Set_Mag_Filter(TextureFilterClass::FILTER_TYPE_NONE);
+ TextureClass *smooth = createTexture(8, 8, checkerboard);
+ smooth->Get_Filter().Set_Min_Filter(TextureFilterClass::FILTER_TYPE_FAST);
+ smooth->Get_Filter().Set_Mag_Filter(TextureFilterClass::FILTER_TYPE_FAST);
+
+ // The sharp image on the left and the smooth one on the right, both scaled from 8 to 96 pixels.
+ Render2DClass renderer(sharp);
+ renderer.Set_Coordinate_Range(RectClass(0.0f, 0.0f, Width, Height));
+ renderer.Add_Quad(RectClass(16.0f, 64.0f, 112.0f, 160.0f));
+ renderer.Render();
+ renderer.Reset();
+ renderer.Set_Texture(smooth);
+ renderer.Add_Quad(RectClass(144.0f, 64.0f, 240.0f, 160.0f));
+ renderer.Render();
+ REF_PTR_RELEASE(sharp);
+ REF_PTR_RELEASE(smooth);
+
+ capture("render2d_texture.png");
+ // Each texel covers 12 by 12 pixels. These are the centers of texel (5, 5), which is red, and texel (1, 1),
+ // which is transparent.
+ EXPECT_EQ(pixel(16 + 5 * 12 + 6, 64 + 5 * 12 + 6), 0xC02020);
+ EXPECT_EQ(pixel(16 + 1 * 12 + 6, 64 + 1 * 12 + 6), background);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TextTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TextTest.cpp
new file mode 100644
index 00000000000..f2de7a934f5
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TextTest.cpp
@@ -0,0 +1,54 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include "WW3D2/render2dsentence.h"
+
+// The UI draws its text with Render2DSentenceClass from glyphs that GDI renders, as W3DDisplayString does,
+// including the drop shadow below the text.
+TEST_F(RenderingTest, Text)
+{
+ const DWORD background = 0x204060;
+ clear(background);
+
+ FontCharsClass *font = NEW_REF(FontCharsClass, ());
+ ASSERT_TRUE(font->Initialize_GDI_Font("Arial", 16, true));
+ Render2DSentenceClass sentence;
+ sentence.Set_Font(font);
+ REF_PTR_RELEASE(font);
+
+ // The black shadow first, then the text over it.
+ const WCHAR *text = L"Command & Conquer";
+ sentence.Build_Sentence(text, nullptr, nullptr);
+ sentence.Set_Location(Vector2(18.0f, 18.0f));
+ sentence.Draw_Sentence(0xFF000000);
+ sentence.Set_Location(Vector2(16.0f, 16.0f));
+ sentence.Draw_Sentence(0xFFFFC000);
+ sentence.Render();
+ const Vector2 extents = sentence.Get_Text_Extents(text);
+
+ capture("text.png");
+ EXPECT_GT(extents.X, 100.0f);
+ // GDI and its fonts differ between systems, so only some text color is expected in the middle row of the text.
+ bool found = false;
+ for (UINT x = 16; x < 16 + extents.X; ++x)
+ {
+ found |= pixel(x, 16 + static_cast(extents.Y) / 2) == 0xFFC000;
+ }
+ EXPECT_TRUE(found);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TracerTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TracerTest.cpp
new file mode 100644
index 00000000000..27bcb9ba40b
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TracerTest.cpp
@@ -0,0 +1,36 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include "WW3D2/line3d.h"
+
+// Tracers are 3D lines, which W3DTracerDraw fades out by lowering their opacity.
+TEST_F(RenderingTest, Tracer)
+{
+ // A solid yellow tracer, crossed by a red one at half opacity.
+ Line3DClass *solid = NEW_REF(Line3DClass, (Vector3(-6.0f, -2.0f, 0.0f), Vector3(6.0f, 2.0f, 0.0f), 0.2f, 1.0f, 0.9f, 0.2f));
+ Line3DClass *faded = NEW_REF(Line3DClass, (Vector3(-6.0f, 2.0f, 0.0f), Vector3(6.0f, -2.0f, 0.0f), 0.2f, 1.0f, 0.3f, 0.2f, 0.5f));
+
+ render({solid, faded});
+ REF_PTR_RELEASE(solid);
+ REF_PTR_RELEASE(faded);
+
+ capture("tracer.png");
+ EXPECT_EQ(pixel(8, 8), 0x000000);
+ EXPECT_NE(pixel(Width / 2, Height / 2), 0x000000);
+}
diff --git a/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp
new file mode 100644
index 00000000000..9e39fda6166
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp
@@ -0,0 +1,35 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+
+// A triangle with a red, a green and a blue corner blends the corner colors across its area.
+TEST_F(RenderingTest, Triangle)
+{
+ const DWORD background = 0x102030;
+ clear(background);
+ drawTriangles({
+ {128.0f, 32.0f, 0xff0000},
+ {224.0f, 224.0f, 0x00ff00},
+ {32.0f, 224.0f, 0x0000ff},
+ });
+ capture("triangle.png");
+ // GPUs round the blended colors differently, so the exact colors are left to the image comparison of CI.
+ EXPECT_EQ(pixel(8, 8), background);
+ EXPECT_NE(pixel(128, 160), background);
+}
diff --git a/Tests/Google/Core/Rendering/RenderingTestFixture.cpp b/Tests/Google/Core/Rendering/RenderingTestFixture.cpp
new file mode 100644
index 00000000000..2551f3985bb
--- /dev/null
+++ b/Tests/Google/Core/Rendering/RenderingTestFixture.cpp
@@ -0,0 +1,213 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#include "RenderingTestFixture.h"
+#include
+#include
+// Reads the BMP that bgfx saves. The game links no stb_image, so it is compiled here.
+#define STB_IMAGE_STATIC
+#define STBI_ONLY_BMP
+#define STB_IMAGE_IMPLEMENTATION
+#include
+#ifdef GGC_RENDER_BACKEND_BGFX
+#include
+#endif
+
+#include "WW3D2/IRenderBackend.h"
+#include "WW3D2/camera.h"
+#include "WW3D2/render2d.h"
+#include "WW3D2/scene.h"
+#include "WW3D2/surfaceclass.h"
+#include "WW3D2/texture.h"
+#include "WW3D2/ww3d.h"
+#include "WWLib/GgcRuntimeFlags.h"
+#include "WWMath/wwmath.h"
+
+CameraClass *RenderingTest::s_camera = nullptr;
+HWND RenderingTest::s_window = nullptr;
+
+// Runs once before the first test of the suite. When it fails, Google Test skips the tests of the suite.
+// WW3D starts up as W3DDisplay::init does, with a windowed device whose back buffer the tests render into.
+void RenderingTest::SetUpTestSuite()
+{
+ // Without a border, the window is as large as the back buffer that bgfx takes from it.
+ s_window = CreateWindowA("STATIC", "RenderingTest", WS_POPUP, 0, 0, Width, Height, nullptr, nullptr, GetModuleHandle(nullptr), nullptr);
+ ASSERT_NE(s_window, nullptr);
+ // bgfx then renders each frame when it ends instead of on a thread of its own, so capture() gets the frame.
+ GgcFlags::SetOverride(GgcFlag_BgfxNoRenderThread, "1");
+ WWMath::Init();
+ ASSERT_EQ(WW3D::Init(s_window), WW3D_ERROR_OK);
+ WW3D::Set_Screen_UV_Bias(TRUE);
+ // Thumbnails need an asset manager, which the tests do without.
+ WW3D::Set_Thumbnail_Enabled(false);
+ ASSERT_EQ(WW3D::Set_Render_Device(0, Width, Height, 32, TRUE), WW3D_ERROR_OK);
+
+ s_camera = NEW_REF(CameraClass, ());
+ Matrix3D transform;
+ transform.Look_At(Vector3(0.0f, -12.0f, 6.0f), Vector3(0.0f, 0.0f, 0.0f), 0.0f);
+ s_camera->Set_Transform(transform);
+ s_camera->Set_View_Plane(DEG_TO_RADF(50.0f), DEG_TO_RADF(50.0f));
+ s_camera->Set_Clip_Planes(1.0f, 100.0f);
+}
+
+void RenderingTest::TearDownTestSuite()
+{
+ REF_PTR_RELEASE(s_camera);
+ WW3D::Shutdown();
+ WWMath::Shutdown();
+ if (s_window != nullptr)
+ {
+ DestroyWindow(s_window);
+ s_window = nullptr;
+ }
+}
+
+void RenderingTest::SetUp()
+{
+ ASSERT_EQ(WW3D::Begin_Render(true, true, Vector3(0.0f, 0.0f, 0.0f)), WW3D_ERROR_OK);
+ m_rendering = true;
+}
+
+void RenderingTest::TearDown()
+{
+ if (m_rendering)
+ {
+ WW3D::End_Render(false);
+ }
+}
+
+void RenderingTest::clear(DWORD color)
+{
+ const Vector3 rgb((color >> 16 & 0xFF) / 255.0f, (color >> 8 & 0xFF) / 255.0f, (color & 0xFF) / 255.0f);
+ WW3D::Get_Render_Backend()->Clear(true, false, rgb);
+}
+
+// Render2DClass draws in screen space through the backend, and its colors can differ per vertex.
+void RenderingTest::drawTriangles(std::initializer_list vertices)
+{
+ Render2DClass renderer;
+ renderer.Set_Coordinate_Range(RectClass(0.0f, 0.0f, Width, Height));
+ renderer.Enable_Texturing(false);
+ // Draws the triangles whichever way round their vertices go.
+ renderer.Get_Shader()->Set_Cull_Mode(ShaderClass::CULL_MODE_DISABLE);
+ const Vector2 uv(0.0f, 0.0f);
+ for (const Vertex *vertex = vertices.begin(); vertex + 2 < vertices.end(); vertex += 3)
+ {
+ renderer.Add_Tri(Vector2(vertex[0].x, vertex[0].y), Vector2(vertex[1].x, vertex[1].y), Vector2(vertex[2].x, vertex[2].y), uv, uv, uv);
+ }
+ DynamicVectorClass &colors = renderer.Get_Color_Array();
+ int index = 0;
+ for (const Vertex &vertex : vertices)
+ {
+ colors[index++] = 0xFF000000 | vertex.color;
+ }
+ renderer.Render();
+}
+
+void RenderingTest::render(std::initializer_list objects)
+{
+ SimpleSceneClass *scene = NEW_REF(SimpleSceneClass, ());
+ scene->Set_Ambient_Light(Vector3(1.0f, 1.0f, 1.0f));
+ for (RenderObjClass *object : objects)
+ {
+ scene->Add_Render_Object(object);
+ }
+ EXPECT_EQ(WW3D::Render(scene, s_camera), WW3D_ERROR_OK);
+ REF_PTR_RELEASE(scene);
+}
+
+TextureClass *RenderingTest::createTexture(UINT width, UINT height, const std::function &color)
+{
+ SurfaceClass *surface = NEW_REF(SurfaceClass, (width, height, WW3D_FORMAT_A8R8G8B8));
+ int pitch = 0;
+ BYTE *bits = static_cast(surface->Lock(&pitch));
+ for (UINT y = 0; y < height; ++y)
+ {
+ for (UINT x = 0; x < width; ++x)
+ {
+ reinterpret_cast(bits + y * pitch)[x] = color(x, y);
+ }
+ }
+ surface->Unlock();
+ TextureClass *texture = NEW_REF(TextureClass, (surface, MIP_LEVELS_1));
+ REF_PTR_RELEASE(surface);
+ return texture;
+}
+
+void RenderingTest::capture(const char *imageName)
+{
+ ASSERT_TRUE(m_rendering);
+ IRenderBackend *backend = WW3D::Get_Render_Backend();
+ std::vector rgb;
+ rgb.reserve(Width * Height * 3);
+ if (backend->Supports_Native_Screen_Shot())
+ {
+ // bgfx reads the back buffer back while it renders the frame, which ends the frame, and saves it as a BMP.
+ // On Direct3D 12 it reads the back buffer of the frame before, so it saves the image during the next frame.
+ const std::string bmp = std::string(imageName) + ".bmp";
+ bool nextFrame = false;
+#ifdef GGC_RENDER_BACKEND_BGFX
+ nextFrame = bgfx::getRendererType() == bgfx::RendererType::Direct3D12;
+#endif
+ if (nextFrame)
+ {
+ WW3D::End_Render(false);
+ m_rendering = false;
+ ASSERT_EQ(WW3D::Begin_Render(false, false, Vector3(0.0f, 0.0f, 0.0f)), WW3D_ERROR_OK);
+ m_rendering = true;
+ }
+ ASSERT_TRUE(backend->Request_Native_Screen_Shot(bmp.c_str()));
+ WW3D::End_Render(false);
+ m_rendering = false;
+ int width = 0;
+ int height = 0;
+ int channels = 0;
+ unsigned char *bits = stbi_load(bmp.c_str(), &width, &height, &channels, 3);
+ ASSERT_NE(bits, nullptr) << bmp;
+ rgb.assign(bits, bits + width * height * 3);
+ stbi_image_free(bits);
+ DeleteFileA(bmp.c_str());
+ ASSERT_EQ(width, static_cast(Width));
+ ASSERT_EQ(height, static_cast(Height));
+ }
+ else
+ {
+ // The DX8 back buffer keeps the frame until it is presented.
+ WW3D::End_Render(false);
+ m_rendering = false;
+ RenderBackendImage image;
+ ASSERT_TRUE(backend->Capture_Back_Buffer_Image(0, image));
+ ASSERT_EQ(image.Width, Width);
+ ASSERT_EQ(image.Height, Height);
+ for (UINT y = 0; y < Height; ++y)
+ {
+ const DWORD *row = reinterpret_cast(image.Bytes.data() + y * image.Pitch);
+ for (UINT x = 0; x < Width; ++x)
+ {
+ rgb.push_back(static_cast(row[x] >> 16));
+ rgb.push_back(static_cast(row[x] >> 8));
+ rgb.push_back(static_cast(row[x]));
+ }
+ }
+ }
+ for (UINT i = 0; i < Width * Height; ++i)
+ {
+ m_pixels[i] = rgb[i * 3] << 16 | rgb[i * 3 + 1] << 8 | rgb[i * 3 + 2];
+ }
+ EXPECT_NE(stbi_write_png(imageName, Width, Height, 3, rgb.data(), Width * 3), 0) << imageName;
+}
diff --git a/Tests/Google/Core/Rendering/RenderingTestFixture.h b/Tests/Google/Core/Rendering/RenderingTestFixture.h
new file mode 100644
index 00000000000..1b776df52fc
--- /dev/null
+++ b/Tests/Google/Core/Rendering/RenderingTestFixture.h
@@ -0,0 +1,74 @@
+/*
+** Command & Conquer Generals Zero Hour(tm)
+** Copyright 2026 TheSuperHackers
+**
+** This program is free software: you can redistribute it and/or modify
+** it under the terms of the GNU General Public License as published by
+** the Free Software Foundation, either version 3 of the License, or
+** (at your option) any later version.
+**
+** This program is distributed in the hope that it will be useful,
+** but WITHOUT ANY WARRANTY; without even the implied warranty of
+** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+** GNU General Public License for more details.
+**
+** You should have received a copy of the GNU General Public License
+** along with this program. If not, see .
+*/
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+class CameraClass;
+class RenderObjClass;
+class TextureClass;
+
+// Each test renders one frame with WW3D on the render backend of the build: DX8, or bgfx with the renderer that the
+// GGC_BGFX_RENDERER environment variable picks. The rendering check of CI runs them for each backend on Windows and
+// shows the saved images side by side.
+// Write each test as TEST_F(RenderingTest, Name) in a file of its own. The tests share the device, which WW3D creates
+// once for the suite, and each test starts its frame with a black back buffer.
+class RenderingTest : public ::testing::Test
+{
+protected:
+ static constexpr UINT Width = 256;
+ static constexpr UINT Height = 256;
+
+ // A position on the back buffer in pixels, and its color as 0xRRGGBB.
+ struct Vertex
+ {
+ float x, y;
+ DWORD color;
+ };
+
+ static void SetUpTestSuite();
+ static void TearDownTestSuite();
+ virtual void SetUp() override;
+ virtual void TearDown() override;
+
+ static void clear(DWORD color);
+ // Draws a triangle list and blends the vertex colors across each triangle.
+ static void drawTriangles(std::initializer_list vertices);
+ // Renders the objects as a scene seen through s_camera.
+ static void render(std::initializer_list objects);
+ // Creates a texture whose texels get the 0xAARRGGBB colors that color(x, y) returns.
+ static TextureClass *createTexture(UINT width, UINT height, const std::function &color);
+ // Ends the frame without presenting it, saves the back buffer as a PNG for the CI report and keeps its pixels
+ // for pixel().
+ void capture(const char *imageName);
+ // The color of a captured pixel as 0xRRGGBB.
+ DWORD pixel(UINT x, UINT y) const { return m_pixels[y * Width + x]; }
+
+ // Looks down at the origin from the south, with x pointing east, y north and z up.
+ static CameraClass *s_camera;
+
+private:
+ std::vector m_pixels = std::vector(Width * Height);
+ bool m_rendering = false;
+ static HWND s_window;
+};
diff --git a/cmake/config-build.cmake b/cmake/config-build.cmake
index ebb32dc4d4c..167fb775ab4 100644
--- a/cmake/config-build.cmake
+++ b/cmake/config-build.cmake
@@ -8,6 +8,7 @@ option(RTS_BUILD_OPTION_PROFILE_TRACY "Build code with Tracy profiling enabled."
option(RTS_BUILD_OPTION_DEBUG "Build code with the \"Debug\" configuration." OFF)
option(RTS_BUILD_OPTION_ASAN "Build code with Address Sanitizer." OFF)
option(RTS_BUILD_OPTION_VC6_FULL_DEBUG "Build VC6 with full debug info." OFF)
+option(RTS_BUILD_OPTION_TESTS "Build the unit tests." OFF)
option(SAGE_USE_SDL3 "Use SDL3 for GeneralsMD windowing and input (cross-platform alternative to the Win32 path; macOS and Windows)." OFF)
option(SAGE_USE_OPENAL "Use OpenAL for GeneralsMD audio (cross-platform alternative to the Windows audio path; macOS and Windows)." OFF)
option(GGC_DIAGNOSTIC_TOOLS "Compile diagnostic- and probe-tier GGC_* runtime flags into the game (see GgcRuntimeFlags.h)." ON)
@@ -25,6 +26,7 @@ add_feature_info(ProfileBuild RTS_BUILD_OPTION_PROFILE "Building as a \"Profile\
add_feature_info(DebugBuild RTS_BUILD_OPTION_DEBUG "Building as a \"Debug\" build")
add_feature_info(AddressSanitizer RTS_BUILD_OPTION_ASAN "Building with address sanitizer")
add_feature_info(Vc6FullDebug RTS_BUILD_OPTION_VC6_FULL_DEBUG "Building VC6 with full debug info")
+add_feature_info(Tests RTS_BUILD_OPTION_TESTS "Building the unit tests")
add_feature_info(FFmpegSupport RTS_BUILD_OPTION_FFMPEG "Building with FFmpeg support")
add_feature_info(SDL3Windowing SAGE_USE_SDL3 "Using SDL3 for GeneralsMD windowing and input")
add_feature_info(OpenALAudio SAGE_USE_OPENAL "Using OpenAL for GeneralsMD audio")
diff --git a/cmake/googletest.cmake b/cmake/googletest.cmake
new file mode 100644
index 00000000000..b51172bc42b
--- /dev/null
+++ b/cmake/googletest.cmake
@@ -0,0 +1,13 @@
+# Fetch Google Test for the unit test executables.
+# It is always built from source, so that it uses the same compiler, architecture and runtime library as the game.
+
+set(BUILD_GMOCK OFF)
+
+FetchContent_Declare(
+ googletest
+ EXCLUDE_FROM_ALL # Exclude Google Test's development files from the INSTALL target.
+ GIT_REPOSITORY https://github.com/TheSuperHackers/google-test
+ GIT_TAG 063de7e9578f82b369302001269680b4b1553359 # 1.18.0
+)
+
+FetchContent_MakeAvailable(googletest)