From 74ae4f543ef47f576625514ebd5b7ec8462a0d9f Mon Sep 17 00:00:00 2001 From: Geoffrey McRae Date: Thu, 30 Jul 2026 14:53:38 +1000 Subject: [PATCH] [client] tests: add framebuffer rendering validation 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. --- .github/workflows/build.yml | 44 ++ client/CMakeLists.txt | 19 + client/include/interface/renderer.h | 28 ++ client/include/interface/test_capture.h | 49 +++ client/renderers/EGL/egl.c | 70 +++ client/src/main.c | 132 ++++++ client/tests/CMakeLists.txt | 43 ++ client/tests/README.md | 46 ++ client/tests/render_test.cpp | 556 ++++++++++++++++++++++++ client/tests/run-with-weston.sh | 62 +++ client/transports/Test/test.c | 129 +++++- 11 files changed, 1172 insertions(+), 6 deletions(-) create mode 100644 client/include/interface/test_capture.h create mode 100644 client/tests/CMakeLists.txt create mode 100644 client/tests/README.md create mode 100644 client/tests/render_test.cpp create mode 100644 client/tests/run-with-weston.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 624fadce..d90acc0a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -236,3 +236,47 @@ jobs: run: | cd doc make dirhtml SPHINXOPTS='-b spelling -W' -j$(nproc) + + render-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install dependencies + run: | + sudo apt-get update + sudo apt-get install \ + weston libgtest-dev libgl1-mesa-dri \ + libdw-dev libunwind-dev \ + libspice-protocol-dev nettle-dev \ + libgl-dev libgles-dev \ + libx11-dev libxss-dev libxi-dev libxinerama-dev \ + libxcursor-dev libxpresent-dev \ + libwayland-dev libxkbcommon-dev libfontconfig-dev \ + libsamplerate0-dev libpipewire-0.3-dev libpulse-dev + + - name: Configure + run: | + cmake -S client -B client/build-render-tests \ + -DOPTIMIZE_FOR_NATIVE=OFF \ + -DENABLE_LIBDECOR=OFF \ + -DENABLE_TEST_TRANSPORT=ON \ + -DENABLE_RENDER_TESTS=ON + + - name: Build + run: cmake --build client/build-render-tests --parallel + + - name: Run rendering matrix + run: | + ctest --test-dir client/build-render-tests \ + --output-on-failure \ + -R looking-glass-render-tests + + - name: Upload failed captures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: render-test-failures + path: /tmp/lg-render-case.* diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index 7cb29834..738bee77 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -36,6 +36,19 @@ add_feature_info(ENABLE_EGL ENABLE_EGL "EGL renderer.") option(ENABLE_TEST_TRANSPORT "Enable the test transport" OFF) add_feature_info(ENABLE_TEST_TRANSPORT ENABLE_TEST_TRANSPORT "Synthetic frames for graphics-pipeline validation.") +if(ENABLE_TEST_TRANSPORT) + add_definitions(-D ENABLE_TEST_TRANSPORT) +endif() + +option(ENABLE_RENDER_TESTS "Build framebuffer rendering integration tests" OFF) +add_feature_info(ENABLE_RENDER_TESTS ENABLE_RENDER_TESTS + "Pixel-accurate EGL rendering tests using the test transport.") +if(ENABLE_RENDER_TESTS AND NOT ENABLE_TEST_TRANSPORT) + message(FATAL_ERROR "ENABLE_RENDER_TESTS requires ENABLE_TEST_TRANSPORT") +endif() +if(ENABLE_RENDER_TESTS AND NOT ENABLE_EGL) + message(FATAL_ERROR "ENABLE_RENDER_TESTS requires ENABLE_EGL") +endif() option(ENABLE_BACKTRACE "Enable backtrace support on crash" ON) add_feature_info(ENABLE_BACKTRACE ENABLE_BACKTRACE "Backtrace support.") @@ -205,6 +218,12 @@ target_link_libraries(looking-glass-client cimgui ) +if(ENABLE_RENDER_TESTS) + include(CTest) + enable_testing() + add_subdirectory(tests) +endif() + if (ENABLE_PIPEWIRE OR ENABLE_PULSEAUDIO) add_definitions(-D ENABLE_AUDIO) add_subdirectory(audiodevs) diff --git a/client/include/interface/renderer.h b/client/include/interface/renderer.h index 2226ca6f..4285bd04 100644 --- a/client/include/interface/renderer.h +++ b/client/include/interface/renderer.h @@ -19,6 +19,7 @@ */ #pragma once +#include #include #include @@ -110,6 +111,28 @@ typedef struct LG_RendererRect } LG_RendererRect; +typedef enum LG_RendererCaptureFormat +{ + LG_CAPTURE_RGBA8, + LG_CAPTURE_RGB10_A2, + LG_CAPTURE_RGBA32F, +} +LG_RendererCaptureFormat; + +typedef struct LG_RendererCapture +{ + unsigned int width; + unsigned int height; + size_t stride; + size_t dataSize; + LG_RendererCaptureFormat format; + bool hdr; + bool hdrPQ; + bool nativeHDR; + void * data; +} +LG_RendererCapture; + typedef enum LG_RendererCursor { LG_CURSOR_COLOR , @@ -229,6 +252,11 @@ typedef struct LG_RendererOps const bool newFrame, const bool invalidateWindow, void (*preSwap)(void * udata), void * udata); + /* Optional test/diagnostic readback of the fully composed framebuffer. + * Called on the render thread before swap while the graphics context is + * current. The caller owns capture->data and must free it. */ + bool (*capture)(LG_Renderer * renderer, LG_RendererCapture * capture); + /* called to create a texture from the specified 32-bit RGB image data. This * method is for use with Dear ImGui * Context: renderThread */ diff --git a/client/include/interface/test_capture.h b/client/include/interface/test_capture.h new file mode 100644 index 00000000..b0bf8a5a --- /dev/null +++ b/client/include/interface/test_capture.h @@ -0,0 +1,49 @@ +/** + * 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. + */ + +#pragma once + +#include + +#define LG_TEST_CAPTURE_MAGIC UINT64_C(0x4c47434150545552) +#define LG_TEST_CAPTURE_VERSION 1 + +typedef enum LG_TestCaptureFormat +{ + LG_TEST_CAPTURE_RGBA8, + LG_TEST_CAPTURE_RGB10_A2, + LG_TEST_CAPTURE_RGBA32F, +} +LG_TestCaptureFormat; + +enum +{ + LG_TEST_CAPTURE_HDR = 0x1, + LG_TEST_CAPTURE_HDR_PQ = 0x2, + LG_TEST_CAPTURE_NATIVE_HDR = 0x4, + LG_TEST_CAPTURE_BOTTOM_UP = 0x8, +}; + +typedef struct LG_TestCaptureHeader +{ + uint64_t magic; + uint32_t version; + uint32_t headerSize; + uint64_t frameSerial; + uint32_t sourceType; + uint32_t captureFormat; + uint32_t width; + uint32_t height; + uint32_t stride; + uint32_t flags; + uint64_t dataSize; +} +LG_TestCaptureHeader; diff --git a/client/renderers/EGL/egl.c b/client/renderers/EGL/egl.c index bfe2ef92..86990098 100644 --- a/client/renderers/EGL/egl.c +++ b/client/renderers/EGL/egl.c @@ -39,6 +39,7 @@ #include "generator/output/cimgui_impl.h" #include +#include #include #include "egl_dynprocs.h" @@ -131,6 +132,7 @@ struct Inst bool surfaceSupportsPQ; bool surfaceSupportsSCRGB; + LG_RendererCaptureFormat captureFormat; bool hdr; // true if current frame format is HDR bool nativeHDR; // true only while the surface HDR description is active bool nativeHDRPQ; @@ -977,6 +979,8 @@ static bool egl_renderStartup(LG_Renderer * renderer, bool useDMA) this->surfaceSupportsPQ = configs[chosen].pq; this->surfaceSupportsSCRGB = configs[chosen].scRGB; + this->captureFormat = chosen == 0 ? LG_CAPTURE_RGBA32F : + chosen == 1 ? LG_CAPTURE_RGB10_A2 : LG_CAPTURE_RGBA8; DEBUG_INFO("EGL config: %s%s", configDesc, this->surfaceSupportsPQ ? " (HDR capable)" : ""); @@ -1570,6 +1574,71 @@ static bool egl_render(LG_Renderer * renderer, LG_RendererRotate rotate, return true; } +static bool egl_capture(LG_Renderer * renderer, LG_RendererCapture * capture) +{ + struct Inst * this = UPCAST(struct Inst, renderer); + if (!capture || this->width <= 0 || this->height <= 0) + return false; + + size_t bytesPerPixel; + GLenum dataType; + switch (this->captureFormat) + { + case LG_CAPTURE_RGBA8: + bytesPerPixel = 4; + dataType = GL_UNSIGNED_BYTE; + break; + + case LG_CAPTURE_RGB10_A2: + bytesPerPixel = 4; + dataType = GL_UNSIGNED_INT_2_10_10_10_REV; + break; + + case LG_CAPTURE_RGBA32F: + bytesPerPixel = sizeof(float) * 4; + dataType = GL_FLOAT; + break; + + default: + return false; + } + + if ((size_t)this->width > SIZE_MAX / bytesPerPixel || + (size_t)this->height > + SIZE_MAX / ((size_t)this->width * bytesPerPixel)) + return false; + + const size_t stride = (size_t)this->width * bytesPerPixel; + const size_t dataSize = stride * this->height; + void * data = malloc(dataSize); + if (!data) + return false; + + while (glGetError() != GL_NO_ERROR) + ; + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glReadPixels(0, 0, this->width, this->height, GL_RGBA, dataType, data); + if (glGetError() != GL_NO_ERROR) + { + free(data); + DEBUG_ERROR("Failed to read the composed EGL framebuffer"); + return false; + } + + *capture = (LG_RendererCapture) { + .width = this->width, + .height = this->height, + .stride = stride, + .dataSize = dataSize, + .format = this->captureFormat, + .hdr = this->format.hdr, + .hdrPQ = this->format.hdrPQ, + .nativeHDR = this->nativeHDR, + .data = data, + }; + return true; +} + static void * egl_createTexture(LG_Renderer * renderer, int width, int height, uint8_t * data) { @@ -1661,6 +1730,7 @@ struct LG_RendererOps LGR_EGL = .onFrame = egl_onFrame, .renderStartup = egl_renderStartup, .render = egl_render, + .capture = egl_capture, .createTexture = egl_createTexture, .freeTexture = egl_freeTexture, diff --git a/client/src/main.c b/client/src/main.c index 093ac002..2583f670 100644 --- a/client/src/main.c +++ b/client/src/main.c @@ -30,10 +30,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -49,6 +51,7 @@ #include "common/paths.h" #include "common/cpuinfo.h" #include "common/ll.h" +#include "common/option.h" #include "common/proctitle.h" #include "message.h" @@ -66,6 +69,16 @@ #include "render_queue.h" #include "evdev.h" +#ifdef ENABLE_TEST_TRANSPORT +#include "interface/test_capture.h" +_Static_assert((int)LG_CAPTURE_RGBA8 == (int)LG_TEST_CAPTURE_RGBA8, + "capture format mismatch"); +_Static_assert((int)LG_CAPTURE_RGB10_A2 == (int)LG_TEST_CAPTURE_RGB10_A2, + "capture format mismatch"); +_Static_assert((int)LG_CAPTURE_RGBA32F == (int)LG_TEST_CAPTURE_RGBA32F, + "capture format mismatch"); +#endif + // forwards static int renderThread(void * unused); @@ -86,6 +99,23 @@ struct CursorState g_cursor; // this structure is initialized in config.c struct AppParams g_params = { 0 }; +#ifdef ENABLE_TEST_TRANSPORT +static struct +{ + const char * path; + uint64_t targetSerial; + uint64_t lastSerial; + unsigned delay; + unsigned stableRenders; + bool enabled; + bool complete; +} +l_testCapture; + +static atomic_uint_least64_t l_testFrameSerial; +static _Atomic(FrameType) l_testFrameType; +#endif + static void lgInit(void) { g_state.formatValid = false; @@ -174,6 +204,74 @@ static void preSwapCallback(void * udata) const uint64_t * renderStart = (const uint64_t *)udata; ringbuffer_push(g_state.renderDuration, &(float) {(nanotime() - *renderStart) * 1e-6f}); + +#ifdef ENABLE_TEST_TRANSPORT + if (!l_testCapture.enabled || l_testCapture.complete) + return; + + const uint64_t serial = + atomic_load_explicit(&l_testFrameSerial, memory_order_acquire); + if (serial < l_testCapture.targetSerial) + return; + + if (l_testCapture.lastSerial != serial) + { + l_testCapture.lastSerial = serial; + l_testCapture.stableRenders = 0; + return; + } + + if (l_testCapture.stableRenders++ < l_testCapture.delay) + return; + + l_testCapture.complete = true; + LG_RendererCapture capture; + if (!g_state.lgr->ops.capture || + !RENDERER(capture, &capture)) + { + DEBUG_ERROR("The selected renderer cannot capture its framebuffer"); + app_setState(APP_STATE_SHUTDOWN); + return; + } + + const FrameType sourceType = + atomic_load_explicit(&l_testFrameType, memory_order_acquire); + const LG_TestCaptureHeader header = { + .magic = LG_TEST_CAPTURE_MAGIC, + .version = LG_TEST_CAPTURE_VERSION, + .headerSize = sizeof(header), + .frameSerial = serial, + .sourceType = sourceType, + .captureFormat = capture.format, + .width = capture.width, + .height = capture.height, + .stride = capture.stride, + .flags = (capture.hdr ? LG_TEST_CAPTURE_HDR : 0) | + (capture.hdrPQ ? LG_TEST_CAPTURE_HDR_PQ : 0) | + (capture.nativeHDR ? LG_TEST_CAPTURE_NATIVE_HDR : 0) | + LG_TEST_CAPTURE_BOTTOM_UP, + .dataSize = capture.dataSize, + }; + + FILE * file = fopen(l_testCapture.path, "wb"); + bool written = false; + if (file) + { + written = + fwrite(&header, sizeof(header), 1, file) == 1 && + fwrite(capture.data, capture.dataSize, 1, file) == 1; + if (fclose(file) != 0) + written = false; + } + free(capture.data); + + if (!written) + DEBUG_ERROR("Failed to write test capture to: %s", l_testCapture.path); + else + DEBUG_INFO("Wrote test capture for frame %" PRIu64 " to: %s", + serial, l_testCapture.path); + app_setState(APP_STATE_SHUTDOWN); +#endif } static int renderThread(void * unused) @@ -608,6 +706,10 @@ int main_frameThread(void * unused) g_state.formatValid = true; formatVersion = format->version; +#ifdef ENABLE_TEST_TRANSPORT + atomic_store_explicit(&l_testFrameType, format->type, + memory_order_release); +#endif DEBUG_INFO("Format: %s %ux%u (%ux%u) stride:%u pitch:%u rotation:%d hdr:%d pq:%d sdrWhite:%u nits", FrameTypeStr[format->type], format->frameWidth, format->frameHeight, format->dataWidth, format->dataHeight, format->stride, format->pitch, @@ -694,6 +796,10 @@ int main_frameThread(void * unused) g_state.lastFrameTimeValid = true; atomic_fetch_add_explicit(&g_state.frameCount, 1, memory_order_relaxed); +#ifdef ENABLE_TEST_TRANSPORT + atomic_store_explicit(&l_testFrameSerial, frame.serial, + memory_order_release); +#endif if (g_state.jitRender) { if (atomic_load_explicit(&g_state.pendingCount, memory_order_acquire) < 10) @@ -1095,6 +1201,32 @@ static int transportSessionProbe(void * opaque) static int lg_run(void) { +#ifdef ENABLE_TEST_TRANSPORT + memset(&l_testCapture, 0, sizeof(l_testCapture)); + atomic_store_explicit(&l_testFrameSerial, 0, memory_order_relaxed); + atomic_store_explicit(&l_testFrameType, FRAME_TYPE_INVALID, + memory_order_relaxed); + if (strcmp(g_params.transport, "test") == 0) + { + const char * capturePath = option_get_string("test", "captureFile"); + const int captureFrame = option_get_int("test", "captureFrame"); + const int captureDelay = option_get_int("test", "captureDelay"); + if (capturePath) + { + if (captureFrame < 1 || captureDelay < 0) + { + DEBUG_ERROR("test capture requires captureFrame >= 1 and " + "captureDelay >= 0"); + return -1; + } + l_testCapture.path = capturePath; + l_testCapture.targetSerial = captureFrame; + l_testCapture.delay = captureDelay; + l_testCapture.enabled = true; + } + } +#endif + g_cursor.sens = g_params.mouseSens; if (g_cursor.sens < -9) g_cursor.sens = -9; else if (g_cursor.sens > 9) g_cursor.sens = 9; diff --git a/client/tests/CMakeLists.txt b/client/tests/CMakeLists.txt new file mode 100644 index 00000000..0e7322ca --- /dev/null +++ b/client/tests/CMakeLists.txt @@ -0,0 +1,43 @@ +cmake_minimum_required(VERSION 3.10) + +find_package(GTest REQUIRED) +find_program(WESTON_EXECUTABLE NAMES weston) +if(NOT WESTON_EXECUTABLE) + message(FATAL_ERROR "weston is required to run framebuffer rendering tests") +endif() + +add_executable(looking-glass-render-tests + render_test.cpp +) +add_dependencies(looking-glass-render-tests looking-glass-client) + +set_target_properties(looking-glass-render-tests PROPERTIES + CXX_STANDARD 17 + CXX_STANDARD_REQUIRED ON +) + +target_compile_definitions(looking-glass-render-tests PRIVATE + LG_CLIENT_PATH="$" +) + +target_include_directories(looking-glass-render-tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/../include" + "${PROJECT_TOP}/common/include" +) + +if(TARGET GTest::gtest_main) + target_link_libraries(looking-glass-render-tests GTest::gtest_main) +else() + target_link_libraries(looking-glass-render-tests GTest::Main) +endif() + +add_test(NAME looking-glass-render-tests + COMMAND /bin/bash + "${CMAKE_CURRENT_SOURCE_DIR}/run-with-weston.sh" + "${WESTON_EXECUTABLE}" + "$" +) + +set_tests_properties(looking-glass-render-tests PROPERTIES + TIMEOUT 120 +) diff --git a/client/tests/README.md b/client/tests/README.md new file mode 100644 index 00000000..5bcbde2b --- /dev/null +++ b/client/tests/README.md @@ -0,0 +1,46 @@ +# Client rendering tests + +These gtests run the production client with the synthetic `test` transport, +capture the fully composed framebuffer immediately before presentation, and +compare every pixel with an independent reference implementation. + +The default CTest entry starts an isolated headless Weston compositor using +Mesa software rendering. It covers: + +- BGRA, RGBA, packed BGR32, RGB24, PQ RGBA10, and scRGB RGBA16F; +- odd widths and 24-bit row pitches; +- the Cartesian matrix of every format with full-frame, moving old/new-box, + overlapping, maximum-count, invalid, null, and zero-area damage; +- HDR-to-SDR transfer, gamut conversion, and tone mapping. + +When run on a color-managed Wayland compositor with a compatible EGL surface, +the same tests validate native RGB10A2 or FP16 output. Native PQ tests also +check the requested BT.2020 image description, reference white, mastering +luminance, MaxCLL, and MaxFALL in the client log. + +## Building and running + +```sh +cmake -S client -B client/build \ + -DENABLE_TEST_TRANSPORT=ON \ + -DENABLE_RENDER_TESTS=ON +cmake --build client/build +ctest --test-dir client/build --output-on-failure \ + -R looking-glass-render-tests +``` + +GoogleTest and Weston are required when `ENABLE_RENDER_TESTS` is enabled. +Failed cases retain their capture and client log under `/tmp` and print the +artifact paths. + +To exercise native HDR on the current Wayland session instead of the headless +SDR compositor: + +```sh +client/build/tests/looking-glass-render-tests \ + --gtest_filter='*rgba10*:*rgba16f*' +``` + +Framebuffer readback verifies the signal produced by Looking Glass. Verifying +a compositor's scanout and a physical panel's luminance remains a separate +hardware test. diff --git a/client/tests/render_test.cpp b/client/tests/render_test.cpp new file mode 100644 index 00000000..216a31f0 --- /dev/null +++ b/client/tests/render_test.cpp @@ -0,0 +1,556 @@ +/** + * 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 + +extern "C" +{ +#include "common/types.h" +#include "interface/test_capture.h" +} + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +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 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(input), + std::istreambuf_iterator()); +} + +std::filesystem::path makeTempDirectory() +{ + std::array 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 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 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(&result.header), sizeof(result.header)); + EXPECT_EQ(input.gcount(), static_cast(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(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(LG_TEST_CAPTURE_HDR) : 0u); + EXPECT_EQ(result.header.flags & LG_TEST_CAPTURE_HDR_PQ, + format.pq ? static_cast(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(result.data.data()), result.data.size()); + EXPECT_EQ(input.gcount(), static_cast(result.data.size())); + return result; +} + +RGB generatedColor(unsigned x, unsigned y, unsigned serial) +{ + uint8_t r = static_cast(x) * 255 / (kWidth - 1); + uint8_t g = static_cast(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(r) / 255.0f, + static_cast(g) / 255.0f, + static_cast(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(scRGB * (lutSize / 4.0f) + 0.5f); + const float lutScRGB = static_cast(index) * 4.0f / lutSize; + const float pq = linearToPQ(lutScRGB / 125.0f); + const unsigned code = static_cast(pq * 1023.0f + 0.5f); + return static_cast(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(exponent) << 10) | (mantissa >> 13); +} + +float halfToFloat(uint16_t value) +{ + const uint32_t sign = static_cast(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( packed & 0x3ff) / 1023.0f, + static_cast((packed >> 10) & 0x3ff) / 1023.0f, + static_cast((packed >> 20) & 0x3ff) / 1023.0f, + }; + } + + case LG_TEST_CAPTURE_RGBA32F: + { + std::array 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; + +class RenderPipelineTest : public testing::TestWithParam +{ +}; + +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 & 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 diff --git a/client/tests/run-with-weston.sh b/client/tests/run-with-weston.sh new file mode 100644 index 00000000..bafecfcd --- /dev/null +++ b/client/tests/run-with-weston.sh @@ -0,0 +1,62 @@ +#!/bin/bash +set -euo pipefail + +weston_bin=$1 +shift + +runtime_dir=$(mktemp -d /tmp/lg-render-tests.XXXXXX) +weston_pid= +cleanup() +{ + if [[ -n "$weston_pid" ]]; then + kill "$weston_pid" 2>/dev/null || true + wait "$weston_pid" 2>/dev/null || true + fi + rm -rf -- "$runtime_dir" +} +trap cleanup EXIT + +chmod 700 "$runtime_dir" +export XDG_RUNTIME_DIR="$runtime_dir" +export LIBGL_ALWAYS_SOFTWARE=1 +unset DISPLAY WAYLAND_DISPLAY + +socket_name=lg-render-tests +"$weston_bin" \ + --backend=headless \ + --renderer=gl \ + --width=1024 \ + --height=768 \ + --idle-time=0 \ + --no-config \ + --socket="$socket_name" \ + --log="$runtime_dir/weston.log" & +weston_pid=$! + +startup_deadline=$((SECONDS + 30)) +while (( SECONDS < startup_deadline )); do + if [[ -S "$runtime_dir/$socket_name" ]]; then + break + fi + if ! kill -0 "$weston_pid" 2>/dev/null; then + sed -n '1,240p' "$runtime_dir/weston.log" >&2 + exit 1 + fi + sleep 0.1 +done + +if [[ ! -S "$runtime_dir/$socket_name" ]]; then + echo "Timed out waiting for the Weston Wayland socket" >&2 + sed -n '1,240p' "$runtime_dir/weston.log" >&2 + exit 1 +fi + +export WAYLAND_DISPLAY="$socket_name" +set +e +"$@" +status=$? +set -e +if (( status != 0 )); then + sed -n '1,240p' "$runtime_dir/weston.log" >&2 +fi +exit "$status" \ No newline at end of file diff --git a/client/transports/Test/test.c b/client/transports/Test/test.c index 28d01f00..35d83acb 100644 --- a/client/transports/Test/test.c +++ b/client/transports/Test/test.c @@ -34,6 +34,17 @@ #define TEST_BUFFER_COUNT 2 #define TEST_PQ_LUT_SIZE 4096 +enum TestDamageMode +{ + TEST_DAMAGE_MOVING, + TEST_DAMAGE_FULL, + TEST_DAMAGE_OVERLAP, + TEST_DAMAGE_MAX, + TEST_DAMAGE_INVALID, + TEST_DAMAGE_NULL, + TEST_DAMAGE_ZERO, +}; + struct TestFormat { const char * name; @@ -66,6 +77,8 @@ struct LG_Transport unsigned frameCount; bool realtime; bool cycleFormats; + bool holdLastFrame; + enum TestDamageMode damageMode; unsigned formatIndex; bool connected; @@ -74,7 +87,7 @@ struct LG_Transport uint64_t nextFrameTime; unsigned bufferIndex; struct TestBuffer buffers[TEST_BUFFER_COUNT]; - FrameDamageRect damage[2]; + FrameDamageRect damage[LG_TRANSPORT_MAX_DAMAGE_RECTS]; LG_TransportFrameFormat format; uint16_t pqLUT[TEST_PQ_LUT_SIZE + 1]; }; @@ -126,6 +139,43 @@ static void test_setup(void) .type = OPTION_TYPE_STRING, .value.x_string = "bgra", }, + { + .module = "test", + .name = "damage", + .description = "Damage mode: moving, full, overlap, max, invalid, " + "null, or zero", + .type = OPTION_TYPE_STRING, + .value.x_string = "moving", + }, + { + .module = "test", + .name = "holdLastFrame", + .description = + "Keep the session alive after generating frameCount frames", + .type = OPTION_TYPE_BOOL, + .value.x_bool = false, + }, + { + .module = "test", + .name = "captureFile", + .description = "Write a composed framebuffer capture to this file", + .type = OPTION_TYPE_STRING, + .value.x_string = NULL, + }, + { + .module = "test", + .name = "captureFrame", + .description = "Capture after this synthetic frame serial is submitted", + .type = OPTION_TYPE_INT, + .value.x_int = 0, + }, + { + .module = "test", + .name = "captureDelay", + .description = "Stable render passes to wait before framebuffer capture", + .type = OPTION_TYPE_INT, + .value.x_int = 4, + }, {0} }; @@ -141,6 +191,25 @@ static int test_findFormat(const char * name) return -1; } +static int test_findDamageMode(const char * name) +{ + static const char * names[] = + { + [TEST_DAMAGE_MOVING] = "moving", + [TEST_DAMAGE_FULL] = "full", + [TEST_DAMAGE_OVERLAP] = "overlap", + [TEST_DAMAGE_MAX] = "max", + [TEST_DAMAGE_INVALID] = "invalid", + [TEST_DAMAGE_NULL] = "null", + [TEST_DAMAGE_ZERO] = "zero", + }; + + for (unsigned i = 0; i < ARRAY_LENGTH(names); ++i) + if (strcmp(name, names[i]) == 0) + return (int)i; + return -1; +} + static float test_linearToPQ(float linear) { const float m1 = 2610.0f / 16384.0f; @@ -251,11 +320,13 @@ static bool test_create(LG_Transport ** result) const int frameRate = option_get_int("test", "frameRate"); const int frameCount = option_get_int("test", "frameCount"); const char * format = option_get_string("test", "format"); + const char * damage = option_get_string("test", "damage"); + const int damageMode = damage ? test_findDamageMode(damage) : -1; const bool cycleFormats = strcmp(format, "cycle") == 0; const int formatIndex = cycleFormats ? 0 : test_findFormat(format); const unsigned maxBytesPerPixel = formatIndex < 0 ? 0 : cycleFormats ? 8 : testFormats[formatIndex].bytesPerPixel; - if (width < 1 || height < 1 || !maxBytesPerPixel || + if (width < 1 || height < 1 || !maxBytesPerPixel || damageMode < 0 || width > (int)(UINT32_MAX / maxBytesPerPixel) || frameRate < 1 || frameRate > 1000000000 || frameCount < 0 || (size_t)width > (SIZE_MAX - sizeof(FrameBuffer)) / @@ -273,6 +344,8 @@ static bool test_create(LG_Transport ** result) this->frameCount = frameCount; this->realtime = option_get_bool("test", "realtime"); this->cycleFormats = cycleFormats; + this->holdLastFrame = option_get_bool("test", "holdLastFrame"); + this->damageMode = damageMode; this->formatIndex = (unsigned)formatIndex; test_initPQLUT(this); @@ -489,7 +562,12 @@ static LG_TransportStatus test_nextFrame(LG_Transport * this, bool useDMA, if (this->framePending) return LG_TRANSPORT_ERROR; if (this->frameCount && this->serial >= this->frameCount) - return LG_TRANSPORT_END; + { + if (!this->holdLastFrame) + return LG_TRANSPORT_END; + usleep(1000); + return LG_TRANSPORT_TIMEOUT; + } if (this->realtime) { @@ -518,7 +596,8 @@ static LG_TransportStatus test_nextFrame(LG_Transport * this, bool useDMA, frame->framebuffer = buffer->framebuffer; frame->dmaFD = -1; - if (this->serial == 1 || formatChanged) + if (this->damageMode == TEST_DAMAGE_FULL || + this->serial == 1 || formatChanged) frame->damageRectsCount = 0; else { @@ -542,8 +621,46 @@ static LG_TransportStatus test_nextFrame(LG_Transport * this, bool useDMA, .width = boxSize, .height = boxSize, }; - frame->damageRects = this->damage; - frame->damageRectsCount = 2; + frame->damageRects = this->damage; + switch (this->damageMode) + { + case TEST_DAMAGE_MOVING: + frame->damageRectsCount = 2; + break; + + case TEST_DAMAGE_OVERLAP: + this->damage[2] = this->damage[0]; + this->damage[3] = this->damage[1]; + frame->damageRectsCount = 4; + break; + + case TEST_DAMAGE_MAX: + for (unsigned i = 2; i < ARRAY_LENGTH(this->damage); ++i) + this->damage[i] = (FrameDamageRect) {0}; + frame->damageRectsCount = ARRAY_LENGTH(this->damage); + break; + + case TEST_DAMAGE_INVALID: + this->damage[0] = (FrameDamageRect) { + .x = this->width, .y = 0, .width = 1, .height = 1 + }; + frame->damageRectsCount = 1; + break; + + case TEST_DAMAGE_NULL: + frame->damageRects = NULL; + frame->damageRectsCount = 1; + break; + + case TEST_DAMAGE_ZERO: + this->damage[2] = (FrameDamageRect) {0}; + frame->damageRectsCount = 3; + break; + + case TEST_DAMAGE_FULL: + DEBUG_UNREACHABLE(); + break; + } } this->framePending = true;