mirror of
https://github.com/gnif/LookingGlass.git
synced 2026-08-02 05:12:02 +00:00
Add an optional EGL framebuffer capture path for the test transport. Capture the fully composed frame before presentation and retain its surface format and HDR state for validation. Add GoogleTest integration tests which run the production client under a headless Weston compositor and compare every output pixel against an independent reference implementation. The matrix covers BGRA, RGBA, BGR32, RGB24, PQ RGBA10 and scRGB RGBA16F with full, moving, overlapping, maximum-count, invalid, null and zero-area damage rectangles. Validate HDR-to-SDR conversion and native HDR output metadata when supported by the compositor. Keep tests opt-in through ENABLE_RENDER_TESTS and document how to run the software and native HDR tiers.
557 lines
16 KiB
C++
557 lines
16 KiB
C++
/**
|
|
* Looking Glass
|
|
* Copyright © 2017-2026 The Looking Glass Authors
|
|
* https://looking-glass.io
|
|
*
|
|
* 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 2 of the License, or (at your option)
|
|
* any later version.
|
|
*/
|
|
|
|
#include <gtest/gtest.h>
|
|
|
|
extern "C"
|
|
{
|
|
#include "common/types.h"
|
|
#include "interface/test_capture.h"
|
|
}
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <cstring>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <tuple>
|
|
#include <vector>
|
|
|
|
#include <fcntl.h>
|
|
#include <signal.h>
|
|
#include <sys/types.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
|
|
namespace
|
|
{
|
|
|
|
constexpr unsigned kWidth = 127;
|
|
constexpr unsigned kHeight = 95;
|
|
constexpr unsigned kFrameSerial = 4;
|
|
|
|
struct FormatCase
|
|
{
|
|
const char * name;
|
|
FrameType type;
|
|
bool hdr;
|
|
bool pq;
|
|
};
|
|
|
|
struct DamageCase
|
|
{
|
|
const char * name;
|
|
};
|
|
|
|
struct Capture
|
|
{
|
|
LG_TestCaptureHeader header {};
|
|
std::vector<uint8_t> data;
|
|
std::filesystem::path directory;
|
|
std::filesystem::path path;
|
|
std::filesystem::path log;
|
|
};
|
|
|
|
struct RGB
|
|
{
|
|
float r;
|
|
float g;
|
|
float b;
|
|
};
|
|
|
|
std::string readText(const std::filesystem::path & path)
|
|
{
|
|
std::ifstream input(path);
|
|
return std::string(
|
|
std::istreambuf_iterator<char>(input),
|
|
std::istreambuf_iterator<char>());
|
|
}
|
|
|
|
std::filesystem::path makeTempDirectory()
|
|
{
|
|
std::array<char, 64> value {};
|
|
std::snprintf(value.data(), value.size(), "/tmp/lg-render-case.XXXXXX");
|
|
char * result = mkdtemp(value.data());
|
|
if (!result)
|
|
return {};
|
|
return result;
|
|
}
|
|
|
|
int runClient(const FormatCase & format, const char * damage,
|
|
const std::filesystem::path & capture,
|
|
const std::filesystem::path & log)
|
|
{
|
|
const std::string size = std::to_string(kWidth) + "x" +
|
|
std::to_string(kHeight);
|
|
const std::string width = "test:width=" + std::to_string(kWidth);
|
|
const std::string height = "test:height=" + std::to_string(kHeight);
|
|
const std::string formatArg = std::string("test:format=") + format.name;
|
|
const std::string damageArg = std::string("test:damage=") + damage;
|
|
const std::string count = "test:frameCount=" +
|
|
std::to_string(kFrameSerial);
|
|
const std::string captureFile = "test:captureFile=" + capture.string();
|
|
const std::string captureFrame = "test:captureFrame=" +
|
|
std::to_string(kFrameSerial);
|
|
|
|
std::vector<std::string> args = {
|
|
LG_CLIENT_PATH,
|
|
"app:transport=test",
|
|
"app:renderer=EGL",
|
|
width,
|
|
height,
|
|
formatArg,
|
|
damageArg,
|
|
"test:frameRate=60",
|
|
count,
|
|
"test:holdLastFrame=yes",
|
|
"test:realtime=no",
|
|
captureFile,
|
|
captureFrame,
|
|
"test:captureDelay=12",
|
|
"win:size=" + size,
|
|
"win:autoResize=no",
|
|
"win:allowResize=no",
|
|
"win:quickSplash=yes",
|
|
"win:alerts=no",
|
|
"win:noScreensaver=no",
|
|
"spice:enable=no",
|
|
"egl:multisample=no",
|
|
"egl:scale=1",
|
|
};
|
|
|
|
std::vector<char *> argv;
|
|
argv.reserve(args.size() + 1);
|
|
for (std::string & arg : args)
|
|
argv.push_back(arg.data());
|
|
argv.push_back(nullptr);
|
|
|
|
const pid_t pid = fork();
|
|
if (pid < 0)
|
|
return -1;
|
|
if (pid == 0)
|
|
{
|
|
setenv("XDG_CONFIG_HOME", capture.parent_path().c_str(), 1);
|
|
setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1);
|
|
const int logFd = open(log.c_str(), O_WRONLY | O_CREAT | O_TRUNC, 0600);
|
|
if (logFd >= 0)
|
|
{
|
|
dup2(logFd, STDOUT_FILENO);
|
|
dup2(logFd, STDERR_FILENO);
|
|
close(logFd);
|
|
}
|
|
execv(LG_CLIENT_PATH, argv.data());
|
|
_exit(127);
|
|
}
|
|
|
|
int status = 0;
|
|
const auto deadline =
|
|
std::chrono::steady_clock::now() + std::chrono::seconds(20);
|
|
while (std::chrono::steady_clock::now() < deadline)
|
|
{
|
|
const pid_t result = waitpid(pid, &status, WNOHANG);
|
|
if (result == pid)
|
|
return WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
|
|
if (result < 0)
|
|
return -1;
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
|
}
|
|
|
|
kill(pid, SIGTERM);
|
|
if (waitpid(pid, &status, 0) < 0)
|
|
return -1;
|
|
return 124;
|
|
}
|
|
|
|
Capture produceCapture(const FormatCase & format, const char * damage)
|
|
{
|
|
Capture result;
|
|
result.directory = makeTempDirectory();
|
|
if (result.directory.empty())
|
|
return result;
|
|
result.path = result.directory /
|
|
(std::string(format.name) + "-" + damage + ".lgcapture");
|
|
result.log = result.directory /
|
|
(std::string(format.name) + "-" + damage + ".log");
|
|
|
|
const int status = runClient(format, damage, result.path, result.log);
|
|
EXPECT_EQ(status, 0) << readText(result.log);
|
|
if (status != 0)
|
|
return result;
|
|
|
|
std::ifstream input(result.path, std::ios::binary);
|
|
EXPECT_TRUE(input.good()) << "capture missing; client log:\n" <<
|
|
readText(result.log);
|
|
if (!input)
|
|
return result;
|
|
|
|
input.read(reinterpret_cast<char *>(&result.header), sizeof(result.header));
|
|
EXPECT_EQ(input.gcount(), static_cast<std::streamsize>(sizeof(result.header)));
|
|
EXPECT_EQ(result.header.magic, LG_TEST_CAPTURE_MAGIC);
|
|
EXPECT_EQ(result.header.version, LG_TEST_CAPTURE_VERSION);
|
|
EXPECT_EQ(result.header.headerSize, sizeof(result.header));
|
|
EXPECT_EQ(result.header.frameSerial, kFrameSerial);
|
|
EXPECT_EQ(result.header.sourceType, static_cast<uint32_t>(format.type));
|
|
EXPECT_EQ(result.header.width, kWidth);
|
|
EXPECT_EQ(result.header.height, kHeight);
|
|
EXPECT_EQ(result.header.flags & LG_TEST_CAPTURE_HDR,
|
|
format.hdr ? static_cast<uint32_t>(LG_TEST_CAPTURE_HDR) : 0u);
|
|
EXPECT_EQ(result.header.flags & LG_TEST_CAPTURE_HDR_PQ,
|
|
format.pq ? static_cast<uint32_t>(LG_TEST_CAPTURE_HDR_PQ) : 0u);
|
|
if (result.header.flags & LG_TEST_CAPTURE_NATIVE_HDR)
|
|
{
|
|
const std::string clientLog = readText(result.log);
|
|
if (format.pq)
|
|
{
|
|
EXPECT_NE(clientLog.find(
|
|
"HDR image description requested (PQ, BT.2020, "
|
|
"referenceWhite:203 cd/m² maxLum:1000 cd/m²"),
|
|
std::string::npos);
|
|
EXPECT_NE(clientLog.find("maxCLL:1000 maxFALL:400"),
|
|
std::string::npos);
|
|
}
|
|
else
|
|
EXPECT_NE(clientLog.find(
|
|
"HDR image description requested (scRGB, Windows-scRGB)"),
|
|
std::string::npos);
|
|
}
|
|
|
|
result.data.resize(result.header.dataSize);
|
|
input.read(reinterpret_cast<char *>(result.data.data()), result.data.size());
|
|
EXPECT_EQ(input.gcount(), static_cast<std::streamsize>(result.data.size()));
|
|
return result;
|
|
}
|
|
|
|
RGB generatedColor(unsigned x, unsigned y, unsigned serial)
|
|
{
|
|
uint8_t r = static_cast<uint64_t>(x) * 255 / (kWidth - 1);
|
|
uint8_t g = static_cast<uint64_t>(y) * 255 / (kHeight - 1);
|
|
uint8_t b = ((x / 32) ^ (y / 32)) & 1 ? 0x30 : 0x90;
|
|
|
|
const unsigned boxSize = std::min({kWidth, kHeight, 64u});
|
|
const unsigned boxX = (serial * 7) % (kWidth - boxSize);
|
|
const unsigned boxY = (serial * 5) % (kHeight - boxSize);
|
|
if (x >= boxX && y >= boxY &&
|
|
x < boxX + boxSize && y < boxY + boxSize)
|
|
{
|
|
const uint32_t color = serial * UINT32_C(2654435761);
|
|
r = color >> 16;
|
|
g = color >> 8;
|
|
b = color;
|
|
}
|
|
|
|
return {
|
|
static_cast<float>(r) / 255.0f,
|
|
static_cast<float>(g) / 255.0f,
|
|
static_cast<float>(b) / 255.0f,
|
|
};
|
|
}
|
|
|
|
float linearToPQ(float linear)
|
|
{
|
|
constexpr float m1 = 2610.0f / 16384.0f;
|
|
constexpr float m2 = 2523.0f / 32.0f;
|
|
constexpr float c1 = 3424.0f / 4096.0f;
|
|
constexpr float c2 = 2413.0f / 128.0f;
|
|
constexpr float c3 = 2392.0f / 128.0f;
|
|
const float p = std::pow(std::max(linear, 0.0f), m1);
|
|
return std::pow((c1 + c2 * p) / (1.0f + c3 * p), m2);
|
|
}
|
|
|
|
float pqToLinear(float pq)
|
|
{
|
|
constexpr float m1inv = 16384.0f / 2610.0f;
|
|
constexpr float m2inv = 32.0f / 2523.0f;
|
|
constexpr float c1 = 3424.0f / 4096.0f;
|
|
constexpr float c2 = 2413.0f / 128.0f;
|
|
constexpr float c3 = 2392.0f / 128.0f;
|
|
const float p = std::pow(std::max(pq, 0.0f), m2inv);
|
|
const float d = std::max(p - c1, 0.0f) / (c2 - c3 * p);
|
|
return std::pow(d, m1inv);
|
|
}
|
|
|
|
float quantizePQ(float scRGB)
|
|
{
|
|
constexpr unsigned lutSize = 4096;
|
|
const unsigned index = scRGB <= 0.0f ? 0 :
|
|
scRGB >= 4.0f ? lutSize :
|
|
static_cast<unsigned>(scRGB * (lutSize / 4.0f) + 0.5f);
|
|
const float lutScRGB = static_cast<float>(index) * 4.0f / lutSize;
|
|
const float pq = linearToPQ(lutScRGB / 125.0f);
|
|
const unsigned code = static_cast<unsigned>(pq * 1023.0f + 0.5f);
|
|
return static_cast<float>(code) / 1023.0f;
|
|
}
|
|
|
|
uint16_t floatToHalf(float value)
|
|
{
|
|
uint32_t bits;
|
|
std::memcpy(&bits, &value, sizeof(bits));
|
|
const uint16_t sign = (bits >> 16) & 0x8000;
|
|
int exponent = ((bits >> 23) & 0xff) - 127 + 15;
|
|
uint32_t mantissa = bits & 0x7fffff;
|
|
if (exponent <= 0)
|
|
return sign;
|
|
if (exponent >= 31)
|
|
return sign | 0x7c00;
|
|
mantissa += 0x1000;
|
|
if (mantissa & 0x800000)
|
|
{
|
|
mantissa = 0;
|
|
if (++exponent >= 31)
|
|
return sign | 0x7c00;
|
|
}
|
|
return sign | (static_cast<uint16_t>(exponent) << 10) | (mantissa >> 13);
|
|
}
|
|
|
|
float halfToFloat(uint16_t value)
|
|
{
|
|
const uint32_t sign = static_cast<uint32_t>(value & 0x8000) << 16;
|
|
uint32_t exponent = (value >> 10) & 0x1f;
|
|
uint32_t mantissa = value & 0x3ff;
|
|
uint32_t bits;
|
|
if (exponent == 0)
|
|
bits = sign;
|
|
else if (exponent == 31)
|
|
bits = sign | 0x7f800000 | (mantissa << 13);
|
|
else
|
|
{
|
|
exponent = exponent - 15 + 127;
|
|
bits = sign | (exponent << 23) | (mantissa << 13);
|
|
}
|
|
float result;
|
|
std::memcpy(&result, &bits, sizeof(result));
|
|
return result;
|
|
}
|
|
|
|
RGB toBT2020(RGB value)
|
|
{
|
|
return {
|
|
value.r * 0.6274039f + value.g * 0.3292830f + value.b * 0.0433131f,
|
|
value.r * 0.0690973f + value.g * 0.9195404f + value.b * 0.0113623f,
|
|
value.r * 0.0163914f + value.g * 0.0880133f + value.b * 0.8955953f,
|
|
};
|
|
}
|
|
|
|
RGB toBT709(RGB value)
|
|
{
|
|
return {
|
|
value.r * 1.6604910f + value.g * -0.5876411f + value.b * -0.0728499f,
|
|
value.r * -0.1245505f + value.g * 1.1328999f + value.b * -0.0083494f,
|
|
value.r * -0.0181508f + value.g * -0.1005789f + value.b * 1.1187297f,
|
|
};
|
|
}
|
|
|
|
float linearToSRGB(float value)
|
|
{
|
|
return value >= 0.0031308f ?
|
|
std::pow(std::max(value, 0.0f), 1.0f / 2.4f) * 1.055f - 0.055f :
|
|
value * 12.92f;
|
|
}
|
|
|
|
RGB compressAndEncode(RGB value)
|
|
{
|
|
value.r = std::max(value.r, 0.0f);
|
|
value.g = std::max(value.g, 0.0f);
|
|
value.b = std::max(value.b, 0.0f);
|
|
const float peak = std::max({value.r, value.g, value.b});
|
|
if (peak > 0.0f)
|
|
{
|
|
const float compressed = peak < 0.75f ?
|
|
peak : 0.75f + (peak - 0.75f) * 0.25f;
|
|
const float scale = compressed / peak;
|
|
value.r *= scale;
|
|
value.g *= scale;
|
|
value.b *= scale;
|
|
}
|
|
value.r = linearToSRGB(std::clamp(value.r, 0.0f, 1.0f));
|
|
value.g = linearToSRGB(std::clamp(value.g, 0.0f, 1.0f));
|
|
value.b = linearToSRGB(std::clamp(value.b, 0.0f, 1.0f));
|
|
return value;
|
|
}
|
|
|
|
RGB expectedColor(const FormatCase & format, const Capture & capture,
|
|
unsigned x, unsigned y)
|
|
{
|
|
const RGB generated = generatedColor(x, y, kFrameSerial);
|
|
if (!format.hdr)
|
|
return generated;
|
|
|
|
if (format.type == FRAME_TYPE_RGBA16F)
|
|
{
|
|
RGB scRGB = {
|
|
halfToFloat(floatToHalf(generated.r * 4.0f)),
|
|
halfToFloat(floatToHalf(generated.g * 4.0f)),
|
|
halfToFloat(floatToHalf(generated.b * 4.0f)),
|
|
};
|
|
if (capture.header.flags & LG_TEST_CAPTURE_NATIVE_HDR)
|
|
return scRGB;
|
|
scRGB.r *= 80.0f / 250.0f;
|
|
scRGB.g *= 80.0f / 250.0f;
|
|
scRGB.b *= 80.0f / 250.0f;
|
|
return compressAndEncode(scRGB);
|
|
}
|
|
|
|
RGB linear709 = {
|
|
generated.r * 4.0f,
|
|
generated.g * 4.0f,
|
|
generated.b * 4.0f,
|
|
};
|
|
RGB encoded2020 = toBT2020(linear709);
|
|
encoded2020.r = quantizePQ(encoded2020.r);
|
|
encoded2020.g = quantizePQ(encoded2020.g);
|
|
encoded2020.b = quantizePQ(encoded2020.b);
|
|
if (capture.header.flags & LG_TEST_CAPTURE_NATIVE_HDR)
|
|
return encoded2020;
|
|
|
|
RGB linear2020 = {
|
|
pqToLinear(encoded2020.r) * (10000.0f / 250.0f),
|
|
pqToLinear(encoded2020.g) * (10000.0f / 250.0f),
|
|
pqToLinear(encoded2020.b) * (10000.0f / 250.0f),
|
|
};
|
|
return compressAndEncode(toBT709(linear2020));
|
|
}
|
|
|
|
RGB capturedColor(const Capture & capture, unsigned x, unsigned y)
|
|
{
|
|
const bool bottomUp =
|
|
capture.header.flags & LG_TEST_CAPTURE_BOTTOM_UP;
|
|
const unsigned row = bottomUp ? capture.header.height - 1 - y : y;
|
|
const uint8_t * pixel =
|
|
capture.data.data() + row * capture.header.stride;
|
|
|
|
switch (capture.header.captureFormat)
|
|
{
|
|
case LG_TEST_CAPTURE_RGBA8:
|
|
pixel += x * 4;
|
|
return {
|
|
pixel[0] / 255.0f,
|
|
pixel[1] / 255.0f,
|
|
pixel[2] / 255.0f,
|
|
};
|
|
|
|
case LG_TEST_CAPTURE_RGB10_A2:
|
|
{
|
|
uint32_t packed;
|
|
std::memcpy(&packed, pixel + x * sizeof(packed), sizeof(packed));
|
|
return {
|
|
static_cast<float>( packed & 0x3ff) / 1023.0f,
|
|
static_cast<float>((packed >> 10) & 0x3ff) / 1023.0f,
|
|
static_cast<float>((packed >> 20) & 0x3ff) / 1023.0f,
|
|
};
|
|
}
|
|
|
|
case LG_TEST_CAPTURE_RGBA32F:
|
|
{
|
|
std::array<float, 4> value;
|
|
std::memcpy(value.data(), pixel + x * sizeof(value), sizeof(value));
|
|
return {value[0], value[1], value[2]};
|
|
}
|
|
|
|
default:
|
|
ADD_FAILURE() << "Unknown capture format: " <<
|
|
capture.header.captureFormat;
|
|
return {};
|
|
}
|
|
}
|
|
|
|
void compareReference(const FormatCase & format, const Capture & capture)
|
|
{
|
|
float maxError = 0.0f;
|
|
unsigned maxX = 0;
|
|
unsigned maxY = 0;
|
|
RGB maxActual {};
|
|
RGB maxExpected {};
|
|
for (unsigned y = 0; y < capture.header.height; ++y)
|
|
for (unsigned x = 0; x < capture.header.width; ++x)
|
|
{
|
|
const RGB actual = capturedColor(capture, x, y);
|
|
const RGB expected = expectedColor(format, capture, x, y);
|
|
const float error = std::max({
|
|
std::abs(actual.r - expected.r),
|
|
std::abs(actual.g - expected.g),
|
|
std::abs(actual.b - expected.b),
|
|
});
|
|
if (error > maxError)
|
|
{
|
|
maxError = error;
|
|
maxX = x;
|
|
maxY = y;
|
|
maxActual = actual;
|
|
maxExpected = expected;
|
|
}
|
|
}
|
|
|
|
const float tolerance = format.hdr ? 0.012f : 0.006f;
|
|
EXPECT_LE(maxError, tolerance)
|
|
<< "max error at (" << maxX << ", " << maxY << ")"
|
|
<< "\nactual: " << maxActual.r << ", " << maxActual.g << ", "
|
|
<< maxActual.b
|
|
<< "\nexpected: " << maxExpected.r << ", " << maxExpected.g << ", "
|
|
<< maxExpected.b
|
|
<< "\ncapture retained at: " << capture.path;
|
|
}
|
|
|
|
using RenderCase = std::tuple<FormatCase, DamageCase>;
|
|
|
|
class RenderPipelineTest : public testing::TestWithParam<RenderCase>
|
|
{
|
|
};
|
|
|
|
TEST_P(RenderPipelineTest, MatchesReference)
|
|
{
|
|
const FormatCase & format = std::get<0>(GetParam());
|
|
const DamageCase & damage = std::get<1>(GetParam());
|
|
Capture capture = produceCapture(format, damage.name);
|
|
ASSERT_FALSE(capture.data.empty()) << "artifacts: " << capture.directory;
|
|
|
|
compareReference(format, capture);
|
|
if (!testing::Test::HasFailure())
|
|
std::filesystem::remove_all(capture.directory);
|
|
}
|
|
|
|
std::string renderCaseName(
|
|
const testing::TestParamInfo<RenderCase> & info)
|
|
{
|
|
return std::string(std::get<0>(info.param).name) + "_" +
|
|
std::get<1>(info.param).name;
|
|
}
|
|
|
|
INSTANTIATE_TEST_SUITE_P(FormatDamageMatrix, RenderPipelineTest,
|
|
testing::Combine(
|
|
testing::Values(
|
|
FormatCase {"bgra", FRAME_TYPE_BGRA, false, false},
|
|
FormatCase {"rgba", FRAME_TYPE_RGBA, false, false},
|
|
FormatCase {"bgr32", FRAME_TYPE_BGR_32, false, false},
|
|
FormatCase {"rgb24", FRAME_TYPE_RGB_24, false, false},
|
|
FormatCase {"rgba10", FRAME_TYPE_RGBA10, true, true },
|
|
FormatCase {"rgba16f", FRAME_TYPE_RGBA16F, true, false}
|
|
),
|
|
testing::Values(
|
|
DamageCase {"full"},
|
|
DamageCase {"moving"},
|
|
DamageCase {"overlap"},
|
|
DamageCase {"max"},
|
|
DamageCase {"invalid"},
|
|
DamageCase {"null"},
|
|
DamageCase {"zero"}
|
|
)
|
|
),
|
|
renderCaseName);
|
|
|
|
} // namespace
|