diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3fd5b0b952d..5e5dde73010 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -59,106 +59,8 @@ jobs:
echo "- Shared: ${{ steps.filter.outputs.shared == 'true' && '✅' || '❌' }}" >> $GITHUB_STEP_SUMMARY
# Note the build jobs are split into two jobs for vc6 and win32 because the replay checks
- # only require the vc6 build and compiling vc6 is much faster than win32
- build-generals-vc6:
- name: Build Generals${{ matrix.preset && '' }}
- needs: detect-changes
- if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.generals == 'true' || needs.detect-changes.outputs.shared == 'true' }}
- permissions:
- contents: read
- strategy:
- matrix:
- include:
- - preset: "vc6"
- tools: true
- extras: true
- - preset: "vc6-profile"
- tools: true
- extras: true
- - preset: "vc6-debug"
- tools: true
- extras: true
- - preset: "vc6-releaselog"
- tools: true
- extras: true
- fail-fast: false
- uses: ./.github/workflows/reusable-build-toolchain.yml
- with:
- game: "Generals"
- preset: ${{ matrix.preset }}
- tools: ${{ matrix.tools }}
- extras: ${{ matrix.extras }}
- secrets: inherit
-
- build-generals-win32:
- name: Build Generals${{ matrix.preset && '' }}
- needs: detect-changes
- if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.generals == 'true' || needs.detect-changes.outputs.shared == 'true' }}
- permissions:
- contents: read
- packages: write
- strategy:
- matrix:
- include:
- - preset: "win32"
- tools: true
- extras: true
- - preset: "win32-profile"
- tools: true
- extras: true
- - preset: "win32-debug"
- tools: true
- extras: true
- - preset: "win32-vcpkg"
- tools: true
- extras: true
- - preset: "win32-vcpkg-profile"
- tools: true
- extras: true
- - preset: "win32-vcpkg-debug"
- tools: true
- extras: true
- fail-fast: false
- uses: ./.github/workflows/reusable-build-toolchain.yml
- with:
- game: "Generals"
- preset: ${{ matrix.preset }}
- tools: ${{ matrix.tools }}
- extras: ${{ matrix.extras }}
- tests: true
- benchmarks: ${{ matrix.preset == 'win32' }}
- secrets: inherit
-
- build-generalsmd-vc6:
- name: Build GeneralsMD${{ matrix.preset && '' }}
- needs: detect-changes
- if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.generalsmd == 'true' || needs.detect-changes.outputs.shared == 'true' }}
- permissions:
- contents: read
- strategy:
- matrix:
- include:
- - preset: "vc6"
- tools: true
- extras: true
- - preset: "vc6-profile"
- tools: true
- extras: true
- - preset: "vc6-debug"
- tools: true
- extras: true
- - preset: "vc6-releaselog"
- tools: true
- extras: true
- fail-fast: false
- uses: ./.github/workflows/reusable-build-toolchain.yml
- with:
- game: "GeneralsMD"
- preset: ${{ matrix.preset }}
- tools: ${{ matrix.tools }}
- extras: ${{ matrix.extras }}
- secrets: inherit
-
+ # only require the vc6 build and compiling vc6 is much faster than win32,
+ # and the rendering checks only require the win32 build.
build-generalsmd-win32:
name: Build GeneralsMD${{ matrix.preset && '' }}
needs: detect-changes
@@ -169,25 +71,11 @@ jobs:
strategy:
matrix:
include:
+ # Only the build that the rendering check needs, which keeps the runs of this fork short.
- preset: "win32"
+ # The artifact step collects the core tools, so they are built as well.
tools: true
- extras: true
- - preset: "win32-profile"
- tools: true
- extras: true
- - preset: "win32-debug"
- tools: true
- extras: true
- - preset: "win32-vcpkg"
- tools: true
- extras: true
- ffmpeg: true
- - preset: "win32-vcpkg-profile"
- tools: true
- extras: true
- - preset: "win32-vcpkg-debug"
- tools: true
- extras: true
+ extras: false
fail-fast: false
uses: ./.github/workflows/reusable-build-toolchain.yml
with:
@@ -195,45 +83,18 @@ jobs:
preset: ${{ matrix.preset }}
tools: ${{ matrix.tools }}
extras: ${{ matrix.extras }}
- ffmpeg: ${{ matrix.ffmpeg || false}}
+ ffmpeg: false
tests: true
- benchmarks: ${{ matrix.preset == 'win32' }}
- secrets: inherit
-
- replaycheck-generals:
- name: Replay Check Generals${{ matrix.preset && '' }}
- needs: [detect-changes, build-generals-vc6]
- if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.generals == 'true' || needs.detect-changes.outputs.shared == 'true' }}
- permissions:
- contents: read
- strategy:
- matrix:
- include:
- - preset: "vc6+t+e"
- - preset: "vc6-releaselog+t+e" # optimized build with logging and crashing enabled should be compatible, so we test that here.
- fail-fast: false
- uses: ./.github/workflows/reusable-check-replays.yml
- with:
- game: "Generals"
- userdata: "GeneralsReplays/Generals/1.08"
- preset: ${{ matrix.preset }}
+ benchmarks: false
secrets: inherit
- replaycheck-generalsmd:
- name: Replay Check GeneralsMD${{ matrix.preset && '' }}
- needs: [detect-changes, build-generalsmd-vc6]
+ renderingcheck-generalsmd:
+ name: Rendering Check GeneralsMD
+ needs: [detect-changes, build-generalsmd-win32]
if: ${{ github.event_name == 'workflow_dispatch' || needs.detect-changes.outputs.generalsmd == 'true' || needs.detect-changes.outputs.shared == 'true' }}
permissions:
contents: read
- strategy:
- matrix:
- include:
- - preset: "vc6+t+e"
- - preset: "vc6-releaselog+t+e" # optimized build with logging and crashing enabled should be compatible, so we test that here.
- fail-fast: false
- uses: ./.github/workflows/reusable-check-replays.yml
+ actions: read
+ uses: ./.github/workflows/reusable-check-rendering.yml
with:
game: "GeneralsMD"
- userdata: "GeneralsReplays/GeneralsZH/1.04"
- preset: ${{ matrix.preset }}
- secrets: inherit
diff --git a/.github/workflows/reusable-build-toolchain.yml b/.github/workflows/reusable-build-toolchain.yml
index 40edcc2d01d..3997a68eb81 100644
--- a/.github/workflows/reusable-build-toolchain.yml
+++ b/.github/workflows/reusable-build-toolchain.yml
@@ -178,9 +178,22 @@ jobs:
- name: Test ${{ inputs.game }} with CTest Using ${{ inputs.preset }}${{ inputs.tools && '+t' || '' }}${{ inputs.extras && '+e' || '' }} Preset
if: ${{ inputs.tests }}
shell: pwsh
+ env:
+ GTEST_FILTER: -RenderingTest.*
run: |
ctest --preset ${{ inputs.preset }} --verbose
+ # For the rendering check in ci.yml.
+ - name: Upload Rendering Test Executable
+ if: ${{ inputs.tests && inputs.preset == 'win32' }}
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ with:
+ name: ${{ inputs.game }}-win32-rendering-tests
+ path: build/${{ inputs.preset }}/Tests/Google/Release/${{ inputs.game == 'Generals' && 'g' || 'z' }}_googletest.exe
+ # Pull requests compare their rendering with main.
+ retention-days: ${{ github.ref == 'refs/heads/main' && 90 || 7 }}
+ if-no-files-found: error
+
- name: Collect ${{ inputs.game }} ${{ inputs.preset }}${{ inputs.tools && '+t' || '' }}${{ inputs.extras && '+e' || '' }} Artifact
shell: pwsh
run: |
diff --git a/.github/workflows/reusable-check-rendering.yml b/.github/workflows/reusable-check-rendering.yml
new file mode 100644
index 00000000000..3b95e6e2564
--- /dev/null
+++ b/.github/workflows/reusable-check-rendering.yml
@@ -0,0 +1,232 @@
+name: Check Rendering
+
+# WARNING: Do not declare permissions in this reusable workflow.
+# Declare the required permissions in each calling job:
+# contents: read - required for rendering checks.
+# actions: read - required to download the rendering tests of the base commit.
+
+on:
+ workflow_call:
+ inputs:
+ game:
+ required: true
+ type: string
+ description: "Game to check (Generals, GeneralsMD)"
+
+jobs:
+ rendering:
+ name: win32
+ runs-on: windows-2022
+ timeout-minutes: 15
+ env:
+ GAME: ${{ inputs.game }}
+ TEST_NAME: ${{ inputs.game == 'Generals' && 'g_googletest' || 'z_googletest' }}
+ GTEST_FILTER: RenderingTest.*
+ GTEST_FAIL_IF_NO_TEST_SELECTED: 1
+ GTEST_OUTPUT: xml:results.xml
+ 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 MSVC Rendering Tests
+ uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
+ with:
+ name: ${{ inputs.game }}-win32-rendering-tests
+ path: build/rendering-tests
+
+ - 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
+ New-Item -ItemType Directory build/runtime | Out-Null
+ tar -C build/runtime -xf build/dxvk.tar.gz --strip-components=2 "$dxvk/x32/d3d8.dll" "$dxvk/x32/d3d9.dll"
+ 7z e build/vulkan.zip -obuild/runtime "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 elevated runner ignores VK_DRIVER_FILES.
+ reg add HKLM\SOFTWARE\WOW6432Node\Khronos\Vulkan\Drivers /v "$PWD\build\lavapipe\lvp_icd.x86.json" /t REG_DWORD /d 0 /f
+
+ - name: Run Rendering Google Tests
+ working-directory: build/rendering-tests
+ timeout-minutes: 5
+ run: |
+ Copy-Item ../runtime/* .
+ & "./$env:TEST_NAME.exe"
+
+ - name: Download Base Rendering Tests
+ id: base
+ if: always()
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PUSH_BEFORE: ${{ github.event.before }}
+ PR_BASE: ${{ github.event.pull_request.base.sha }}
+ run: |
+ # The previous main commit for a push, the main commit that a pull request merges into, or else the merge base.
+ $repo = "repos/$env:GITHUB_REPOSITORY"
+ $base = switch ($env:GITHUB_EVENT_NAME) {
+ 'push' { $env:PUSH_BEFORE }
+ 'pull_request' { $env:PR_BASE }
+ default { gh api "$repo/compare/main...$env:GITHUB_SHA" --jq .merge_base_commit.sha }
+ }
+ if ($LASTEXITCODE) { $base = $null }
+ # Without a package, the nearest ancestor that has one renders the same.
+ $commits = @()
+ if ($base) {
+ $commits = gh api "$repo/commits?sha=$base&per_page=20" --jq '.[].sha'
+ if ($LASTEXITCODE) { $commits = @() }
+ }
+ foreach ($commit in $commits) {
+ $runs = gh api "$repo/actions/runs?head_sha=$commit" --jq ".workflow_runs[] | select(.id != $env:GITHUB_RUN_ID) | .id"
+ if ($LASTEXITCODE) { continue }
+ foreach ($run in $runs) {
+ gh run download $run --repo $env:GITHUB_REPOSITORY --name "${env:GAME}-win32-rendering-tests" --dir build/rendering-tests-base
+ if ($LASTEXITCODE -eq 0) {
+ "Using the rendering tests of run $run for commit $commit (base commit $base)"
+ "sha=$commit" >> $env:GITHUB_OUTPUT
+ exit 0
+ }
+ Remove-Item -Recurse -Force -ErrorAction Ignore build/rendering-tests-base
+ }
+ }
+ "::notice title=Rendering::No rendering tests found for base commit $($base ?? 'unknown'), so the images are not compared"
+ exit 0
+
+ - name: Run Base Rendering Google Tests
+ if: ${{ always() && steps.base.outputs.sha }}
+ working-directory: build/rendering-tests-base
+ timeout-minutes: 5
+ run: |
+ Copy-Item ../runtime/* .
+ & "./$env:TEST_NAME.exe"
+ if ($LASTEXITCODE) { "::warning title=Rendering::The base rendering tests failed, so only the images they wrote are compared" }
+ exit 0
+
+ - name: Compare Rendering with Base
+ id: compare
+ if: always()
+ env:
+ BASE_SHA: ${{ steps.base.outputs.sha }}
+ run: |
+ $since = if ($env:BASE_SHA) { "since base $($env:BASE_SHA.Substring(0, 10))" } else { '(no base found)' }
+ function Get-Image($path) {
+ if (Test-Path $path) { "
" }
+ }
+ # A pixel changes when a color channel differs by more than 16 levels, as in the ignoreLess mode of Resemble.js.
+ Add-Type -AssemblyName System.Drawing
+ function Get-Mismatch($oldPath, $newPath) {
+ $old, $new = foreach ($path in $oldPath, $newPath) {
+ $bitmap = [Drawing.Bitmap]::new((Resolve-Path $path).Path)
+ $data = $bitmap.LockBits([Drawing.Rectangle]::new(0, 0, $bitmap.Width, $bitmap.Height), 'ReadOnly', 'Format32bppArgb')
+ $bytes = [byte[]]::new($data.Stride * $data.Height)
+ [Runtime.InteropServices.Marshal]::Copy($data.Scan0, $bytes, 0, $bytes.Length)
+ $bitmap.Dispose()
+ , $bytes
+ }
+ if ($old.Length -ne $new.Length) { return 100 }
+ $count = 0
+ for ($i = 0; $i -lt $old.Length; $i += 4) {
+ if ([Math]::Abs($old[$i] - $new[$i]) -gt 16 -or [Math]::Abs($old[$i + 1] - $new[$i + 1]) -gt 16 -or [Math]::Abs($old[$i + 2] - $new[$i + 2]) -gt 16) { $count++ }
+ }
+ # Rounded up, so that a single pixel shows.
+ [Math]::Ceiling(10000 * $count / ($old.Length / 4)) / 100
+ }
+ $images = foreach ($name in (Get-ChildItem build/rendering-tests*/*_RenderTest/*.png).Name | Sort-Object -Unique) {
+ $old = Get-Image build/rendering-tests-base/*_RenderTest/$name
+ $new = Get-Image build/rendering-tests/*_RenderTest/$name
+ $mismatch = if ($old -and $new -and $old -ne $new) { Get-Mismatch build/rendering-tests-base/*_RenderTest/$name build/rendering-tests/*_RenderTest/$name } else { 0 }
+ $state = if (!$old) { 'new' } elseif (!$new) { 'removed' } elseif ($mismatch -eq 0) { 'unchanged' } else { 'changed' }
+ [pscustomobject]@{ Name = $name; State = $state; Old = $old; New = $new; Mismatch = $mismatch }
+ }
+ $changes = ($images | Where-Object State -in 'changed', 'removed' | ForEach-Object { "$($_.Name) $($_.State)" }) -join ', '
+ "changes=$changes" >> $env:GITHUB_OUTPUT
+ "message=$(if ($changes) { $changes } else { 'No changes' }) $since" >> $env:GITHUB_OUTPUT
+ $counts = ($images | Group-Object State | ForEach-Object { "$($_.Count) $($_.Name)" }) -join ', '
+ "### $env:GAME Rendering`n`n$counts $since.`n" >> $env:GITHUB_STEP_SUMMARY
+ # One self-contained page with a card per test, changes first.
+ $order = 'changed', 'removed', 'new', 'unchanged'
+ $tests = $images | Group-Object { $_.Name -replace '_\d+\.png$' } | ForEach-Object {
+ [pscustomobject]@{ Name = $_.Name; Images = $_.Group; State = $_.Group.State | Sort-Object { $order.IndexOf($_) } | Select-Object -First 1 }
+ }
+ $cards = foreach ($test in $tests | Sort-Object { $order.IndexOf($_.State) }, Name) {
+ $index = 0
+ $rows = foreach ($image in $test.Images | Sort-Object { ($_.Name -replace '^.*_(\d+)\.png$', '$1') -as [int] }) {
+ $title = if ($index++ -eq 0) { "
$($test.Name)
" }
+ $number = if ($test.Images.Count -gt 1) { "$($image.Name -replace '^.*_(\d+)\.png$', '#$1')" }
+ $diff, $percent = if ($image.State -eq 'changed') { ' class=diff', "$($image.Mismatch)% of the pixels differ" }
+ "$title$number$($image.State)$percent
$($image.Old)
$($image.New)
"
+ }
+ ""
+ }
+ @"
+
+
+ $env:GAME rendering $since
+
+
+
+ $env:GAME rendering
+ $counts $since
+ Base
This commit
Difference
+ $($cards -join "`n")
+
+ "@ | Set-Content "build/$env:GAME-rendering-report.html"
+
+ - name: Upload Rendering Report
+ id: report
+ if: always()
+ uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
+ with:
+ path: build/${{ inputs.game }}-rendering-report.html
+ archive: false
+ retention-days: 7
+ if-no-files-found: warn
+
+ # Changes fail pull requests and are warnings on main.
+ - name: Check Rendering Changes
+ if: always()
+ env:
+ CHANGES: ${{ steps.compare.outputs.changes }}
+ MESSAGE: ${{ steps.compare.outputs.message }}
+ REPORT_URL: ${{ steps.report.outputs.artifact-url }}
+ run: |
+ "[Open the $env:GAME rendering report]($env:REPORT_URL)" >> $env:GITHUB_STEP_SUMMARY
+ $level = if (!$env:CHANGES) { 'notice' } elseif ($env:GITHUB_EVENT_NAME -eq 'pull_request') { 'error' } else { 'warning' }
+ "::$level title=$env:GAME rendering::$env:MESSAGE. Report: $env:REPORT_URL"
+ if ($level -eq 'error') { exit 1 }
diff --git a/Tests/Google/Core/CMakeLists.txt b/Tests/Google/Core/CMakeLists.txt
index 68c37d4b96d..c4be45b6eda 100644
--- a/Tests/Google/Core/CMakeLists.txt
+++ b/Tests/Google/Core/CMakeLists.txt
@@ -15,6 +15,17 @@ set(COREI_GOOGLETEST_SRC
Libraries/WWVegas/WWMath/WWMathKeys.h
Libraries/WWVegas/WWMath/WWMathTest.cpp
Libraries/WWVegas/WWMath/WWMathTestCase.h
+ 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)
@@ -26,6 +37,7 @@ target_link_libraries(corei_googletest INTERFACE
depsi_googletest
GTest::gtest
imm32
+ stb
vfw32
winmm
)
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..b68150a68e2
--- /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 unsigned int color = 0x102030;
+ clear(color);
+ capture();
+ 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..6992cc60739
--- /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, [](int x, int) -> unsigned int
+ {
+ const unsigned int brightness = 255 - abs(2 * 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();
+ 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..82884624766
--- /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, [](int x, int y) -> unsigned int
+ {
+ const float dx = (x - 15.5f) / 16.0f;
+ const float dy = (y - 15.5f) / 16.0f;
+ const unsigned int 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();
+ 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..0dbd8af4ee4
--- /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 unsigned int 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();
+ // 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..7d2163d1eb3
--- /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();
+ // 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..f1899f6fb9b
--- /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 unsigned int background = 0x204060;
+ clear(background);
+
+ // A red and white checkerboard with a transparent top left quarter.
+ const auto checkerboard = [](int x, int y) -> unsigned int
+ {
+ const unsigned int 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();
+ // 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..565f42fd2a1
--- /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 unsigned int 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();
+ 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 (int 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..5373116f34f
--- /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();
+ 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..3ca84150baa
--- /dev/null
+++ b/Tests/Google/Core/Rendering/EXAMPLE_TEMPORARY_TriangleTest.cpp
@@ -0,0 +1,45 @@
+/*
+** 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"
+
+// A triangle with a red, a green and a blue corner blends the corner colors across its area.
+TEST_F(RenderingTest, Triangle)
+{
+ const unsigned int background = 0x102030;
+ clear(background);
+ Render2DClass renderer;
+ renderer.Set_Coordinate_Range(RectClass(0.0f, 0.0f, Width, Height));
+ renderer.Enable_Texturing(false);
+ // Draws the triangle whichever way round its corners go.
+ renderer.Get_Shader()->Set_Cull_Mode(ShaderClass::CULL_MODE_DISABLE);
+ const Vector2 uv(0.0f, 0.0f);
+ renderer.Add_Tri(Vector2(128.0f, 32.0f), Vector2(224.0f, 224.0f), Vector2(32.0f, 224.0f), uv, uv, uv);
+ // Add_Tri gives all corners one color. The color array is a DynamicVectorClass in Zero Hour and a
+ // SimpleDynVecClass in Generals.
+ auto &colors = renderer.Get_Color_Array();
+ colors[0] = 0xFFFF0000;
+ colors[1] = 0xFF00FF00;
+ colors[2] = 0xFF0000FF;
+ renderer.Render();
+ capture();
+ // 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..559c1391ac5
--- /dev/null
+++ b/Tests/Google/Core/Rendering/RenderingTestFixture.cpp
@@ -0,0 +1,159 @@
+/*
+** 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
+#include
+
+#include "WW3D2/Renderer.h"
+#include "WW3D2/camera.h"
+#include "WW3D2/dx8wrapper.h"
+#include "WW3D2/scene.h"
+#include "WW3D2/surfaceclass.h"
+#include "WW3D2/texture.h"
+#include "WW3D2/ww3d.h"
+#include "WWMath/wwmath.h"
+
+#ifdef RTS_ZEROHOUR
+static const char ImageFolder[] = "z_RenderTest";
+#else
+static const char ImageFolder[] = "g_RenderTest";
+#endif
+
+CameraClass *RenderingTest::s_camera = nullptr;
+HWND RenderingTest::s_window = nullptr;
+
+void RenderingTest::SetUpTestSuite()
+{
+ // Borderless, so that the window is as large as the back buffer.
+ s_window = CreateWindowA("STATIC", "RenderingTest", WS_POPUP, 0, 0, Width, Height, nullptr, nullptr, GetModuleHandle(nullptr), nullptr);
+ ASSERT_NE(s_window, nullptr);
+ WWMath::Init();
+ ASSERT_EQ(WW3D::Init(s_window), WW3D_ERROR_OK);
+ WW3D::Set_Screen_UV_Bias(TRUE);
+ // Thumbnails need an asset manager.
+ 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);
+
+ std::filesystem::create_directory(ImageFolder);
+}
+
+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(unsigned int color)
+{
+ const Vector3 rgb((color >> 16 & 0xFF) / 255.0f, (color >> 8 & 0xFF) / 255.0f, (color & 0xFF) / 255.0f);
+ Renderer::Clear(true, false, rgb);
+}
+
+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(int width, int height, const std::function &color)
+{
+ SurfaceClass *surface = NEW_REF(SurfaceClass, (width, height, WW3D_FORMAT_A8R8G8B8));
+ int pitch = 0;
+ unsigned char *bits = static_cast(surface->Lock(&pitch));
+ for (int y = 0; y < height; ++y)
+ {
+ for (int 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;
+}
+
+// DX8 specific until Renderer can read the back buffer.
+void RenderingTest::capture()
+{
+ ASSERT_TRUE(m_rendering);
+ const std::string imageName = (std::filesystem::path(ImageFolder) / ::testing::UnitTest::GetInstance()->current_test_info()->name()).string() + "_" + std::to_string(m_captures++) + ".png";
+ WW3D::End_Render(false);
+ m_rendering = false;
+ SurfaceClass *backBuffer = DX8Wrapper::_Get_DX8_Back_Buffer();
+ ASSERT_NE(backBuffer, nullptr);
+ SurfaceClass *copy = NEW_REF(SurfaceClass, (Width, Height, Renderer::Get_Back_Buffer_Format()));
+ DX8Wrapper::_Copy_DX8_Rects(backBuffer->Peek_D3D_Surface(), nullptr, 0, copy->Peek_D3D_Surface(), nullptr);
+ REF_PTR_RELEASE(backBuffer);
+ int pitch = 0;
+ const unsigned char *bits = static_cast(copy->Lock(&pitch));
+ std::vector rgb;
+ rgb.reserve(Width * Height * 3);
+ for (int y = 0; y < Height; ++y)
+ {
+ const unsigned int *row = reinterpret_cast(bits + y * pitch);
+ for (int x = 0; x < Width; ++x)
+ {
+ const unsigned int color = row[x] & 0x00ffffff;
+ m_pixels[y * Width + x] = color;
+ rgb.push_back(static_cast(color >> 16));
+ rgb.push_back(static_cast(color >> 8));
+ rgb.push_back(static_cast(color));
+ }
+ }
+ copy->Unlock();
+ REF_PTR_RELEASE(copy);
+ EXPECT_NE(stbi_write_png(imageName.c_str(), Width, Height, 3, rgb.data(), Width * 3), 0) << imageName;
+ ASSERT_EQ(WW3D::Begin_Render(true, true, Vector3(0.0f, 0.0f, 0.0f)), WW3D_ERROR_OK);
+ m_rendering = true;
+}
diff --git a/Tests/Google/Core/Rendering/RenderingTestFixture.h b/Tests/Google/Core/Rendering/RenderingTestFixture.h
new file mode 100644
index 00000000000..0a53dc352f5
--- /dev/null
+++ b/Tests/Google/Core/Rendering/RenderingTestFixture.h
@@ -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 .
+*/
+
+#pragma once
+
+#include
+#include
+#include
+#include
+#include
+
+class CameraClass;
+class RenderObjClass;
+class TextureClass;
+
+// Each test renders with WW3D into a black frame and captures it for the rendering check of CI.
+class RenderingTest : public ::testing::Test
+{
+protected:
+ static constexpr int Width = 256;
+ static constexpr int Height = 256;
+
+ static void SetUpTestSuite();
+ static void TearDownTestSuite();
+ virtual void SetUp() override;
+ virtual void TearDown() override;
+
+ static void clear(unsigned int color);
+ static void render(std::initializer_list objects);
+ static TextureClass *createTexture(int width, int height, const std::function &color); // color(x, y) returns 0xAARRGGBB.
+ void capture(); // Saves the frame as _.png and starts a new one.
+ unsigned int pixel(int x, int y) const { return m_pixels[y * Width + x]; } // 0xRRGGBB of the last capture.
+
+ static CameraClass *s_camera; // Looks at the origin from the south, with z up.
+
+private:
+ std::vector m_pixels = std::vector(Width * Height);
+ int m_captures = 0;
+ bool m_rendering = false;
+ static HWND s_window;
+};