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
$reference$image
" + } + "$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)