Repository navigation
Golden Image Testing
The Golden Image Testing framework captures framebuffer screenshots, compares them pixel-by-pixel against stored reference images, and reports visual regressions. It supports configurable per-pixel and per-image tolerance thresholds, generates diff images highlighting changed regions, and integrates with CI pipelines for automated visual regression detection.
Source: SparkEngine/Source/Utils/GoldenImageTest.h
| Class | Responsibility |
|---|---|
GoldenImageTestRunner |
Singleton that captures screenshots, compares against golden references, and manages the test workflow |
IGoldenImageCapture |
Abstract interface for framebuffer readback (one implementation per RHI backend) |
ImageComparisonResult |
Detailed result of comparing a captured screenshot against a golden image |
GoldenImageConfig |
Configuration for directories, tolerance thresholds, and reporting limits |
PixelDiff |
Information about a single pixel that differs between golden and actual images |
Configuration struct controlling directories and tolerance:
struct GoldenImageConfig
{
std::string goldenImageDir; // Directory containing reference images
std::string outputDir; // Directory for captured / diff images
float tolerancePercent = 0.5f; // Max allowed percent of differing pixels
float perPixelThreshold = 10.0f; // Max channel distance before a pixel counts as different
uint32_t maxDiffsToReport = 100; // Cap on PixelDiff entries stored in results
};Full result of a golden image comparison:
struct ImageComparisonResult
{
std::string sceneName; // Scene/test name
bool matched = true; // True if within tolerance
uint32_t totalPixels = 0; // Total pixel count (width * height)
uint32_t differentPixels = 0; // Number of pixels exceeding threshold
float percentDifferent = 0.f; // Percentage of differing pixels
float maxPixelDistance = 0.f; // Maximum per-pixel distance observed
float averagePixelDistance = 0.f; // Average distance across all pixels
std::string diffImagePath; // Path to the generated diff image
std::vector<PixelDiff> diffs; // First N differing pixels (capped)
};Details about a single mismatched pixel:
struct PixelDiff
{
uint32_t x = 0; // Pixel X coordinate
uint32_t y = 0; // Pixel Y coordinate
uint8_t expectedR = 0; // Golden red channel
uint8_t expectedG = 0; // Golden green channel
uint8_t expectedB = 0; // Golden blue channel
uint8_t actualR = 0; // Actual red channel
uint8_t actualG = 0; // Actual green channel
uint8_t actualB = 0; // Actual blue channel
float distance = 0.f; // Euclidean distance in RGB space
};Abstract interface for capturing the current framebuffer. Implement this per RHI backend:
class IGoldenImageCapture
{
public:
virtual ~IGoldenImageCapture() = default;
// Capture the current framebuffer as RGBA pixels
// Returns RGBA byte vector of size width * height * 4
virtual std::vector<uint8_t> CaptureFramebuffer(uint32_t width, uint32_t height) = 0;
};#include "Utils/GoldenImageTest.h"
auto& runner = Spark::GoldenImageTestRunner::GetInstance();
// Configure directories and tolerances
Spark::GoldenImageConfig config;
config.goldenImageDir = "Tests/GoldenImages";
config.outputDir = "Tests/Output";
config.tolerancePercent = 0.5f; // Allow up to 0.5% of pixels to differ
config.perPixelThreshold = 10.0f; // Per-pixel RGB distance threshold
config.maxDiffsToReport = 100; // Report at most 100 differing pixels
runner.Initialize(config);Each RHI backend provides its own framebuffer readback. Here is an example for D3D11:
class D3D11Capture : public Spark::IGoldenImageCapture
{
public:
D3D11Capture(ID3D11Device* device, ID3D11DeviceContext* context)
: m_device(device), m_context(context) {}
std::vector<uint8_t> CaptureFramebuffer(uint32_t width, uint32_t height) override
{
// Create staging texture, copy from backbuffer, map and read RGBA data
std::vector<uint8_t> pixels(width * height * 4);
// ... D3D11 readback implementation ...
return pixels;
}
private:
ID3D11Device* m_device;
ID3D11DeviceContext* m_context;
};
// Inject the capture backend
runner.SetCapture(std::make_unique<D3D11Capture>(device, context));On the first run (or whenever the expected visuals change), capture golden references:
// Render the scene, then capture
renderEngine.RenderFrame();
runner.CaptureGolden("MainMenu");
renderEngine.LoadScene("Level1");
renderEngine.RenderFrame();
runner.CaptureGolden("Level1_Spawn");Golden images are saved to the goldenImageDir as {sceneName}.png files at a default resolution of 1920x1080.
On subsequent runs, compare the current framebuffer against stored references:
renderEngine.RenderFrame();
auto result = runner.CompareWithGolden("MainMenu");
if (!result.matched)
{
std::println("Visual regression in {}: {:.2f}% pixels differ (max distance: {:.1f})",
result.sceneName, result.percentDifferent, result.maxPixelDistance);
std::println("Diff image saved to: {}", result.diffImagePath);
// Inspect individual pixel differences
for (const auto& diff : result.diffs)
{
std::println(" Pixel ({}, {}): expected RGB({},{},{}), got RGB({},{},{}) distance={:.1f}",
diff.x, diff.y,
diff.expectedR, diff.expectedG, diff.expectedB,
diff.actualR, diff.actualG, diff.actualB,
diff.distance);
}
}Compare every golden image in the golden directory at once:
auto results = runner.RunAllComparisons();
if (Spark::GoldenImageTestRunner::HasRegressions(results))
{
for (const auto& r : results)
{
if (!r.matched)
{
std::println("FAIL: {} -- {:.2f}% different", r.sceneName, r.percentDifferent);
}
}
}
else
{
std::println("All {} golden image tests passed.", results.size());
}When visuals change intentionally, update the golden references:
runner.UpdateGolden("MainMenu"); // Equivalent to CaptureGoldenauto names = runner.GetGoldenImageNames();
for (const auto& name : names)
{
std::println("Golden image: {}", name);
}| Setting | Default | Description |
|---|---|---|
tolerancePercent |
0.5% | Maximum percentage of pixels allowed to differ before the test fails |
perPixelThreshold |
10.0 | Per-pixel Euclidean RGB distance threshold; pixels below this are considered matching |
maxDiffsToReport |
100 | Maximum number of PixelDiff entries stored in results |
Pixel distance is computed as the Euclidean distance in RGB space (alpha is ignored):
distance = sqrt((Rg - Ra)^2 + (Gg - Ga)^2 + (Bg - Ba)^2)
Where g = golden and a = actual. The maximum possible distance is approximately 441.7 (sqrt(3 * 255^2)).
A pixel is counted as "different" only if its distance exceeds perPixelThreshold. The test passes only if the percentage of different pixels is at or below tolerancePercent.
When a comparison fails, a diff image is generated showing:
- Red pixels: Differing regions, with intensity proportional to the distance (brighter red = larger difference)
- Dark green pixels: Matching regions
The diff image is saved to {outputDir}/{sceneName}_diff.png.
std::string status = runner.Console_GetStatus();
// Output: "[GoldenImageTest] goldenDir=Tests/GoldenImages, outputDir=Tests/Output,
// tolerance=0.5%, perPixel=10, capture=set"int main()
{
auto& runner = Spark::GoldenImageTestRunner::GetInstance();
Spark::GoldenImageConfig config;
config.goldenImageDir = "Tests/GoldenImages";
config.outputDir = "Tests/CIOutput";
config.tolerancePercent = 1.0f; // Slightly relaxed for CI
config.perPixelThreshold = 15.0f;
runner.Initialize(config);
runner.SetCapture(std::make_unique<SoftwareCapture>());
// Render test scenes and compare
for (const auto& scene : testScenes)
{
RenderScene(scene);
auto result = runner.CompareWithGolden(scene);
if (!result.matched)
{
std::print(stderr, "VISUAL REGRESSION: {} ({:.2f}% diff)\n",
result.sceneName, result.percentDifferent);
}
}
auto results = runner.RunAllComparisons();
return Spark::GoldenImageTestRunner::HasRegressions(results) ? 1 : 0;
}For GPU-less CI environments, use NullRHIDevice or Mesa llvmpipe for software rendering. The capture interface works with any RHI backend.
Each RHI backend (D3D11, D3D12, Vulkan, OpenGL, Metal) can provide its own IGoldenImageCapture implementation for framebuffer readback. The NullRHIDevice can return blank frames for headless testing.
Combine visual and performance regression testing:
// Run performance benchmarks
auto& bench = Spark::BenchmarkFramework::GetInstance();
auto perfResults = bench.RunAll();
auto perfComparisons = bench.CompareWithBaseline(perfResults, baselines);
// Run visual regression tests
auto& runner = Spark::GoldenImageTestRunner::GetInstance();
auto vizResults = runner.RunAllComparisons();
bool allPassed = !bench.HasRegressions(perfComparisons)
&& !Spark::GoldenImageTestRunner::HasRegressions(vizResults);The CompareImages static method can compare any two RGBA buffers without the full runner:
auto result = Spark::GoldenImageTestRunner::CompareImages(
goldenData, actualData, width, height, perPixelThreshold);
std::println("Diff: {} pixels ({:.2f}%), max distance: {:.1f}",
result.differentPixels, result.percentDifferent,
result.maxPixelDistance);Save and load raw RGBA images (simplified format: [width:4][height:4][RGBA data]):
// Save
Spark::GoldenImageTestRunner::SavePNG("output.png", pixelData, width, height);
// Load
uint32_t w, h;
auto pixels = Spark::GoldenImageTestRunner::LoadPNG("input.png", w, h);| Method | Description |
|---|---|
GetInstance() -> GoldenImageTestRunner& |
Access the singleton |
Initialize(const GoldenImageConfig&) |
Set up directories and tolerances |
Shutdown() |
Release capture interface and reset config |
SetCapture(unique_ptr<IGoldenImageCapture>) |
Set the framebuffer capture backend |
CaptureGolden(string_view sceneName) |
Capture and save a golden reference (1920x1080) |
CompareWithGolden(string_view) -> ImageComparisonResult |
Compare current framebuffer against stored golden |
RunAllComparisons() -> vector<ImageComparisonResult> |
Compare all golden images in the directory |
HasRegressions(vector<ImageComparisonResult>) -> bool |
Check if any results did not match (static) |
UpdateGolden(string_view sceneName) |
Overwrite golden reference with current frame |
GetGoldenImageNames() -> vector<string> |
List all golden image scene names |
CompareImages(golden, actual, w, h, tolerance) -> ImageComparisonResult |
Static pixel-by-pixel comparison |
SavePNG(string_view, data, w, h) -> bool |
Save RGBA data to file (static) |
LoadPNG(string_view, w&, h&) -> vector<uint8_t> |
Load RGBA data from file (static) |
Console_GetStatus() -> string |
Human-readable status for console |
| Method | Description |
|---|---|
CaptureFramebuffer(uint32_t w, uint32_t h) -> vector<uint8_t> |
Capture framebuffer as RGBA pixels |
-
GoldenImageTestRunneris a singleton with no internal synchronization. All methods must be called from the main thread (or a single test thread). -
CompareImages,SavePNG, andLoadPNGare static methods with no shared state and are safe to call from any thread. -
GetInstance()uses a function-local static and is safe for concurrent first-access under C++11 magic-statics guarantees. - The
IGoldenImageCaptureimplementation may interact with the GPU; ensure framebuffer readback happens after the frame is fully rendered.
- Benchmark-Framework -- Performance regression testing
- Graphics-Engine -- Rendering pipeline and framebuffer management
- RHI-Overview -- RHI abstraction layer and backend implementations
- Testing -- Unit test infrastructure and CTest setup
Published from 2b03dc797148. Edit the canonical source in wiki/.
- Documentation
- Docs route
- Wiki index
- Guides
- Tutorials
- Samples
- Examples
- API Reference
- API route
- Reference
- Build Guide
- Dependencies
- FAQ
- Changelog
- Roadmap
- Contributing
- Code of Conduct
- Home
- FAQ
- Getting Started
- Quick-Start Tutorial
- Making Your First Game
- Making Your First Multiplayer Game
- Artist Workflow Guide
- Editor Walkthrough
- Migration Guide
- How SparkEngine Works
- Architecture Overview
- Engine Architecture Flowchart
- Creating a Game Module
- Game Modules (catalog)
- Entity Component System
- Rendering and Graphics
- Physics
- Cloth Simulation
- Audio
- Input System
- Camera System
- Scripting with AngelScript
- Visual Scripting
- AI and Navigation
- Animation
- 2D Systems
- Networking
- Dedicated Server
- Multiplayer Quick Start
- Area Server Architecture
- Scene Management
- Large World Support
- Collaborative Editing
- Coroutine System
- Event System
- Event Response System
- Job System
- UI System
- UI Layout Extensions
- Localization
- Dialogue System
- Destruction System
- Replay System
- Achievement System
- Loading System
- Mod System
- Content Delivery
- Tween System
- Memory Integrity
- Gameplay Systems
- Terrain and Procedural Generation
- Save System
- Persistence System
- Day Night Cycle and Weather
- Cinematic Sequencer
- Runtime Prefabs
- SparkEditor
- Editor Tutorials
- SparkConsole
- SparkDaemon
- Shader Pipeline
- Asset Pipeline
- Asset Validation
- Asset Migration
- Game Packaging
- Online Services
- DataTable System
- Loot and Crafting System
- CSG System
- Font System
- Timer Manager
- Movie Render Pipeline
- HLOD and World Partition
- Remote Debug System
- Selection Manager
- Asset Dependency Graph
- Editor Automation
- File Watcher
- Project Templates
- System Requirements
- VR Support
- Mobile Platform
- Accessibility
- Platform Input
- Platform Certification
- Cross-Compilation: Wine Testing
- RHI Abstraction Layer
- D3D11 Backend
- D3D12 Backend
- Vulkan Backend
- OpenGL Backend
- Metal Backend
- DXR Raytracing
- Hybrid Ray Tracing
- Upscaling (DLSS/FSR)
- Render Graph
- Shader Graph
- GPU Particles
- GPU-Driven Rendering
- Volumetric Fog
- Volumetric Clouds
- Global Illumination
- Virtual Texturing
- Water Rendering
- Clustered Lighting
- Material System
- Post-Processing
- Shadow System
- Particle System
- Decal System
- Sky and Atmosphere
- Foliage System
- Mesh Shaders
- Neural Rendering
- Configuration Reference
- Performance Tips
- Benchmark Framework
- Threading Model
- Fuzz Policy and Parser Security
- Memory Safety
- Memory Management Patterns
- Build System and CMake Modules
- Profiler and Debugging
- Performance Profiling Guide
- Telemetry System
- Crash Reporting
- Golden Image Testing
- Utilities
- Testing
- Fuzz Policy and Parser Security
- Codebase Statistics
- Codebase Health
- Error Handling Patterns
- Hot Reload Overview
- Troubleshooting
- Contributing
- Workflow Patterns
- Build Optimizations
- CI Reproducible Builds
- GitHub API and PR Checks
- Git Rebase Conflicts
- Clang-Format
- Code Quality Violations
- AI Bloat Pattern
- MinGW + Wine Cross-Compilation
- Live Editor Testing
- Engine & Renderer Landscape
- DuetOS Portability Catalog
- Five-Engine Analysis
- Eleven-Engine Analysis
- ThorVG / Unity Graphics Analysis
- Advanced Techniques Catalog
- Third-Party Library Evaluation
- Engine Viability Evaluation
- Engine Feature Recommendations
- Project Recommendations
- Mac Compatibility Analysis
- Codebase Observations
- Codebase Bloat Audit
- Test Suite Audit
- Documentation Coverage Audit
- ThirdParty Dependencies Audit
- Load Test Baseline
- Gameplay Systems Status
- SparkGame Module Status
- Stub and Abandoned Features
- Memory Integrity System
- Memory Safety Evaluation
- Hardware Acceleration Systems
- Jolt Physics Integration
- GPU/CPU Separation Plan
- Daemon Services Architecture
- Reflection & Polymorphism Refactoring Plan
- SparkBuild In-Tree
- Wine No-JobSystem Breakthrough
- Wine Role and Fallback Tiers