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;