GLdc/tools/golden.h
2026-06-22 09:05:44 +01:00

374 lines
13 KiB
C++

/*
* Golden-image test harness for GLdc.
*
* GLdc has a "software" backend that, on the desktop, normally renders the
* submitted Tile-Accelerator (TA) poly-lists through SDL's GPU renderer. That
* path needs a display and is not deterministic across machines/SDL versions,
* so it is unsuitable for committed "golden" reference images.
*
* Instead this harness contains a tiny, fully self-contained, deterministic CPU
* rasteriser that consumes exactly the same poly-lists the real backend does
* (OP_LIST / PT_LIST / TR_LIST, filled by glEnd / glDrawArrays / ...). It
* reproduces the backend's triangle-strip walking (see SceneListFinish in
* GL/platforms/software.c) and the perspective divide it performs, then fills
* triangles with Gouraud-interpolated vertex colour, optionally modulated by a
* decoded texture. The result is compared against a committed PPM reference.
*
* Because the rasteriser lives here (not in the library) it never changes when
* the library changes, so a diff in the rendered output reliably indicates a
* regression in GLdc's transform / colour / clipping / submission pipeline.
*
* Determinism notes:
* - Pure float maths, pixel-centre sampling, top-left-ish fill rule.
* - Painter's-order compositing (matching the software backend, which does
* no depth buffering): later triangles overwrite earlier ones for opaque
* lists; the transparent list alpha-blends.
* - Comparison is tolerant (per-channel + mismatch-fraction thresholds) so
* sub-LSB rounding differences between compilers do not cause flakiness,
* while real geometry/colour regressions are still caught.
*
* Usage from a test:
* golden::Image img(64, 64);
* img.clear(0, 0, 0);
* ... GL draw calls ...
* golden::rasterize_all_lists(img); // or rasterize_list(...)
* assert_true(golden::check(img, "name")); // compares tests/goldens/name.ppm
*
* Set the environment variable GLDC_UPDATE_GOLDENS=1 to (re)generate references.
* On a mismatch the actual and diff images are written next to the golden with
* .actual.ppm / .diff.ppm suffixes for inspection.
*/
#pragma once
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <cmath>
#include <string>
#include <vector>
#include "GL/private.h"
#include "GL/platform.h"
#include "containers/aligned_vector.h"
#ifndef GLDC_GOLDEN_DIR
/* Fallback; the real value is injected by CMake as a compile definition. */
#define GLDC_GOLDEN_DIR "goldens"
#endif
namespace golden {
/* ------------------------------------------------------------------ Image */
struct Image {
int w;
int h;
std::vector<uint8_t> rgb; /* w*h*3, row-major, top row first */
Image(int width, int height) : w(width), h(height), rgb(size_t(width) * height * 3, 0) {}
void clear(uint8_t r, uint8_t g, uint8_t b) {
for(size_t i = 0; i < rgb.size(); i += 3) {
rgb[i + 0] = r;
rgb[i + 1] = g;
rgb[i + 2] = b;
}
}
inline void put(int x, int y, uint8_t r, uint8_t g, uint8_t b) {
if(x < 0 || y < 0 || x >= w || y >= h) return;
size_t i = (size_t(y) * w + x) * 3;
rgb[i + 0] = r;
rgb[i + 1] = g;
rgb[i + 2] = b;
}
inline void get(int x, int y, uint8_t& r, uint8_t& g, uint8_t& b) const {
size_t i = (size_t(y) * w + x) * 3;
r = rgb[i + 0];
g = rgb[i + 1];
b = rgb[i + 2];
}
};
/* -------------------------------------------------------------------- PPM */
inline bool write_ppm(const std::string& path, const Image& img) {
FILE* f = fopen(path.c_str(), "wb");
if(!f) return false;
fprintf(f, "P6\n%d %d\n255\n", img.w, img.h);
fwrite(img.rgb.data(), 1, img.rgb.size(), f);
fclose(f);
return true;
}
inline bool read_ppm(const std::string& path, Image& out) {
FILE* f = fopen(path.c_str(), "rb");
if(!f) return false;
char magic[3] = {0};
int w = 0, h = 0, maxv = 0;
if(fscanf(f, "%2s", magic) != 1 || strcmp(magic, "P6") != 0) { fclose(f); return false; }
if(fscanf(f, "%d %d %d", &w, &h, &maxv) != 3) { fclose(f); return false; }
fgetc(f); /* single whitespace after the header */
out = Image(w, h);
size_t got = fread(out.rgb.data(), 1, out.rgb.size(), f);
fclose(f);
return got == out.rgb.size();
}
/* ------------------------------------------------------- Texture decoding */
/* Decodes one texel of a non-twiddled 16bpp DC texture to RGBA 0-255.
* Only the formats useful for golden tests are supported; everything else
* falls back to opaque white so the modulate path still produces vertex
* colour. Twiddled/compressed/paletted formats should be exercised by the
* byte-exact texture-loading unit tests instead. */
struct Texel { uint8_t r, g, b, a; };
inline Texel decode_texel(const TextureObject* tex, int x, int y) {
Texel out = {255, 255, 255, 255};
if(!tex || !tex->data) return out;
const int w = tex->width;
const uint16_t* px = (const uint16_t*) tex->data;
uint16_t v = px[size_t(y) * w + x];
switch(tex->internalFormat) {
case GL_RGB565_KOS: {
uint8_t r5 = (v >> 11) & 0x1f, g6 = (v >> 5) & 0x3f, b5 = v & 0x1f;
out.r = (r5 << 3) | (r5 >> 2);
out.g = (g6 << 2) | (g6 >> 4);
out.b = (b5 << 3) | (b5 >> 2);
out.a = 255;
} break;
case GL_ARGB4444_KOS: {
uint8_t a4 = (v >> 12) & 0xf, r4 = (v >> 8) & 0xf, g4 = (v >> 4) & 0xf, b4 = v & 0xf;
out.a = (a4 << 4) | a4;
out.r = (r4 << 4) | r4;
out.g = (g4 << 4) | g4;
out.b = (b4 << 4) | b4;
} break;
case GL_ARGB1555_KOS: {
uint8_t a1 = (v >> 15) & 0x1, r5 = (v >> 10) & 0x1f, g5 = (v >> 5) & 0x1f, b5 = v & 0x1f;
out.a = a1 ? 255 : 0;
out.r = (r5 << 3) | (r5 >> 2);
out.g = (g5 << 3) | (g5 >> 2);
out.b = (b5 << 3) | (b5 >> 2);
} break;
default:
break; /* unsupported -> white */
}
return out;
}
/* ------------------------------------------------------------- Rasteriser */
struct RVertex {
float x, y; /* screen space (post perspective divide) */
float a, r, g, b; /* 0-1 */
float u, v; /* texture coords */
};
inline float edge(const RVertex& a, const RVertex& b, float px, float py) {
return (px - a.x) * (b.y - a.y) - (py - a.y) * (b.x - a.x);
}
inline int wrap_coord(int c, int n) {
c %= n;
if(c < 0) c += n;
return c;
}
inline void fill_triangle(Image& img, const RVertex& v0, const RVertex& v1,
const RVertex& v2, const TextureObject* tex, bool blend) {
float area = edge(v0, v1, v2.x, v2.y);
if(fabsf(area) < 1e-6f) return; /* degenerate */
float inv_area = 1.0f / area;
int minx = (int) floorf(fminf(v0.x, fminf(v1.x, v2.x)));
int maxx = (int) ceilf (fmaxf(v0.x, fmaxf(v1.x, v2.x)));
int miny = (int) floorf(fminf(v0.y, fminf(v1.y, v2.y)));
int maxy = (int) ceilf (fmaxf(v0.y, fmaxf(v1.y, v2.y)));
if(minx < 0) minx = 0;
if(miny < 0) miny = 0;
if(maxx > img.w) maxx = img.w;
if(maxy > img.h) maxy = img.h;
for(int y = miny; y < maxy; ++y) {
float py = y + 0.5f;
for(int x = minx; x < maxx; ++x) {
float px = x + 0.5f;
float w0 = edge(v1, v2, px, py) * inv_area;
float w1 = edge(v2, v0, px, py) * inv_area;
float w2 = edge(v0, v1, px, py) * inv_area;
/* Inside test that accepts either winding (no culling, like the
* software backend). */
bool inside = (w0 >= 0 && w1 >= 0 && w2 >= 0) ||
(w0 <= 0 && w1 <= 0 && w2 <= 0);
if(!inside) continue;
float a = w0 * v0.a + w1 * v1.a + w2 * v2.a;
float r = w0 * v0.r + w1 * v1.r + w2 * v2.r;
float g = w0 * v0.g + w1 * v1.g + w2 * v2.g;
float b = w0 * v0.b + w1 * v1.b + w2 * v2.b;
if(tex) {
float u = w0 * v0.u + w1 * v1.u + w2 * v2.u;
float v = w0 * v0.v + w1 * v1.v + w2 * v2.v;
int tx = wrap_coord((int) floorf(u * tex->width), tex->width);
int ty = wrap_coord((int) floorf(v * tex->height), tex->height);
Texel t = decode_texel(tex, tx, ty);
r *= t.r / 255.0f;
g *= t.g / 255.0f;
b *= t.b / 255.0f;
a *= t.a / 255.0f;
}
a = a < 0 ? 0 : (a > 1 ? 1 : a);
r = r < 0 ? 0 : (r > 1 ? 1 : r);
g = g < 0 ? 0 : (g > 1 ? 1 : g);
b = b < 0 ? 0 : (b > 1 ? 1 : b);
uint8_t sr = (uint8_t) lrintf(r * 255.0f);
uint8_t sg = (uint8_t) lrintf(g * 255.0f);
uint8_t sb = (uint8_t) lrintf(b * 255.0f);
if(blend) {
uint8_t dr, dg, db;
img.get(x, y, dr, dg, db);
sr = (uint8_t) lrintf(sr * a + dr * (1.0f - a));
sg = (uint8_t) lrintf(sg * a + dg * (1.0f - a));
sb = (uint8_t) lrintf(sb * a + db * (1.0f - a));
}
img.put(x, y, sr, sg, sb);
}
}
}
inline RVertex to_screen(const Vertex* v) {
/* Reproduce the software backend perspective divide (see
* _glPerspectiveDivideVertex). Vertices in the lists are already viewport
* transformed, so dividing x/y by w gives window coordinates. */
float inv_w = (v->w != 0.0f) ? 1.0f / v->w : 1.0f;
RVertex out;
out.x = v->xyz[0] * inv_w;
out.y = v->xyz[1] * inv_w;
out.a = v->argb[0];
out.r = v->argb[1];
out.g = v->argb[2];
out.b = v->argb[3];
out.u = v->uv[0];
out.v = v->uv[1];
return out;
}
/* Rasterise a single poly list. Triangle-strip extraction mirrors
* SceneListFinish() exactly so the same triangles are produced. */
inline void rasterize_list(Image& img, PolyList* list, const TextureObject* tex, bool blend) {
uint32_t n = aligned_vector_size(&list->vector);
if(n < 4) return;
uint32_t vidx = 0;
for(uint32_t i = 0; i < n; ++i) {
Vertex* v = (Vertex*) aligned_vector_at(&list->vector, i);
if((v->flags & GPU_CMD_POLYHDR) == GPU_CMD_POLYHDR) {
vidx = 0;
continue;
}
if(v->flags == GPU_CMD_VERTEX || v->flags == GPU_CMD_VERTEX_EOL) {
++vidx;
}
if(vidx > 2) {
Vertex* a = (Vertex*) aligned_vector_at(&list->vector, i - 2);
Vertex* b = (Vertex*) aligned_vector_at(&list->vector, i - 1);
RVertex r0 = to_screen(a);
RVertex r1 = to_screen(b);
RVertex r2 = to_screen(v);
fill_triangle(img, r0, r1, r2, tex, blend);
}
if(v->flags == GPU_CMD_VERTEX_EOL) {
vidx = 0;
}
}
}
/* Convenience: rasterise the opaque, punch-through and transparent lists in
* the order the backend submits them. */
inline void rasterize_all_lists(Image& img, const TextureObject* tex = NULL) {
rasterize_list(img, &OP_LIST, tex, false);
rasterize_list(img, &PT_LIST, tex, false);
rasterize_list(img, &TR_LIST, tex, true);
}
/* ------------------------------------------------------------- Comparison */
inline std::string golden_path(const std::string& name, const char* suffix = "") {
return std::string(GLDC_GOLDEN_DIR) + "/" + name + suffix + ".ppm";
}
/* Compare img against the committed golden. Returns true on match (or when a
* golden was just (re)generated). max_channel_diff is the largest per-channel
* absolute difference tolerated per pixel; max_bad_fraction is the fraction of
* pixels allowed to exceed that. */
inline bool check(const Image& img, const std::string& name,
int max_channel_diff = 2, double max_bad_fraction = 0.005) {
std::string path = golden_path(name);
const char* update = getenv("GLDC_UPDATE_GOLDENS");
Image golden(0, 0);
bool have_golden = read_ppm(path, golden);
if((update && update[0] == '1') || !have_golden) {
if(!write_ppm(path, img)) {
fprintf(stderr, "golden: failed to write %s\n", path.c_str());
return false;
}
fprintf(stderr, "golden: generated reference %s\n", path.c_str());
return true;
}
if(golden.w != img.w || golden.h != img.h) {
fprintf(stderr, "golden: size mismatch for %s (%dx%d vs %dx%d)\n",
name.c_str(), golden.w, golden.h, img.w, img.h);
write_ppm(golden_path(name, ".actual"), img);
return false;
}
size_t bad = 0;
int worst = 0;
Image diff(img.w, img.h);
for(size_t i = 0; i < img.rgb.size(); i += 3) {
int dr = abs(int(img.rgb[i + 0]) - int(golden.rgb[i + 0]));
int dg = abs(int(img.rgb[i + 1]) - int(golden.rgb[i + 1]));
int db = abs(int(img.rgb[i + 2]) - int(golden.rgb[i + 2]));
int d = dr > dg ? (dr > db ? dr : db) : (dg > db ? dg : db);
if(d > worst) worst = d;
if(d > max_channel_diff) {
++bad;
diff.rgb[i + 0] = 255; /* highlight differing pixels in red */
}
}
size_t total = img.rgb.size() / 3;
double frac = double(bad) / double(total);
if(frac > max_bad_fraction) {
fprintf(stderr,
"golden: MISMATCH %s — %zu/%zu px differ (%.3f%%), worst channel diff %d\n",
name.c_str(), bad, total, frac * 100.0, worst);
write_ppm(golden_path(name, ".actual"), img);
write_ppm(golden_path(name, ".diff"), diff);
return false;
}
return true;
}
} // namespace golden