Browse Source

Port 2.8 scene, camera, text and particle features

Scene/camera: perspective layers (perspectiveoverridefov), image x/y angles and 2D depth -2000..2000, camera objects with path timelines and queuemode, scene camera paths with camerafade, camera zoom and shake, WE keyframe evaluation, distance/height fog, HDR rendering with HDR bloom (--post-processing ultra), volumetric lightv for lpoint lights (--volumetrics, --shadows).

Text: HarfBuzz shaping and MSDF glyphs (msdfgen submodule) for outline/blur/drop shadow, WE line wrapping and row limits, colorBlendMode, screen anchor, script properties under WE names, layout tests.

Particles: children (static/eventfollow/eventspawn/eventdeath) with control point and value inheritance, 2.7 components (remap, boids, collisions, maintain*, hsv, colorlist and others), instanceoverride color retargeting, starttime prewarm.

3D: CMesh for static .mdl models with a perspective camera.

Output: image filters, color options and flip, --hdr PQ output on Wayland (color-management-v1), HDR video.

Also: copybackground for passthrough layers, cursor propagation (disablepropagation/solid), headless GPU drive for headless_render.sh and regress.py --gpu.
UwU 2 ngày trước cách đây
mục cha
commit
812a617cb1
72 tập tin đã thay đổi với 10118 bổ sung và 1052 xóa
  1. 3 0
      .gitmodules
  2. 32 2
      CMakeLists.txt
  3. 45 0
      docs/rendering/MDL_FILES.md
  4. 1631 0
      protocols/color-management-v1.xml
  5. 1 0
      src/External/msdfgen
  6. 151 0
      src/WallpaperEngine/Application/ApplicationContext.cpp
  7. 16 0
      src/WallpaperEngine/Application/ApplicationContext.h
  8. 179 5
      src/WallpaperEngine/Application/WallpaperApplication.cpp
  9. 5 1
      src/WallpaperEngine/Application/WallpaperApplication.h
  10. 7 1
      src/WallpaperEngine/Data/Assets/Texture.h
  11. 43 0
      src/WallpaperEngine/Data/Model/ImageAdjustments.h
  12. 429 17
      src/WallpaperEngine/Data/Model/Object.h
  13. 5 0
      src/WallpaperEngine/Data/Model/Project.h
  14. 3 0
      src/WallpaperEngine/Data/Model/PropertyAnimation.h
  15. 4 0
      src/WallpaperEngine/Data/Model/Types.h
  16. 45 0
      src/WallpaperEngine/Data/Model/Wallpaper.h
  17. 106 36
      src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp
  18. 5 0
      src/WallpaperEngine/Data/Parsers/DynamicValueParser.h
  19. 4 3
      src/WallpaperEngine/Data/Parsers/MaterialParser.cpp
  20. 665 43
      src/WallpaperEngine/Data/Parsers/ObjectParser.cpp
  21. 7 2
      src/WallpaperEngine/Data/Parsers/ObjectParser.h
  22. 10 0
      src/WallpaperEngine/Data/Parsers/TextureParser.cpp
  23. 161 16
      src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp
  24. 54 1
      src/WallpaperEngine/Maths.cpp
  25. 3 0
      src/WallpaperEngine/Maths.h
  26. 33 0
      src/WallpaperEngine/Render/CFBO.cpp
  27. 3 0
      src/WallpaperEngine/Render/CFBO.h
  28. 286 15
      src/WallpaperEngine/Render/CWallpaper.cpp
  29. 39 1
      src/WallpaperEngine/Render/CWallpaper.h
  30. 123 10
      src/WallpaperEngine/Render/Camera.cpp
  31. 38 0
      src/WallpaperEngine/Render/Camera.h
  32. 198 0
      src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.cpp
  33. 67 0
      src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.h
  34. 2 0
      src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h
  35. 130 0
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp
  36. 21 0
      src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h
  37. 5 0
      src/WallpaperEngine/Render/Drivers/VideoDriver.h
  38. 7 5
      src/WallpaperEngine/Render/Drivers/VideoFactories.cpp
  39. 135 1
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
  40. 20 0
      src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h
  41. 155 0
      src/WallpaperEngine/Render/Objects/CCamera.cpp
  42. 53 0
      src/WallpaperEngine/Render/Objects/CCamera.h
  43. 77 18
      src/WallpaperEngine/Render/Objects/CImage.cpp
  44. 9 0
      src/WallpaperEngine/Render/Objects/CImage.h
  45. 482 0
      src/WallpaperEngine/Render/Objects/CMesh.cpp
  46. 48 0
      src/WallpaperEngine/Render/Objects/CMesh.h
  47. 775 176
      src/WallpaperEngine/Render/Objects/CParticle.cpp
  48. 181 2
      src/WallpaperEngine/Render/Objects/CParticle.h
  49. 357 481
      src/WallpaperEngine/Render/Objects/CText.cpp
  50. 65 48
      src/WallpaperEngine/Render/Objects/CText.h
  51. 212 41
      src/WallpaperEngine/Render/Objects/Effects/CPass.cpp
  52. 43 1
      src/WallpaperEngine/Render/Objects/Effects/CPass.h
  53. 930 0
      src/WallpaperEngine/Render/Objects/TextLayout.cpp
  54. 160 0
      src/WallpaperEngine/Render/Objects/TextLayout.h
  55. 2 0
      src/WallpaperEngine/Render/RenderContext.cpp
  56. 29 0
      src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp
  57. 1 0
      src/WallpaperEngine/Render/Shaders/ShaderUnit.h
  58. 139 0
      src/WallpaperEngine/Render/Utils/NoiseUtils.h
  59. 500 0
      src/WallpaperEngine/Render/Volumetrics.cpp
  60. 61 0
      src/WallpaperEngine/Render/Volumetrics.h
  61. 2 0
      src/WallpaperEngine/Render/WallpaperState.h
  62. 751 65
      src/WallpaperEngine/Render/Wallpapers/CScene.cpp
  63. 73 0
      src/WallpaperEngine/Render/Wallpapers/CScene.h
  64. 9 1
      src/WallpaperEngine/Render/Wallpapers/CVideo.cpp
  65. 60 42
      src/WallpaperEngine/Scripting/AnimationSystem.cpp
  66. 5 0
      src/WallpaperEngine/Scripting/AnimationSystem.h
  67. 2 0
      src/WallpaperEngine/Scripting/ScriptableObject.cpp
  68. 120 0
      src/WallpaperEngine/Testing/Cases/TextLayoutRules.cpp
  69. 20 4
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp
  70. 6 0
      src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h
  71. 28 9
      tools/headless_render.sh
  72. 42 5
      tools/regression/regress.py

+ 3 - 0
.gitmodules

@@ -32,3 +32,6 @@
 [submodule "src/External/quickjs"]
 [submodule "src/External/quickjs"]
 	path = src/External/quickjs
 	path = src/External/quickjs
 	url = https://github.com/quickjs-ng/quickjs.git
 	url = https://github.com/quickjs-ng/quickjs.git
+[submodule "src/External/msdfgen"]
+	path = src/External/msdfgen
+	url = https://github.com/Chlumsky/msdfgen.git

+ 32 - 2
CMakeLists.txt

@@ -37,6 +37,8 @@ find_package(LZ4 REQUIRED)
 find_package(FFMPEG REQUIRED)
 find_package(FFMPEG REQUIRED)
 find_package(PulseAudio REQUIRED)
 find_package(PulseAudio REQUIRED)
 find_package(Freetype REQUIRED)
 find_package(Freetype REQUIRED)
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(HARFBUZZ REQUIRED IMPORTED_TARGET harfbuzz)
 
 
 set(ENABLE_OPT OFF)
 set(ENABLE_OPT OFF)
 set(SPIRV_CROSS_FORCE_PIC ON)
 set(SPIRV_CROSS_FORCE_PIC ON)
@@ -144,6 +146,12 @@ add_subdirectory(${CEF_LIBCEF_DLL_WRAPPER_PATH} libcef_dll_wrapper)
 add_subdirectory(src/External/glslang-WallpaperEngine glslang)
 add_subdirectory(src/External/glslang-WallpaperEngine glslang)
 add_subdirectory(src/External/SPIRV-Cross-WallpaperEngine spirv-cross)
 add_subdirectory(src/External/SPIRV-Cross-WallpaperEngine spirv-cross)
 add_subdirectory(src/External/kissfft kissfft)
 add_subdirectory(src/External/kissfft kissfft)
+# only msdfgen's core is needed (text MSDF glyphs), its own CMake project wants vcpkg/skia/tinyxml for the rest
+file(GLOB MSDFGEN_CORE_SOURCES src/External/msdfgen/core/*.cpp)
+add_library(msdfgen-core STATIC ${MSDFGEN_CORE_SOURCES})
+target_include_directories(msdfgen-core PUBLIC src/External/msdfgen)
+# an empty export macro makes base.h skip the msdfgen-config.h its CMake project would generate
+target_compile_definitions(msdfgen-core PUBLIC "MSDFGEN_PUBLIC=")
 add_subdirectory(src/External/argparse argparse)
 add_subdirectory(src/External/argparse argparse)
 set(QJS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
 set(QJS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
 add_subdirectory(src/External/quickjs quickjs)
 add_subdirectory(src/External/quickjs quickjs)
@@ -176,6 +184,10 @@ if(WAYLAND_SUPPORT_FOUND)
         COMMAND ${WaylandScanner} client-header ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h)
         COMMAND ${WaylandScanner} client-header ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c
         COMMAND ${WaylandScanner} private-code ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c)
         COMMAND ${WaylandScanner} private-code ${CMAKE_CURRENT_SOURCE_DIR}/protocols/wlr-layer-shell-unstable-v1.xml ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c)
+    add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/color-management-v1-protocol.h
+        COMMAND ${WaylandScanner} client-header ${CMAKE_CURRENT_SOURCE_DIR}/protocols/color-management-v1.xml ${WAYLAND_OUTPUT_DIR}/color-management-v1-protocol.h)
+    add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/color-management-v1-protocol.c
+        COMMAND ${WaylandScanner} private-code ${CMAKE_CURRENT_SOURCE_DIR}/protocols/color-management-v1.xml ${WAYLAND_OUTPUT_DIR}/color-management-v1-protocol.c)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.h
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.h
         COMMAND ${WaylandScanner} client-header ${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.h)
         COMMAND ${WaylandScanner} client-header ${WAYLAND_PROTOCOLS_DIR}/stable/xdg-shell/xdg-shell.xml ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.h)
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c
     add_custom_command(OUTPUT ${WAYLAND_OUTPUT_DIR}/xdg-shell-protocol.c
@@ -194,6 +206,8 @@ if(WAYLAND_SUPPORT_FOUND)
         wayland-egl
         wayland-egl
         ${OPENGL_egl_LIBRARY})
         ${OPENGL_egl_LIBRARY})
     set(WAYLAND_SOURCES
     set(WAYLAND_SOURCES
+        src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.h
+        src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.cpp
         src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h
         src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h
         src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
         src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp
         src/WallpaperEngine/Render/Drivers/Detectors/WaylandFullScreenDetector.cpp
         src/WallpaperEngine/Render/Drivers/Detectors/WaylandFullScreenDetector.cpp
@@ -211,7 +225,9 @@ if(WAYLAND_SUPPORT_FOUND)
         ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c
         ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.c
         ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h
         ${WAYLAND_OUTPUT_DIR}/wlr-layer-shell-unstable-v1-protocol.h
         ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c
         ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.c
-        ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h)
+        ${WAYLAND_OUTPUT_DIR}/xdg-output-unstable-v1-protocol.h
+        ${WAYLAND_OUTPUT_DIR}/color-management-v1-protocol.c
+        ${WAYLAND_OUTPUT_DIR}/color-management-v1-protocol.h)
 
 
     if(ENABLE_KDE_EXPERIMENTAL_FEATURES)
     if(ENABLE_KDE_EXPERIMENTAL_FEATURES)
         pkg_check_modules(DBUS REQUIRED dbus-1)
         pkg_check_modules(DBUS REQUIRED dbus-1)
@@ -476,8 +492,16 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Objects/CParticle.cpp
     src/WallpaperEngine/Render/Objects/CParticle.cpp
     src/WallpaperEngine/Render/Objects/CText.h
     src/WallpaperEngine/Render/Objects/CText.h
     src/WallpaperEngine/Render/Objects/CText.cpp
     src/WallpaperEngine/Render/Objects/CText.cpp
+    src/WallpaperEngine/Render/Objects/TextLayout.h
+    src/WallpaperEngine/Render/Objects/TextLayout.cpp
     src/WallpaperEngine/Render/Objects/CLight.h
     src/WallpaperEngine/Render/Objects/CLight.h
     src/WallpaperEngine/Render/Objects/CLight.cpp
     src/WallpaperEngine/Render/Objects/CLight.cpp
+    src/WallpaperEngine/Render/Objects/CCamera.h
+    src/WallpaperEngine/Render/Objects/CCamera.cpp
+    src/WallpaperEngine/Render/Volumetrics.h
+    src/WallpaperEngine/Render/Volumetrics.cpp
+    src/WallpaperEngine/Render/Objects/CMesh.h
+    src/WallpaperEngine/Render/Objects/CMesh.cpp
 
 
     src/WallpaperEngine/Render/CFBO.h
     src/WallpaperEngine/Render/CFBO.h
     src/WallpaperEngine/Render/CFBO.cpp
     src/WallpaperEngine/Render/CFBO.cpp
@@ -508,6 +532,7 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Assets/Package.h
     src/WallpaperEngine/Data/Assets/Package.h
     src/WallpaperEngine/Data/Model/Types.h
     src/WallpaperEngine/Data/Model/Types.h
     src/WallpaperEngine/Data/Model/Project.h
     src/WallpaperEngine/Data/Model/Project.h
+    src/WallpaperEngine/Data/Model/ImageAdjustments.h
     src/WallpaperEngine/Data/Model/Wallpaper.h
     src/WallpaperEngine/Data/Model/Wallpaper.h
     src/WallpaperEngine/Data/Model/Object.h
     src/WallpaperEngine/Data/Model/Object.h
     src/WallpaperEngine/Data/Model/Material.h
     src/WallpaperEngine/Data/Model/Material.h
@@ -624,7 +649,8 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Cases/ModuleNamespaceTiming.cpp
         src/WallpaperEngine/Testing/Cases/ModuleNamespaceTiming.cpp
         src/WallpaperEngine/Testing/Cases/AudioSensitivity.cpp
         src/WallpaperEngine/Testing/Cases/AudioSensitivity.cpp
         src/WallpaperEngine/Testing/Cases/ThumbnailPalette.cpp
         src/WallpaperEngine/Testing/Cases/ThumbnailPalette.cpp
-        src/WallpaperEngine/Testing/Cases/TextInputProperty.cpp)
+        src/WallpaperEngine/Testing/Cases/TextInputProperty.cpp
+        src/WallpaperEngine/Testing/Cases/TextLayoutRules.cpp)
 endif()
 endif()
 
 
 add_library(
 add_library(
@@ -646,6 +672,8 @@ target_link_libraries (linux-wallpaperengine-lib PUBLIC
     ${MPV_LIBRARY}
     ${MPV_LIBRARY}
     ${PULSEAUDIO_LIBRARY}
     ${PULSEAUDIO_LIBRARY}
     Freetype::Freetype
     Freetype::Freetype
+    PkgConfig::HARFBUZZ
+    msdfgen-core
     ${WAYLAND_LIBRARIES}
     ${WAYLAND_LIBRARIES}
     ${X11_LIBRARIES}
     ${X11_LIBRARIES}
     ${DBUS_LIBRARIES}
     ${DBUS_LIBRARIES}
@@ -672,6 +700,8 @@ if (BUILD_TESTING)
         ${MPV_LIBRARY}
         ${MPV_LIBRARY}
         ${PULSEAUDIO_LIBRARY}
         ${PULSEAUDIO_LIBRARY}
         Freetype::Freetype
         Freetype::Freetype
+        PkgConfig::HARFBUZZ
+        msdfgen-core
         ${WAYLAND_LIBRARIES}
         ${WAYLAND_LIBRARIES}
         ${X11_LIBRARIES}
         ${X11_LIBRARIES}
         ${DBUS_LIBRARIES}
         ${DBUS_LIBRARIES}

+ 45 - 0
docs/rendering/MDL_FILES.md

@@ -103,6 +103,51 @@ MDLFILE file;
 // mdla => animation information
 // mdla => animation information
 ```
 ```
 
 
+## MDLV header and meshes (from wallpaper64.exe's loader)
+
+The template above only fits single-mesh puppets. The real layout, as `sub_1401D9860` reads it:
+
+```
+CHAR   magic[]            // "MDLV0023", the number after MDLV is the version
+DWORD  defaultFormat      // vertex format for files older than 15
+DWORD  materialsPerMesh   // strings in front of every mesh, 1 in every sample seen
+DWORD  meshCount
+MESH   meshes[meshCount]
+```
+```
+typedef struct {
+    CHAR   material[][materialsPerMesh]; // "materials/models/foo.json"
+    DWORD  flags;          // version >= 4. Bit 1: 32 bit indices (else 16 bit). Bit 2: another DWORD follows
+    FLOAT  bboxMin[3];     // version >= 17
+    FLOAT  bboxMax[3];
+    DWORD  format;         // version >= 15, see below
+    DWORD  vertexByteLength;
+    BYTE   vertices[vertexByteLength];
+    DWORD  indexByteLength;
+    BYTE   indices[indexByteLength];  // triangle list
+    // version 23 only (the 2.4 engine reads up to 19): BYTE a, BYTE b, [DWORD n, BYTE[n] if b], DWORD m, BYTE[m]
+    // static models write six zero bytes here, puppets fill it
+} MESH;
+```
+
+The vertex format is a bitmask, and the vertex is the enabled components packed in this order (table at
+`0x140369CE0`/`0x140369D50`):
+
+| bit | size | | bit | size |
+| --- | --- | --- | --- | --- |
+| `0x1` position | 12 | | `0x10`/`0x20` texcoord0 as vec3/vec4 | 12/16 |
+| `0x10000` position4? | 16 | | `0x40`/`0x80`/`0x100` texcoord1 vec2/3/4 | 8/12/16 |
+| `0x2` normal | 12 | | `0x200`/`0x400`/`0x800` texcoord2 | 8/12/16 |
+| `0x4` tangent4 | 16 | | `0x1000`/`0x2000`/`0x4000` texcoord3 | 8/12/16 |
+| `0x800000` blend indices | 16 | | `0x20000`/`0x40000`/`0x80000` | 8/12/16 |
+| `0x1000000` blend weights | 16 | | `0x100000`/`0x200000`/`0x400000` | 8/12/16 |
+| `0x8` texcoord0 vec2 | 8 | | `0x8000` | 16 |
+
+Static 3D models are `0xf` (48 bytes), skinned ones and the "wide" puppets `0x180000f` (80 bytes), the narrow puppets
+`0x1800009` (52 bytes). One puppet (3771392318) uses `0x181000e`, no `0x1` but the 16 byte `0x10000` in its place,
+most likely a vec4 position. After the last mesh a version 13+ file names its next section (`MDLS...`), an empty string
+when there is none.
+
 ## Vertex blend indices/weights
 ## Vertex blend indices/weights
 
 
 `VERTEX.blendindices`/`blendweight` are always the 32 bytes immediately before the trailing UV pair, regardless of
 `VERTEX.blendindices`/`blendweight` are always the 32 bytes immediately before the trailing UV pair, regardless of

+ 1631 - 0
protocols/color-management-v1.xml

@@ -0,0 +1,1631 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<protocol name="color_management_v1">
+  <copyright>
+    Copyright 2019 Sebastian Wick
+    Copyright 2019 Erwin Burema
+    Copyright 2020 AMD
+    Copyright 2020-2024 Collabora, Ltd.
+    Copyright 2024 Xaver Hugl
+    Copyright 2022-2025 Red Hat, Inc.
+
+    Permission is hereby granted, free of charge, to any person obtaining a
+    copy of this software and associated documentation files (the "Software"),
+    to deal in the Software without restriction, including without limitation
+    the rights to use, copy, modify, merge, publish, distribute, sublicense,
+    and/or sell copies of the Software, and to permit persons to whom the
+    Software is furnished to do so, subject to the following conditions:
+
+    The above copyright notice and this permission notice (including the next
+    paragraph) shall be included in all copies or substantial portions of the
+    Software.
+
+    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
+    THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+    DEALINGS IN THE SOFTWARE.
+  </copyright>
+
+  <description summary="color management protocol">
+    The aim of the color management extension is to allow clients to know
+    the color properties of outputs, and to tell the compositor about the color
+    properties of their content on surfaces. Doing this enables a compositor
+    to perform automatic color management of content for different outputs
+    according to how content is intended to look like.
+
+    The color properties are represented as an image description object which
+    is immutable after it has been created. A wl_output always has an
+    associated image description that clients can observe. A wl_surface
+    always has an associated preferred image description as a hint chosen by
+    the compositor that clients can also observe. Clients can set an image
+    description on a wl_surface to denote the color characteristics of the
+    surface contents.
+
+    An image description includes SDR and HDR colorimetry and encoding, HDR
+    metadata, and viewing environment parameters. An image description does
+    not include the properties set through color-representation extension.
+    It is expected that the color-representation extension is used in
+    conjunction with the color management extension when necessary,
+    particularly with the YUV family of pixel formats.
+
+    Recommendation ITU-T H.273
+    "Coding-independent code points for video signal type identification"
+    shall be referred to as simply H.273 here.
+
+    The color-and-hdr repository
+    (https://gitlab.freedesktop.org/pq/color-and-hdr) contains
+    background information on the protocol design and legacy color management.
+    It also contains a glossary, learning resources for digital color, tools,
+    samples and more.
+
+    The terminology used in this protocol is based on common color science and
+    color encoding terminology where possible. The glossary in the color-and-hdr
+    repository shall be the authority on the definition of terms in this
+    protocol.
+
+    Warning! The protocol described in this file is currently in the testing
+    phase. Backward compatible changes may be added together with the
+    corresponding interface version bump. Backward incompatible changes can
+    only be done by creating a new major version of the extension.
+  </description>
+
+  <interface name="wp_color_manager_v1" version="1">
+    <description summary="color manager singleton">
+      A singleton global interface used for getting color management extensions
+      for wl_surface and wl_output objects, and for creating client defined
+      image description objects. The extension interfaces allow
+      getting the image description of outputs and setting the image
+      description of surfaces.
+
+      Compositors should never remove this global.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the color manager">
+        Destroy the wp_color_manager_v1 object. This does not affect any other
+        objects in any way.
+      </description>
+    </request>
+
+    <enum name="error">
+      <entry name="unsupported_feature" value="0"
+             summary="request not supported"/>
+      <entry name="surface_exists" value="1"
+             summary="color management surface exists already"/>
+    </enum>
+
+    <enum name="render_intent">
+      <description summary="rendering intents">
+        See the ICC.1:2022 specification from the International Color Consortium
+        for more details about rendering intents.
+
+        The principles of ICC defined rendering intents apply with all types of
+        image descriptions, not only those with ICC file profiles.
+
+        Compositors must support the perceptual rendering intent. Other
+        rendering intents are optional.
+      </description>
+
+      <entry name="perceptual" value="0"
+             summary="perceptual"/>
+      <entry name="relative" value="1"
+             summary="media-relative colorimetric"/>
+      <entry name="saturation" value="2"
+             summary="saturation"/>
+      <entry name="absolute" value="3"
+             summary="ICC-absolute colorimetric"/>
+      <entry name="relative_bpc" value="4"
+             summary="media-relative colorimetric + black point compensation"/>
+    </enum>
+
+    <enum name="feature">
+      <description summary="compositor supported features"/>
+
+      <entry name="icc_v2_v4" value="0"
+             summary="create_icc_creator request"/>
+      <entry name="parametric" value="1"
+             summary="create_parametric_creator request"/>
+      <entry name="set_primaries" value="2"
+             summary="parametric set_primaries request"/>
+      <entry name="set_tf_power" value="3"
+             summary="parametric set_tf_power request"/>
+      <entry name="set_luminances" value="4"
+             summary="parametric set_luminances request"/>
+      <entry name="set_mastering_display_primaries" value="5">
+        <description summary="parametric set_mastering_display_primaries request">
+          The compositor supports set_mastering_display_primaries request with a
+          target color volume fully contained inside the primary color volume.
+        </description>
+      </entry>
+      <entry name="extended_target_volume" value="6">
+        <description summary="parametric target exceeds primary color volume">
+          The compositor additionally supports target color volumes that
+          extend outside of the primary color volume.
+
+          This can only be advertised if feature set_mastering_display_primaries
+          is supported as well.
+        </description>
+      </entry>
+      <entry name="windows_scrgb" value="7"
+             summary="create_windows_scrgb request"/>
+    </enum>
+
+    <enum name="primaries">
+      <description summary="named color primaries">
+        Named color primaries used to encode well-known sets of primaries. H.273
+        is the authority, when it comes to the exact values of primaries and
+        authoritative specifications, where an equivalent code point exists.
+
+        A value of 0 is invalid and will never be present in the list of enums.
+
+        Descriptions do list the specifications for convenience.
+      </description>
+
+      <entry name="srgb" value="1">
+        <description summary="Color primaries for the sRGB color space as defined by the BT.709 standard">
+          Color primaries as defined by
+          - Rec. ITU-R BT.709-6
+          - Rec. ITU-R BT.1361-0 conventional colour gamut system and extended
+            colour gamut system (historical)
+          - IEC 61966-2-1 sRGB or sYCC
+          - IEC 61966-2-4
+          - Society of Motion Picture and Television Engineers (SMPTE) RP 177
+            (1993) Annex B
+          Equivalent to H.273 ColourPrimaries code point 1.
+        </description>
+      </entry>
+      <entry name="pal_m" value="2">
+        <description summary="Color primaries for PAL-M as defined by the BT.470 standard">
+          Color primaries as defined by
+          - Rec. ITU-R BT.470-6 System M (historical)
+          - United States National Television System Committee 1953
+            Recommendation for transmission standards for color television
+          - United States Federal Communications Commission (2003) Title 47 Code
+            of Federal Regulations 73.682 (a)(20)
+          Equivalent to H.273 ColourPrimaries code point 4.
+        </description>
+      </entry>
+      <entry name="pal" value="3">
+        <description summary="Color primaries for PAL as defined by the BT.601 standard">
+          Color primaries as defined by
+          - Rec. ITU-R BT.470-6 System B, G (historical)
+          - Rec. ITU-R BT.601-7 625
+          - Rec. ITU-R BT.1358-0 625 (historical)
+          - Rec. ITU-R BT.1700-0 625 PAL and 625 SECAM
+          Equivalent to H.273 ColourPrimaries code point 5.
+        </description>
+      </entry>
+      <entry name="ntsc" value="4">
+        <description summary="Color primaries for NTSC as defined by the BT.601 standard">
+          Color primaries as defined by
+          - Rec. ITU-R BT.601-7 525
+          - Rec. ITU-R BT.1358-1 525 or 625 (historical)
+          - Rec. ITU-R BT.1700-0 NTSC
+          - SMPTE 170M (2004)
+          - SMPTE 240M (1999) (historical)
+          Equivalent to H.273 ColourPrimaries code point 6 and 7.
+        </description>
+      </entry>
+      <entry name="generic_film" value="5">
+        <description summary="Generic film with colour filters using Illuminant C">
+          Color primaries as defined by H.273 for generic film.
+          Equivalent to H.273 ColourPrimaries code point 8.
+        </description>
+      </entry>
+      <entry name="bt2020" value="6">
+        <description summary="Color primaries as defined by the BT.2020 and BT.2100 standard">
+          Color primaries as defined by
+          - Rec. ITU-R BT.2020-2
+          - Rec. ITU-R BT.2100-0
+          Equivalent to H.273 ColourPrimaries code point 9.
+        </description>
+      </entry>
+      <entry name="cie1931_xyz" value="7">
+        <description summary="Color primaries of the full CIE 1931 XYZ color space">
+          Color primaries as defined as the maximum of the CIE 1931 XYZ color
+          space by
+          - SMPTE ST 428-1
+          - (CIE 1931 XYZ as in ISO 11664-1)
+          Equivalent to H.273 ColourPrimaries code point 10.
+        </description>
+      </entry>
+      <entry name="dci_p3" value="8">
+        <description summary="Color primaries of the DCI P3 color space as defined by the SMPTE RP 431 standard">
+          Color primaries as defined by Digital Cinema System and published in
+          SMPTE RP 431-2 (2011). Equivalent to H.273 ColourPrimaries code point
+          11.
+        </description>
+      </entry>
+      <entry name="display_p3" value="9">
+        <description summary="Color primaries of Display P3 variant of the DCI-P3 color space as defined by the SMPTE EG 432 standard">
+          Color primaries as defined by Digital Cinema System and published in
+          SMPTE EG 432-1 (2010).
+          Equivalent to H.273 ColourPrimaries code point 12.
+        </description>
+      </entry>
+      <entry name="adobe_rgb" value="10">
+        <description summary="Color primaries of the Adobe RGB color space as defined by the ISO 12640 standard">
+          Color primaries as defined by Adobe as "Adobe RGB" and later published
+          by ISO 12640-4 (2011).
+        </description>
+      </entry>
+    </enum>
+
+    <enum name="transfer_function">
+      <description summary="named transfer functions">
+        Named transfer functions used to represent well-known transfer
+        characteristics. H.273 is the authority, when it comes to the exact
+        formulas and authoritative specifications, where an equivalent code
+        point exists.
+
+        A value of 0 is invalid and will never be present in the list of enums.
+
+        Descriptions do list the specifications for convenience.
+      </description>
+
+      <entry name="bt1886" value="1">
+        <description summary="BT.1886 display transfer characteristic">
+          Rec. ITU-R BT.1886 is the display transfer characteristic assumed by
+          - Rec. ITU-R BT.601-7 525 and 625
+          - Rec. ITU-R BT.709-6
+          - Rec. ITU-R BT.2020-2
+          These recommendations are referred to by H.273 TransferCharacteristics
+          code points 1, 6, 14, and 15, which are all equivalent.
+
+          This TF implies these default luminances from Rec. ITU-R BT.2035:
+          - primary color volume minimum: 0.01 cd/m²
+          - primary color volume maximum: 100 cd/m²
+          - reference white: 100 cd/m²
+        </description>
+      </entry>
+      <entry name="gamma22" value="2">
+        <description summary="Assumed display gamma 2.2 transfer function">
+          Transfer characteristics as defined by
+          - Rec. ITU-R BT.470-6 System M (historical)
+          - United States National Television System Committee 1953
+            Recommendation for transmission standards for color television
+          - United States Federal Communications Commission (2003) Title 47 Code
+            of Federal Regulations 73.682 (a) (20)
+          - Rec. ITU-R BT.1700-0 625 PAL and 625 SECAM
+          Equivalent to H.273 TransferCharacteristics code point 4.
+        </description>
+      </entry>
+      <entry name="gamma28" value="3">
+        <description summary="Assumed display gamma 2.8 transfer function">
+          Transfer characteristics as defined by
+          - Rec. ITU-R BT.470-6 System B, G (historical)
+          Equivalent to H.273 TransferCharacteristics code point 5.
+        </description>
+      </entry>
+      <entry name="st240" value="4">
+        <description summary="SMPTE ST 240 transfer function">
+          Transfer characteristics as defined by
+          - SMPTE ST 240 (1999)
+          Equivalent to H.273 TransferCharacteristics code point 7.
+        </description>
+      </entry>
+      <entry name="ext_linear" value="5">
+        <description summary="extended linear transfer function">
+          Linear transfer function defined over all real numbers.
+          Normalised electrical values are equal the normalised optical values.
+
+          The differences to H.273 TransferCharacteristics code point 8 are
+          the definition over all real numbers.
+        </description>
+      </entry>
+      <entry name="log_100" value="6">
+        <description summary="logarithmic 100:1 transfer function">
+          Logarithmic transfer characteristic (100:1 range).
+          Equivalent to H.273 TransferCharacteristics code point 9.
+        </description>
+      </entry>
+      <entry name="log_316" value="7">
+        <description summary="logarithmic (100*Sqrt(10) : 1) transfer function">
+          Logarithmic transfer characteristic (100 * Sqrt(10) : 1 range).
+          Equivalent to H.273 TransferCharacteristics code point 10.
+        </description>
+      </entry>
+      <entry name="xvycc" value="8">
+        <description summary="IEC 61966-2-4 transfer function">
+          Transfer characteristics as defined by
+          - IEC 61966-2-4
+          Equivalent to H.273 TransferCharacteristics code point 11.
+        </description>
+      </entry>
+      <entry name="srgb" value="9">
+        <description summary="sRGB piece-wise transfer function">
+          Transfer characteristics as defined by
+          - IEC 61966-2-1 sRGB
+          Equivalent to H.273 TransferCharacteristics code point 13 with
+          MatrixCoefficients set to 0.
+        </description>
+      </entry>
+      <entry name="ext_srgb" value="10">
+        <description summary="Extended sRGB piece-wise transfer function">
+          Transfer characteristics as defined by
+          - IEC 61966-2-1 sYCC
+          Equivalent to H.273 TransferCharacteristics code point 13 with
+          MatrixCoefficients set to anything but 0.
+        </description>
+      </entry>
+      <entry name="st2084_pq" value="11">
+        <description summary="perceptual quantizer transfer function">
+          Transfer characteristics as defined by
+          - SMPTE ST 2084 (2014) for 10-, 12-, 14- and 16-bit systems
+          - Rec. ITU-R BT.2100-2 perceptual quantization (PQ) system
+          Equivalent to H.273 TransferCharacteristics code point 16.
+
+          This TF implies these default luminances
+          - primary color volume minimum: 0.005 cd/m²
+          - primary color volume maximum: 10000 cd/m²
+          - reference white: 203 cd/m²
+
+          The difference between the primary color volume minimum and maximum
+          must be approximately 10000 cd/m² as that is the swing of the EOTF
+          defined by ST 2084 and BT.2100. The default value for the
+          reference white is a protocol addition: it is suggested by
+          Report ITU-R BT.2408-7 and is not part of ST 2084 or BT.2100.
+        </description>
+      </entry>
+      <entry name="st428" value="12">
+        <description summary="SMPTE ST 428 transfer function">
+          Transfer characteristics as defined by
+          - SMPTE ST 428-1 (2019)
+          Equivalent to H.273 TransferCharacteristics code point 17.
+        </description>
+      </entry>
+      <entry name="hlg" value="13">
+        <description summary="hybrid log-gamma transfer function">
+          Transfer characteristics as defined by
+          - ARIB STD-B67 (2015)
+          - Rec. ITU-R BT.2100-2 hybrid log-gamma (HLG) system
+          Equivalent to H.273 TransferCharacteristics code point 18.
+
+          This TF implies these default luminances
+          - primary color volume minimum: 0.005 cd/m²
+          - primary color volume maximum: 1000 cd/m²
+          - reference white: 203 cd/m²
+
+          HLG is a relative display-referred signal with a specified
+          non-linear mapping to the display peak luminance (the HLG OOTF).
+          All absolute luminance values used here for HLG assume a 1000 cd/m²
+          peak display.
+
+          The default value for the reference white is a protocol addition:
+          it is suggested by Report ITU-R BT.2408-7 and is not part of
+          ARIB STD-B67 or BT.2100.
+        </description>
+      </entry>
+    </enum>
+
+    <request name="get_output">
+      <description summary="create a color management interface for a wl_output">
+        This creates a new wp_color_management_output_v1 object for the
+        given wl_output.
+
+        See the wp_color_management_output_v1 interface for more details.
+      </description>
+
+      <arg name="id" type="new_id" interface="wp_color_management_output_v1"/>
+      <arg name="output" type="object" interface="wl_output"/>
+    </request>
+
+    <request name="get_surface">
+      <description summary="create a color management interface for a wl_surface">
+        If a wp_color_management_surface_v1 object already exists for the given
+        wl_surface, the protocol error surface_exists is raised.
+
+        This creates a new color wp_color_management_surface_v1 object for the
+        given wl_surface.
+
+        See the wp_color_management_surface_v1 interface for more details.
+      </description>
+
+      <arg name="id" type="new_id" interface="wp_color_management_surface_v1"/>
+      <arg name="surface" type="object" interface="wl_surface"/>
+    </request>
+
+    <request name="get_surface_feedback">
+      <description summary="create a color management feedback interface">
+        This creates a new color wp_color_management_surface_feedback_v1 object
+        for the given wl_surface.
+
+        See the wp_color_management_surface_feedback_v1 interface for more
+        details.
+      </description>
+
+      <arg name="id" type="new_id"
+           interface="wp_color_management_surface_feedback_v1"/>
+      <arg name="surface" type="object" interface="wl_surface"/>
+    </request>
+
+    <request name="create_icc_creator">
+      <description summary="make a new ICC-based image description creator object">
+        Makes a new ICC-based image description creator object with all
+        properties initially unset. The client can then use the object's
+        interface to define all the required properties for an image description
+        and finally create a wp_image_description_v1 object.
+
+        This request can be used when the compositor advertises
+        wp_color_manager_v1.feature.icc_v2_v4.
+        Otherwise this request raises the protocol error unsupported_feature.
+      </description>
+
+      <arg name="obj"
+           type="new_id" interface="wp_image_description_creator_icc_v1"
+           summary="the new creator object"/>
+    </request>
+
+    <request name="create_parametric_creator">
+      <description summary="make a new parametric image description creator object">
+        Makes a new parametric image description creator object with all
+        properties initially unset. The client can then use the object's
+        interface to define all the required properties for an image description
+        and finally create a wp_image_description_v1 object.
+
+        This request can be used when the compositor advertises
+        wp_color_manager_v1.feature.parametric.
+        Otherwise this request raises the protocol error unsupported_feature.
+      </description>
+
+      <arg name="obj"
+           type="new_id" interface="wp_image_description_creator_params_v1"
+           summary="the new creator object"/>
+    </request>
+
+    <request name="create_windows_scrgb">
+      <description summary="create Windows-scRGB image description object">
+        This creates a pre-defined image description for the so-called
+        Windows-scRGB stimulus encoding. This comes from the Windows 10 handling
+        of its own definition of an scRGB color space for an HDR screen
+        driven in BT.2100/PQ signalling mode.
+
+        Windows-scRGB uses sRGB (BT.709) color primaries and white point.
+        The transfer characteristic is extended linear.
+
+        The nominal color channel value range is extended, meaning it includes
+        negative and greater than 1.0 values. Negative values are used to
+        escape the sRGB color gamut boundaries. To make use of the extended
+        range, the client needs to use a pixel format that can represent those
+        values, e.g. floating-point 16 bits per channel.
+
+        Nominal color value R=G=B=0.0 corresponds to BT.2100/PQ system
+        0 cd/m², and R=G=B=1.0 corresponds to BT.2100/PQ system 80 cd/m².
+        The maximum is R=G=B=125.0 corresponding to 10k cd/m².
+
+        Windows-scRGB is displayed by Windows 10 by converting it to
+        BT.2100/PQ, maintaining the CIE 1931 chromaticity and mapping the
+        luminance as above. No adjustment is made to the signal to account
+        for the viewing conditions.
+
+        The reference white level of Windows-scRGB is unknown. If a
+        reference white level must be assumed for compositor processing, it
+        should be R=G=B=2.5375 corresponding to 203 cd/m² of Report ITU-R
+        BT.2408-7.
+
+        The target color volume of Windows-scRGB is unknown. The color gamut
+        may be anything between sRGB and BT.2100.
+
+        Note: EGL_EXT_gl_colorspace_scrgb_linear definition differs from
+        Windows-scRGB by using R=G=B=1.0 as the reference white level, while
+        Windows-scRGB reference white level is unknown or varies. However,
+        it seems probable that Windows implements both
+        EGL_EXT_gl_colorspace_scrgb_linear and Vulkan
+        VK_COLOR_SPACE_EXTENDED_SRGB_LINEAR_EXT as Windows-scRGB.
+
+        This request can be used when the compositor advertises
+        wp_color_manager_v1.feature.windows_scrgb.
+        Otherwise this request raises the protocol error unsupported_feature.
+
+        The resulting image description object does not allow get_information
+        request. The wp_image_description_v1.ready event shall be sent.
+      </description>
+
+      <arg name="image_description"
+           type="new_id" interface="wp_image_description_v1"/>
+    </request>
+
+    <event name="supported_intent">
+      <description summary="supported rendering intent">
+        When this object is created, it shall immediately send this event once
+        for each rendering intent the compositor supports.
+      </description>
+
+      <arg name="render_intent" type="uint" enum="render_intent"
+           summary="rendering intent"/>
+    </event>
+
+    <event name="supported_feature">
+      <description summary="supported features">
+        When this object is created, it shall immediately send this event once
+        for each compositor supported feature listed in the enumeration.
+      </description>
+
+      <arg name="feature" type="uint" enum="feature"
+           summary="supported feature"/>
+    </event>
+
+    <event name="supported_tf_named">
+      <description summary="supported named transfer characteristic">
+        When this object is created, it shall immediately send this event once
+        for each named transfer function the compositor supports with the
+        parametric image description creator.
+      </description>
+
+      <arg name="tf" type="uint" enum="transfer_function"
+           summary="Named transfer function"/>
+    </event>
+
+    <event name="supported_primaries_named">
+      <description summary="supported named primaries">
+        When this object is created, it shall immediately send this event once
+        for each named set of primaries the compositor supports with the
+        parametric image description creator.
+      </description>
+
+      <arg name="primaries" type="uint" enum="primaries"
+           summary="Named color primaries"/>
+    </event>
+
+    <event name="done">
+      <description summary="all features have been sent">
+        This event is sent when all supported rendering intents, features,
+        transfer functions and named primaries have been sent.
+      </description>
+    </event>
+  </interface>
+
+  <interface name="wp_color_management_output_v1" version="1">
+    <description summary="output color properties">
+      A wp_color_management_output_v1 describes the color properties of an
+      output.
+
+      The wp_color_management_output_v1 is associated with the wl_output global
+      underlying the wl_output object. Therefore the client destroying the
+      wl_output object has no impact, but the compositor removing the output
+      global makes the wp_color_management_output_v1 object inert.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the color management output">
+        Destroy the color wp_color_management_output_v1 object. This does not
+        affect any remaining protocol objects.
+      </description>
+    </request>
+
+    <event name="image_description_changed">
+      <description summary="image description changed">
+        This event is sent whenever the image description of the output changed,
+        followed by one wl_output.done event common to output events across all
+        extensions.
+
+        If the client wants to use the updated image description, it needs to do
+        get_image_description again, because image description objects are
+        immutable.
+      </description>
+    </event>
+
+    <request name="get_image_description">
+      <description summary="get the image description of the output">
+        This creates a new wp_image_description_v1 object for the current image
+        description of the output. There always is exactly one image description
+        active for an output so the client should destroy the image description
+        created by earlier invocations of this request. This request is usually
+        sent as a reaction to the image_description_changed event or when
+        creating a wp_color_management_output_v1 object.
+
+        The image description of an output represents the color encoding the
+        output expects. There might be performance and power advantages, as well
+        as improved color reproduction, if a content update matches the image
+        description of the output it is being shown on. If a content update is
+        shown on any other output than the one it matches the image description
+        of, then the color reproduction on those outputs might be considerably
+        worse.
+
+        The created wp_image_description_v1 object preserves the image
+        description of the output from the time the object was created.
+
+        The resulting image description object allows get_information request.
+
+        If this protocol object is inert, the resulting image description object
+        shall immediately deliver the wp_image_description_v1.failed event with
+        the no_output cause.
+
+        If the interface version is inadequate for the output's image
+        description, meaning that the client does not support all the events
+        needed to deliver the crucial information, the resulting image
+        description object shall immediately deliver the
+        wp_image_description_v1.failed event with the low_version cause.
+
+        Otherwise the object shall immediately deliver the ready event.
+      </description>
+
+      <arg name="image_description"
+           type="new_id" interface="wp_image_description_v1"/>
+    </request>
+  </interface>
+
+  <interface name="wp_color_management_surface_v1" version="1">
+    <description summary="color management extension to a surface">
+        A wp_color_management_surface_v1 allows the client to set the color
+        space and HDR properties of a surface.
+
+        If the wl_surface associated with the wp_color_management_surface_v1 is
+        destroyed, the wp_color_management_surface_v1 object becomes inert.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the color management interface for a surface">
+        Destroy the wp_color_management_surface_v1 object and do the same as
+        unset_image_description.
+      </description>
+    </request>
+
+    <enum name="error">
+      <description summary="protocol errors"/>
+      <entry name="render_intent" value="0"
+             summary="unsupported rendering intent"/>
+      <entry name="image_description" value="1"
+             summary="invalid image description"/>
+      <entry name="inert" value="2"
+             summary="forbidden request on inert object"/>
+    </enum>
+
+    <request name="set_image_description">
+      <description summary="set the surface image description">
+        If this protocol object is inert, the protocol error inert is raised.
+
+        Set the image description of the underlying surface. The image
+        description and rendering intent are double-buffered state, see
+        wl_surface.commit.
+
+        It is the client's responsibility to understand the image description
+        it sets on a surface, and to provide content that matches that image
+        description. Compositors might convert images to match their own or any
+        other image descriptions.
+
+        Image descriptions which are not ready (see wp_image_description_v1)
+        are forbidden in this request, and in such case the protocol error
+        image_description is raised.
+
+        All image descriptions which are ready (see wp_image_description_v1)
+        are allowed and must always be accepted by the compositor.
+
+        A rendering intent provides the client's preference on how content
+        colors should be mapped to each output. The render_intent value must
+        be one advertised by the compositor with
+        wp_color_manager_v1.render_intent event, otherwise the protocol error
+        render_intent is raised.
+
+        When an image description is set on a surface, the Transfer
+        Characteristics of the image description defines the valid range of
+        the nominal (real-valued) color channel values. The processing of
+        out-of-range color channel values is undefined, but compositors are
+        recommended to clamp the values to the valid range when possible.
+
+        By default, a surface does not have an associated image description
+        nor a rendering intent. The handling of color on such surfaces is
+        compositor implementation defined. Compositors should handle such
+        surfaces as sRGB, but may handle them differently if they have specific
+        requirements.
+
+        Setting the image description has copy semantics; after this request,
+        the image description can be immediately destroyed without affecting
+        the pending state of the surface.
+      </description>
+
+      <arg name="image_description"
+           type="object" interface="wp_image_description_v1"/>
+      <arg name="render_intent"
+           type="uint" enum="wp_color_manager_v1.render_intent"
+           summary="rendering intent"/>
+    </request>
+
+    <request name="unset_image_description">
+      <description summary="remove the surface image description">
+        If this protocol object is inert, the protocol error inert is raised.
+
+        This request removes any image description from the surface. See
+        set_image_description for how a compositor handles a surface without
+        an image description. This is double-buffered state, see
+        wl_surface.commit.
+      </description>
+    </request>
+  </interface>
+
+  <interface name="wp_color_management_surface_feedback_v1" version="1">
+    <description summary="color management extension to a surface">
+        A wp_color_management_surface_feedback_v1 allows the client to get the
+        preferred image description of a surface.
+
+        If the wl_surface associated with this object is destroyed, the
+        wp_color_management_surface_feedback_v1 object becomes inert.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the color management interface for a surface">
+        Destroy the wp_color_management_surface_feedback_v1 object.
+      </description>
+    </request>
+
+    <enum name="error">
+      <description summary="protocol errors"/>
+      <entry name="inert" value="0"
+             summary="forbidden request on inert object"/>
+      <entry name="unsupported_feature" value="1"
+             summary="attempted to use an unsupported feature"/>
+    </enum>
+
+    <event name="preferred_changed">
+      <description summary="the preferred image description changed">
+        The preferred image description is the one which likely has the most
+        performance and/or quality benefits for the compositor if used by the
+        client for its wl_surface contents. This event is sent whenever the
+        compositor changes the wl_surface's preferred image description.
+
+        This event sends the identity of the new preferred state as the argument,
+        so clients who are aware of the image description already can reuse it.
+        Otherwise, if the client client wants to know what the preferred image
+        description is, it shall use the get_preferred request.
+
+        The preferred image description is not automatically used for anything.
+        It is only a hint, and clients may set any valid image description with
+        set_image_description, but there might be performance and color accuracy
+        improvements by providing the wl_surface contents in the preferred
+        image description. Therefore clients that can, should render according
+        to the preferred image description
+      </description>
+
+      <arg name="identity" type="uint" summary="image description id number"/>
+    </event>
+
+    <request name="get_preferred">
+      <description summary="get the preferred image description">
+        If this protocol object is inert, the protocol error inert is raised.
+
+        The preferred image description represents the compositor's preferred
+        color encoding for this wl_surface at the current time. There might be
+        performance and power advantages, as well as improved color
+        reproduction, if the image description of a content update matches the
+        preferred image description.
+
+        This creates a new wp_image_description_v1 object for the currently
+        preferred image description for the wl_surface. The client should
+        stop using and destroy the image descriptions created by earlier
+        invocations of this request for the associated wl_surface.
+        This request is usually sent as a reaction to the preferred_changed
+        event or when creating a wp_color_management_surface_feedback_v1 object
+        if the client is capable of adapting to image descriptions.
+
+        The created wp_image_description_v1 object preserves the preferred image
+        description of the wl_surface from the time the object was created.
+
+        The resulting image description object allows get_information request.
+
+        If the image description is parametric, the client should set it on its
+        wl_surface only if the image description is an exact match with the
+        client content. Particularly if everything else matches, but the target
+        color volume is greater than what the client needs, the client should
+        create its own parameric image description with its exact parameters.
+
+        If the interface version is inadequate for the preferred image
+        description, meaning that the client does not support all the
+        events needed to deliver the crucial information, the resulting image
+        description object shall immediately deliver the
+        wp_image_description_v1.failed event with the low_version cause,
+        otherwise the object shall immediately deliver the ready event.
+      </description>
+
+      <arg name="image_description"
+           type="new_id" interface="wp_image_description_v1"/>
+    </request>
+
+    <request name="get_preferred_parametric">
+      <description summary="get the preferred image description">
+        The same description as for get_preferred applies, except the returned
+        image description is guaranteed to be parametric. This is meant for
+        clients that can only deal with parametric image descriptions.
+
+        If the compositor doesn't support parametric image descriptions, the
+        unsupported_feature error is emitted.
+      </description>
+
+      <arg name="image_description"
+           type="new_id" interface="wp_image_description_v1"/>
+    </request>
+  </interface>
+
+  <interface name="wp_image_description_creator_icc_v1" version="1">
+    <description summary="holder of image description ICC information">
+      This type of object is used for collecting all the information required
+      to create a wp_image_description_v1 object from an ICC file. A complete
+      set of required parameters consists of these properties:
+      - ICC file
+
+      Each required property must be set exactly once if the client is to create
+      an image description. The set requests verify that a property was not
+      already set. The create request verifies that all required properties are
+      set. There may be several alternative requests for setting each property,
+      and in that case the client must choose one of them.
+
+      Once all properties have been set, the create request must be used to
+      create the image description object, destroying the creator in the
+      process.
+    </description>
+
+    <enum name="error">
+      <description summary="protocol errors"/>
+
+      <entry name="incomplete_set" value="0"
+             summary="incomplete parameter set"/>
+      <entry name="already_set" value="1"
+             summary="property already set"/>
+      <entry name="bad_fd" value="2"
+             summary="fd not seekable and readable"/>
+      <entry name="bad_size" value="3"
+             summary="no or too much data"/>
+      <entry name="out_of_file" value="4"
+             summary="offset + length exceeds file size"/>
+    </enum>
+
+    <request name="create" type="destructor">
+      <description summary="Create the image description object from ICC data">
+        Create an image description object based on the ICC information
+        previously set on this object. A compositor must parse the ICC data in
+        some undefined but finite amount of time.
+
+        The completeness of the parameter set is verified. If the set is not
+        complete, the protocol error incomplete_set is raised. For the
+        definition of a complete set, see the description of this interface.
+
+        If the particular combination of the information is not supported
+        by the compositor, the resulting image description object shall
+        immediately deliver the wp_image_description_v1.failed event with the
+        'unsupported' cause. If a valid image description was created from the
+        information, the wp_image_description_v1.ready event will eventually
+        be sent instead.
+
+        This request destroys the wp_image_description_creator_icc_v1 object.
+
+        The resulting image description object does not allow get_information
+        request.
+      </description>
+
+      <arg name="image_description"
+           type="new_id" interface="wp_image_description_v1"/>
+    </request>
+
+    <request name="set_icc_file">
+      <description summary="set the ICC profile file">
+        Sets the ICC profile file to be used as the basis of the image
+        description.
+
+        The data shall be found through the given fd at the given offset, having
+        the given length. The fd must be seekable and readable. Violating these
+        requirements raises the bad_fd protocol error.
+
+        If reading the data fails due to an error independent of the client, the
+        compositor shall send the wp_image_description_v1.failed event on the
+        created wp_image_description_v1 with the 'operating_system' cause.
+
+        The maximum size of the ICC profile is 32 MB. If length is greater than
+        that or zero, the protocol error bad_size is raised. If offset + length
+        exceeds the file size, the protocol error out_of_file is raised.
+
+        A compositor may read the file at any time starting from this request
+        and only until whichever happens first:
+        - If create request was issued, the wp_image_description_v1 object
+          delivers either failed or ready event; or
+        - if create request was not issued, this
+          wp_image_description_creator_icc_v1 object is destroyed.
+
+        A compositor shall not modify the contents of the file, and the fd may
+        be sealed for writes and size changes. The client must ensure to its
+        best ability that the data does not change while the compositor is
+        reading it.
+
+        The data must represent a valid ICC profile. The ICC profile version
+        must be 2 or 4, it must be a 3 channel profile and the class must be
+        Display or ColorSpace. Violating these requirements will not result in a
+        protocol error, but will eventually send the
+        wp_image_description_v1.failed event on the created
+        wp_image_description_v1 with the 'unsupported' cause.
+
+        See the International Color Consortium specification ICC.1:2022 for more
+        details about ICC profiles.
+
+        If ICC file has already been set on this object, the protocol error
+        already_set is raised.
+      </description>
+
+      <arg name="icc_profile" type="fd"
+           summary="ICC profile"/>
+      <arg name="offset" type="uint"
+           summary="byte offset in fd to start of ICC data"/>
+      <arg name="length" type="uint"
+           summary="length of ICC data in bytes"/>
+    </request>
+  </interface>
+
+  <interface name="wp_image_description_creator_params_v1" version="1">
+    <description summary="holder of image description parameters">
+      This type of object is used for collecting all the parameters required
+      to create a wp_image_description_v1 object. A complete set of required
+      parameters consists of these properties:
+      - transfer characteristic function (tf)
+      - chromaticities of primaries and white point (primary color volume)
+
+      The following properties are optional and have a well-defined default
+      if not explicitly set:
+      - primary color volume luminance range
+      - reference white luminance level
+      - mastering display primaries and white point (target color volume)
+      - mastering luminance range
+
+      The following properties are optional and will be ignored
+      if not explicitly set:
+      - maximum content light level
+      - maximum frame-average light level
+
+      Each required property must be set exactly once if the client is to create
+      an image description. The set requests verify that a property was not
+      already set. The create request verifies that all required properties are
+      set. There may be several alternative requests for setting each property,
+      and in that case the client must choose one of them.
+
+      Once all properties have been set, the create request must be used to
+      create the image description object, destroying the creator in the
+      process.
+    </description>
+
+    <enum name="error">
+      <description summary="protocol errors"/>
+
+      <entry name="incomplete_set" value="0"
+             summary="incomplete parameter set"/>
+      <entry name="already_set" value="1"
+             summary="property already set"/>
+      <entry name="unsupported_feature" value="2"
+             summary="request not supported"/>
+      <entry name="invalid_tf" value="3"
+             summary="invalid transfer characteristic"/>
+      <entry name="invalid_primaries_named" value="4"
+             summary="invalid primaries named"/>
+      <entry name="invalid_luminance" value="5"
+             summary="invalid luminance value or range"/>
+    </enum>
+
+    <request name="create" type="destructor">
+      <description summary="Create the image description object using params">
+        Create an image description object based on the parameters previously
+        set on this object.
+
+        The completeness of the parameter set is verified. If the set is not
+        complete, the protocol error incomplete_set is raised. For the
+        definition of a complete set, see the description of this interface.
+
+        The protocol error invalid_luminance is raised if any of the following
+        requirements is not met:
+        - When max_cll is set, it must be greater than min L and less or equal
+          to max L of the mastering luminance range.
+        - When max_fall is set, it must be greater than min L and less or equal
+          to max L of the mastering luminance range.
+        - When both max_cll and max_fall are set, max_fall must be less or equal
+          to max_cll.
+
+        If the particular combination of the parameter set is not supported
+        by the compositor, the resulting image description object shall
+        immediately deliver the wp_image_description_v1.failed event with the
+        'unsupported' cause. If a valid image description was created from the
+        parameter set, the wp_image_description_v1.ready event will eventually
+        be sent instead.
+
+        This request destroys the wp_image_description_creator_params_v1
+        object.
+
+        The resulting image description object does not allow get_information
+        request.
+      </description>
+
+      <arg name="image_description"
+           type="new_id" interface="wp_image_description_v1"/>
+    </request>
+
+    <request name="set_tf_named">
+      <description summary="named transfer characteristic">
+        Sets the transfer characteristic using explicitly enumerated named
+        functions.
+
+        When the resulting image description is attached to an image, the
+        content should be encoded and decoded according to the industry standard
+        practices for the transfer characteristic.
+
+        Only names advertised with wp_color_manager_v1 event supported_tf_named
+        are allowed. Other values shall raise the protocol error invalid_tf.
+
+        If transfer characteristic has already been set on this object, the
+        protocol error already_set is raised.
+      </description>
+
+      <arg name="tf" type="uint" enum="wp_color_manager_v1.transfer_function"
+           summary="named transfer function"/>
+    </request>
+
+    <request name="set_tf_power">
+      <description summary="transfer characteristic as a power curve">
+        Sets the color component transfer characteristic to a power curve with
+        the given exponent. Negative values are handled by mirroring the
+        positive half of the curve through the origin. The valid domain and
+        range of the curve are all finite real numbers. This curve represents
+        the conversion from electrical to optical color channel values.
+
+        When the resulting image description is attached to an image, the
+        content should be encoded with the inverse of the power curve.
+
+        The curve exponent shall be multiplied by 10000 to get the argument eexp
+        value to carry the precision of 4 decimals.
+
+        The curve exponent must be at least 1.0 and at most 10.0. Otherwise the
+        protocol error invalid_tf is raised.
+
+        If transfer characteristic has already been set on this object, the
+        protocol error already_set is raised.
+
+        This request can be used when the compositor advertises
+        wp_color_manager_v1.feature.set_tf_power. Otherwise this request raises
+        the protocol error unsupported_feature.
+      </description>
+
+      <arg name="eexp" type="uint" summary="the exponent * 10000"/>
+    </request>
+
+    <request name="set_primaries_named">
+      <description summary="named primaries">
+        Sets the color primaries and white point using explicitly named sets.
+        This describes the primary color volume which is the basis for color
+        value encoding.
+
+        Only names advertised with wp_color_manager_v1 event
+        supported_primaries_named are allowed. Other values shall raise the
+        protocol error invalid_primaries_named.
+
+        If primaries have already been set on this object, the protocol error
+        already_set is raised.
+      </description>
+
+      <arg name="primaries" type="uint" enum="wp_color_manager_v1.primaries"
+           summary="named primaries"/>
+    </request>
+
+    <request name="set_primaries">
+      <description summary="primaries as chromaticity coordinates">
+        Sets the color primaries and white point using CIE 1931 xy chromaticity
+        coordinates. This describes the primary color volume which is the basis
+        for color value encoding.
+
+        Each coordinate value is multiplied by 1 million to get the argument
+        value to carry precision of 6 decimals.
+
+        If primaries have already been set on this object, the protocol error
+        already_set is raised.
+
+        This request can be used if the compositor advertises
+        wp_color_manager_v1.feature.set_primaries. Otherwise this request raises
+        the protocol error unsupported_feature.
+      </description>
+
+      <arg name="r_x" type="int" summary="Red x * 1M"/>
+      <arg name="r_y" type="int" summary="Red y * 1M"/>
+      <arg name="g_x" type="int" summary="Green x * 1M"/>
+      <arg name="g_y" type="int" summary="Green y * 1M"/>
+      <arg name="b_x" type="int" summary="Blue x * 1M"/>
+      <arg name="b_y" type="int" summary="Blue y * 1M"/>
+      <arg name="w_x" type="int" summary="White x * 1M"/>
+      <arg name="w_y" type="int" summary="White y * 1M"/>
+    </request>
+
+    <request name="set_luminances">
+      <description summary="primary color volume luminance range and reference white">
+        Sets the primary color volume luminance range and the reference white
+        luminance level. These values include the minimum display emission
+        and ambient flare luminances, assumed to be optically additive and have
+        the chromaticity of the primary color volume white point.
+
+        The default luminances from
+        https://www.color.org/chardata/rgb/srgb.xalter are
+        - primary color volume minimum: 0.2 cd/m²
+        - primary color volume maximum: 80 cd/m²
+        - reference white: 80 cd/m²
+
+        Setting a named transfer characteristic can imply other default
+        luminances.
+
+        The default luminances get overwritten when this request is used.
+        With transfer_function.st2084_pq the given 'max_lum' value is ignored,
+        and 'max_lum' is taken as 'min_lum' + 10000 cd/m².
+
+        'min_lum' and 'max_lum' specify the minimum and maximum luminances of
+        the primary color volume as reproduced by the targeted display.
+
+        'reference_lum' specifies the luminance of the reference white as
+        reproduced by the targeted display, and reflects the targeted viewing
+        environment.
+
+        Compositors should make sure that all content is anchored, meaning that
+        an input signal level of 'reference_lum' on one image description and
+        another input signal level of 'reference_lum' on another image
+        description should produce the same output level, even though the
+        'reference_lum' on both image representations can be different.
+
+        'reference_lum' may be higher than 'max_lum'. In that case reaching
+        the reference white output level in image content requires the
+        'extended_target_volume' feature support.
+
+        If 'max_lum' or 'reference_lum' are less than or equal to 'min_lum',
+        the protocol error invalid_luminance is raised.
+
+        The minimum luminance is multiplied by 10000 to get the argument
+        'min_lum' value and carries precision of 4 decimals. The maximum
+        luminance and reference white luminance values are unscaled.
+
+        If the primary color volume luminance range and the reference white
+        luminance level have already been set on this object, the protocol error
+        already_set is raised.
+
+        This request can be used if the compositor advertises
+        wp_color_manager_v1.feature.set_luminances. Otherwise this request
+        raises the protocol error unsupported_feature.
+      </description>
+
+      <arg name="min_lum" type="uint"
+           summary="minimum luminance (cd/m²) * 10000"/>
+      <arg name="max_lum" type="uint"
+           summary="maximum luminance (cd/m²)"/>
+      <arg name="reference_lum" type="uint"
+           summary="reference white luminance (cd/m²)"/>
+    </request>
+
+    <request name="set_mastering_display_primaries">
+      <description summary="mastering display primaries as chromaticity coordinates">
+        Provides the color primaries and white point of the mastering display
+        using CIE 1931 xy chromaticity coordinates. This is compatible with the
+        SMPTE ST 2086 definition of HDR static metadata.
+
+        The mastering display primaries and mastering display luminances define
+        the target color volume.
+
+        If mastering display primaries are not explicitly set, the target color
+        volume is assumed to have the same primaries as the primary color volume.
+
+        The target color volume is defined by all tristimulus values between 0.0
+        and 1.0 (inclusive) of the color space defined by the given mastering
+        display primaries and white point. The colorimetry is identical between
+        the container color space and the mastering display color space,
+        including that no chromatic adaptation is applied even if the white
+        points differ.
+
+        The target color volume can exceed the primary color volume to allow for
+        a greater color volume with an existing color space definition (for
+        example scRGB). It can be smaller than the primary color volume to
+        minimize gamut and tone mapping distances for big color spaces (HDR
+        metadata).
+
+        To make use of the entire target color volume a suitable pixel format
+        has to be chosen (e.g. floating point to exceed the primary color
+        volume, or abusing limited quantization range as with xvYCC).
+
+        Each coordinate value is multiplied by 1 million to get the argument
+        value to carry precision of 6 decimals.
+
+        If mastering display primaries have already been set on this object, the
+        protocol error already_set is raised.
+
+        This request can be used if the compositor advertises
+        wp_color_manager_v1.feature.set_mastering_display_primaries. Otherwise
+        this request raises the protocol error unsupported_feature. The
+        advertisement implies support only for target color volumes fully
+        contained within the primary color volume.
+
+        If a compositor additionally supports target color volume exceeding the
+        primary color volume, it must advertise
+        wp_color_manager_v1.feature.extended_target_volume. If a client uses
+        target color volume exceeding the primary color volume and the
+        compositor does not support it, the result is implementation defined.
+        Compositors are recommended to detect this case and fail the image
+        description gracefully, but it may as well result in color artifacts.
+      </description>
+
+      <arg name="r_x" type="int" summary="Red x * 1M"/>
+      <arg name="r_y" type="int" summary="Red y * 1M"/>
+      <arg name="g_x" type="int" summary="Green x * 1M"/>
+      <arg name="g_y" type="int" summary="Green y * 1M"/>
+      <arg name="b_x" type="int" summary="Blue x * 1M"/>
+      <arg name="b_y" type="int" summary="Blue y * 1M"/>
+      <arg name="w_x" type="int" summary="White x * 1M"/>
+      <arg name="w_y" type="int" summary="White y * 1M"/>
+    </request>
+
+    <request name="set_mastering_luminance">
+      <description summary="display mastering luminance range">
+        Sets the luminance range that was used during the content mastering
+        process as the minimum and maximum absolute luminance L. These values
+        include the minimum display emission and ambient flare luminances,
+        assumed to be optically additive and have the chromaticity of the
+        primary color volume white point. This should be
+        compatible with the SMPTE ST 2086 definition of HDR static metadata.
+
+        The mastering display primaries and mastering display luminances define
+        the target color volume.
+
+        If mastering luminances are not explicitly set, the target color volume
+        is assumed to have the same min and max luminances as the primary color
+        volume.
+
+        If max L is less than or equal to min L, the protocol error
+        invalid_luminance is raised.
+
+        Min L value is multiplied by 10000 to get the argument min_lum value
+        and carry precision of 4 decimals. Max L value is unscaled for max_lum.
+
+        This request can be used if the compositor advertises
+        wp_color_manager_v1.feature.set_mastering_display_primaries. Otherwise
+        this request raises the protocol error unsupported_feature. The
+        advertisement implies support only for target color volumes fully
+        contained within the primary color volume.
+
+        If a compositor additionally supports target color volume exceeding the
+        primary color volume, it must advertise
+        wp_color_manager_v1.feature.extended_target_volume. If a client uses
+        target color volume exceeding the primary color volume and the
+        compositor does not support it, the result is implementation defined.
+        Compositors are recommended to detect this case and fail the image
+        description gracefully, but it may as well result in color artifacts.
+      </description>
+
+      <arg name="min_lum" type="uint" summary="min L (cd/m²) * 10000"/>
+      <arg name="max_lum" type="uint" summary="max L (cd/m²)"/>
+    </request>
+
+    <request name="set_max_cll">
+      <description summary="maximum content light level">
+        Sets the maximum content light level (max_cll) as defined by CTA-861-H.
+
+        max_cll is undefined by default.
+      </description>
+
+      <arg name="max_cll" type="uint" summary="Maximum content light level (cd/m²)"/>
+    </request>
+
+    <request name="set_max_fall">
+      <description summary="maximum frame-average light level">
+        Sets the maximum frame-average light level (max_fall) as defined by
+        CTA-861-H.
+
+        max_fall is undefined by default.
+      </description>
+
+      <arg name="max_fall" type="uint" summary="Maximum frame-average light level (cd/m²)"/>
+    </request>
+  </interface>
+
+  <interface name="wp_image_description_v1" version="1">
+    <description summary="Colorimetric image description">
+      An image description carries information about the color encoding used on
+      a surface when attached to a wl_surface via
+      wp_color_management_surface_v1.set_image_description. A compositor can use
+      this information to decode pixel values into colorimetrically meaningful
+      quantities.
+
+      Note, that the wp_image_description_v1 object is not ready to be used
+      immediately after creation. The object eventually delivers either the
+      'ready' or the 'failed' event, specified in all requests creating it. The
+      object is deemed "ready" after receiving the 'ready' event.
+
+      An object which is not ready is illegal to use, it can only be destroyed.
+      Any other request in this interface shall result in the 'not_ready'
+      protocol error. Attempts to use an object which is not ready through other
+      interfaces shall raise protocol errors defined there.
+
+      Once created and regardless of how it was created, a
+      wp_image_description_v1 object always refers to one fixed image
+      description. It cannot change after creation.
+    </description>
+
+    <request name="destroy" type="destructor">
+      <description summary="destroy the image description">
+        Destroy this object. It is safe to destroy an object which is not ready.
+
+        Destroying a wp_image_description_v1 object has no side-effects, not
+        even if a wp_color_management_surface_v1.set_image_description has not
+        yet been followed by a wl_surface.commit.
+      </description>
+    </request>
+
+    <enum name="error">
+      <description summary="protocol errors"/>
+
+      <entry name="not_ready" value="0"
+             summary="attempted to use an object which is not ready"/>
+      <entry name="no_information" value="1"
+             summary="get_information not allowed"/>
+    </enum>
+
+    <enum name="cause">
+      <description summary="generic reason for failure"/>
+
+      <entry name="low_version" value="0"
+             summary="interface version too low"/>
+      <entry name="unsupported" value="1"
+             summary="unsupported image description data"/>
+      <entry name="operating_system" value="2"
+             summary="error independent of the client"/>
+      <entry name="no_output" value="3"
+             summary="the relevant output no longer exists"/>
+    </enum>
+
+    <event name="failed">
+      <description summary="graceful error on creating the image description">
+        If creating a wp_image_description_v1 object fails for a reason that is
+        not defined as a protocol error, this event is sent.
+
+        The requests that create image description objects define whether and
+        when this can occur. Only such creation requests can trigger this event.
+        This event cannot be triggered after the image description was
+        successfully formed.
+
+        Once this event has been sent, the wp_image_description_v1 object will
+        never become ready and it can only be destroyed.
+      </description>
+
+      <arg name="cause" type="uint" enum="cause"
+           summary="generic reason"/>
+      <arg name="msg" type="string"
+           summary="ad hoc human-readable explanation"/>
+    </event>
+
+    <event name="ready">
+      <description summary="indication that the object is ready to be used">
+        Once this event has been sent, the wp_image_description_v1 object is
+        deemed "ready". Ready objects can be used to send requests and can be
+        used through other interfaces.
+
+        Every ready wp_image_description_v1 protocol object refers to an
+        underlying image description record in the compositor. Multiple protocol
+        objects may end up referring to the same record. Clients may identify
+        these "copies" by comparing their id numbers: if the numbers from two
+        protocol objects are identical, the protocol objects refer to the same
+        image description record. Two different image description records
+        cannot have the same id number simultaneously. The id number does not
+        change during the lifetime of the image description record.
+
+        The id number is valid only as long as the protocol object is alive. If
+        all protocol objects referring to the same image description record are
+        destroyed, the id number may be recycled for a different image
+        description record.
+
+        Image description id number is not a protocol object id. Zero is
+        reserved as an invalid id number. It shall not be possible for a client
+        to refer to an image description by its id number in protocol. The id
+        numbers might not be portable between Wayland connections. A compositor
+        shall not send an invalid id number.
+
+        This identity allows clients to de-duplicate image description records
+        and avoid get_information request if they already have the image
+        description information.
+      </description>
+
+      <arg name="identity" type="uint" summary="image description id number"/>
+    </event>
+
+    <request name="get_information">
+      <description summary="get information about the image description">
+        Creates a wp_image_description_info_v1 object which delivers the
+        information that makes up the image description.
+
+        Not all image description protocol objects allow get_information
+        request. Whether it is allowed or not is defined by the request that
+        created the object. If get_information is not allowed, the protocol
+        error no_information is raised.
+      </description>
+
+      <arg name="information"
+           type="new_id" interface="wp_image_description_info_v1"/>
+    </request>
+  </interface>
+
+  <interface name="wp_image_description_info_v1" version="1">
+    <description summary="Colorimetric image description information">
+      Sends all matching events describing an image description object exactly
+      once and finally sends the 'done' event.
+
+      This means
+      - if the image description is parametric, it must send
+        - primaries
+        - named_primaries, if applicable
+        - at least one of tf_power and tf_named, as applicable
+        - luminances
+        - target_primaries
+        - target_luminance
+      - if the image description is parametric, it may send, if applicable,
+        - target_max_cll
+        - target_max_fall
+      - if the image description contains an ICC profile, it must send the
+        icc_file event
+
+      Once a wp_image_description_info_v1 object has delivered a 'done' event it
+      is automatically destroyed.
+
+      Every wp_image_description_info_v1 created from the same
+      wp_image_description_v1 shall always return the exact same data.
+    </description>
+
+    <event name="done" type="destructor">
+      <description summary="end of information">
+        Signals the end of information events and destroys the object.
+      </description>
+    </event>
+
+    <event name="icc_file">
+      <description summary="ICC profile matching the image description">
+        The icc argument provides a file descriptor to the client which may be
+        memory-mapped to provide the ICC profile matching the image description.
+        The fd is read-only, and if mapped then it must be mapped with
+        MAP_PRIVATE by the client.
+
+        The ICC profile version and other details are determined by the
+        compositor. There is no provision for a client to ask for a specific
+        kind of a profile.
+      </description>
+
+      <arg name="icc" type="fd" summary="ICC profile file descriptor"/>
+      <arg name="icc_size" type="uint" summary="ICC profile size, in bytes"/>
+      <!-- Offset always 0, compositor must not expose unnecessary data. -->
+    </event>
+
+    <event name="primaries">
+      <description summary="primaries as chromaticity coordinates">
+        Delivers the primary color volume primaries and white point using CIE
+        1931 xy chromaticity coordinates.
+
+        Each coordinate value is multiplied by 1 million to get the argument
+        value to carry precision of 6 decimals.
+      </description>
+
+      <arg name="r_x" type="int" summary="Red x * 1M"/>
+      <arg name="r_y" type="int" summary="Red y * 1M"/>
+      <arg name="g_x" type="int" summary="Green x * 1M"/>
+      <arg name="g_y" type="int" summary="Green y * 1M"/>
+      <arg name="b_x" type="int" summary="Blue x * 1M"/>
+      <arg name="b_y" type="int" summary="Blue y * 1M"/>
+      <arg name="w_x" type="int" summary="White x * 1M"/>
+      <arg name="w_y" type="int" summary="White y * 1M"/>
+    </event>
+
+    <event name="primaries_named">
+      <description summary="named primaries">
+        Delivers the primary color volume primaries and white point using an
+        explicitly enumerated named set.
+      </description>
+
+      <arg name="primaries" type="uint" enum="wp_color_manager_v1.primaries"
+           summary="named primaries"/>
+    </event>
+
+    <event name="tf_power">
+      <description summary="transfer characteristic as a power curve">
+        The color component transfer characteristic of this image description is
+        a pure power curve. This event provides the exponent of the power
+        function. This curve represents the conversion from electrical to
+        optical pixel or color values.
+
+        The curve exponent has been multiplied by 10000 to get the argument eexp
+        value to carry the precision of 4 decimals.
+      </description>
+
+      <arg name="eexp" type="uint" summary="the exponent * 10000"/>
+    </event>
+
+    <event name="tf_named">
+      <description summary="named transfer characteristic">
+        Delivers the transfer characteristic using an explicitly enumerated
+        named function.
+      </description>
+
+      <arg name="tf" type="uint" enum="wp_color_manager_v1.transfer_function"
+           summary="named transfer function"/>
+    </event>
+
+    <event name="luminances">
+      <description summary="primary color volume luminance range and reference white">
+        Delivers the primary color volume luminance range and the reference
+        white luminance level. These values include the minimum display emission
+        and ambient flare luminances, assumed to be optically additive and have
+        the chromaticity of the primary color volume white point.
+
+        The minimum luminance is multiplied by 10000 to get the argument
+        'min_lum' value and carries precision of 4 decimals. The maximum
+        luminance and reference white luminance values are unscaled.
+      </description>
+
+      <arg name="min_lum" type="uint"
+           summary="minimum luminance (cd/m²) * 10000"/>
+      <arg name="max_lum" type="uint"
+           summary="maximum luminance (cd/m²)"/>
+      <arg name="reference_lum" type="uint"
+           summary="reference white luminance (cd/m²)"/>
+    </event>
+
+    <event name="target_primaries">
+      <description summary="target primaries as chromaticity coordinates">
+        Provides the color primaries and white point of the target color volume
+        using CIE 1931 xy chromaticity coordinates. This is compatible with the
+        SMPTE ST 2086 definition of HDR static metadata for mastering displays.
+
+        While primary color volume is about how color is encoded, the target
+        color volume is the actually displayable color volume. If target color
+        volume is equal to the primary color volume, then this event is not
+        sent.
+
+        Each coordinate value is multiplied by 1 million to get the argument
+        value to carry precision of 6 decimals.
+      </description>
+
+      <arg name="r_x" type="int" summary="Red x * 1M"/>
+      <arg name="r_y" type="int" summary="Red y * 1M"/>
+      <arg name="g_x" type="int" summary="Green x * 1M"/>
+      <arg name="g_y" type="int" summary="Green y * 1M"/>
+      <arg name="b_x" type="int" summary="Blue x * 1M"/>
+      <arg name="b_y" type="int" summary="Blue y * 1M"/>
+      <arg name="w_x" type="int" summary="White x * 1M"/>
+      <arg name="w_y" type="int" summary="White y * 1M"/>
+    </event>
+
+    <event name="target_luminance">
+      <description summary="target luminance range">
+        Provides the luminance range that the image description is targeting as
+        the minimum and maximum absolute luminance L. These values include the
+        minimum display emission and ambient flare luminances, assumed to be
+        optically additive and have the chromaticity of the primary color
+        volume white point. This should be compatible with the SMPTE ST 2086
+        definition of HDR static metadata.
+
+        This luminance range is only theoretical and may not correspond to the
+        luminance of light emitted on an actual display.
+
+        Min L value is multiplied by 10000 to get the argument min_lum value and
+        carry precision of 4 decimals. Max L value is unscaled for max_lum.
+      </description>
+
+      <arg name="min_lum" type="uint" summary="min L (cd/m²) * 10000"/>
+      <arg name="max_lum" type="uint" summary="max L (cd/m²)"/>
+    </event>
+
+    <event name="target_max_cll">
+      <description summary="target maximum content light level">
+        Provides the targeted max_cll of the image description. max_cll is
+        defined by CTA-861-H.
+
+        This luminance is only theoretical and may not correspond to the
+        luminance of light emitted on an actual display.
+      </description>
+
+      <arg name="max_cll" type="uint"
+           summary="Maximum content light-level (cd/m²)"/>
+    </event>
+
+    <event name="target_max_fall">
+      <description summary="target maximum frame-average light level">
+        Provides the targeted max_fall of the image description. max_fall is
+        defined by CTA-861-H.
+
+        This luminance is only theoretical and may not correspond to the
+        luminance of light emitted on an actual display.
+      </description>
+
+      <arg name="max_fall" type="uint"
+           summary="Maximum frame-average light level (cd/m²)"/>
+    </event>
+  </interface>
+</protocol>

+ 1 - 0
src/External/msdfgen

@@ -0,0 +1 @@
+Subproject commit 6574da1310df433c97ca0fddcab7e463c31e58f8

+ 151 - 0
src/WallpaperEngine/Application/ApplicationContext.cpp

@@ -10,6 +10,7 @@
 #include <cstdlib>
 #include <cstdlib>
 #include <cstring>
 #include <cstring>
 #include <fstream>
 #include <fstream>
+#include <functional>
 #include <iostream>
 #include <iostream>
 #include <optional>
 #include <optional>
 #include <sstream>
 #include <sstream>
@@ -22,6 +23,7 @@
 #define APP_DIRECTORY "wallpaper_engine"
 #define APP_DIRECTORY "wallpaper_engine"
 
 
 using namespace WallpaperEngine::Application;
 using namespace WallpaperEngine::Application;
+using WallpaperEngine::Data::Model::ImageAdjustments;
 
 
 namespace {
 namespace {
 // CEF and --web-host children re-run the whole argument parsing, the parent already reported all of it
 // CEF and --web-host children re-run the whole argument parsing, the parent already reported all of it
@@ -685,6 +687,126 @@ void ApplicationContext::loadSettingsFromArgv () {
 	})
 	})
 	.append ();
 	.append ();
 
 
+    // Wallpaper Engine's own image settings (its properties panel), every one of them applies to the previous
+    // --window, --screen-root or --screen-span output like --zoom does
+    const auto updateImageAdjustments = [this, &lastScreen] (const std::function<void (ImageAdjustments&)>& update) {
+	if (this->settings.render.mode == DESKTOP_BACKGROUND) {
+	    update (this->settings.general.screenImageAdjustments[lastScreen]);
+	    if (lastScreen.rfind ("span:", 0) == 0 && !this->settings.general.spanGroups.empty ()) {
+		update (this->settings.general.spanGroups.back ().imageAdjustments);
+	    }
+	} else {
+	    update (this->settings.render.window.imageAdjustments);
+	}
+    };
+    const auto parseSlider = [] (const std::string& name, const std::string& value) -> float {
+	float result;
+
+	try {
+	    result = std::stof (value);
+	} catch (const std::exception&) {
+	    sLog.exception ("Invalid ", name, " value: ", value);
+	}
+
+	if (result < 0.0f || result > 100.0f) {
+	    sLog.exception (name, " must be between 0 and 100: ", value);
+	}
+
+	return result;
+    };
+
+    backgroundGroup.add_argument ("--image-filter")
+	.help (
+	    "Applies one of Wallpaper Engine's image filters (a color lookup table from the assets' materials/lut "
+	    "folder, e.g. \"lutx32_amber\" or \"k23_b\"), \"none\" removes it. Scene and video wallpapers only. "
+	    "This applies to the previous --window, --screen-root, or --screen-span output, or the default "
+	    "background if no other background is specified"
+	)
+	.action ([updateImageAdjustments] (const std::string& value) -> void {
+	    updateImageAdjustments ([&value] (ImageAdjustments& adjustments) {
+		adjustments.filter = value == "none" ? "" : value;
+	    });
+	})
+	.append ();
+
+    backgroundGroup.add_argument ("--image-filter-strength")
+	.help ("How strongly --image-filter applies, 0-100. Default: 100")
+	.action ([updateImageAdjustments, parseSlider] (const std::string& value) -> void {
+	    const float strength = parseSlider ("image filter strength", value);
+
+	    updateImageAdjustments ([strength] (ImageAdjustments& adjustments) {
+		adjustments.filterStrength = strength;
+	    });
+	})
+	.append ();
+
+    for (const auto& [option, name] : std::initializer_list<std::pair<const char*, const char*>> {
+	     { "--brightness", "brightness" },
+	     { "--contrast", "contrast" },
+	     { "--saturation", "saturation" },
+	     { "--hue", "hue" },
+	 }) {
+	backgroundGroup.add_argument (option)
+	    .help (
+		std::string ("Wallpaper Engine's ") + name
+		+ " color option, 0-100 with 50 leaving the image unchanged. Scene and video wallpapers only"
+	    )
+	    .action ([updateImageAdjustments, parseSlider, name] (const std::string& value) -> void {
+		const float amount = parseSlider (name, value);
+		const std::string_view which = name;
+
+		updateImageAdjustments ([amount, which] (ImageAdjustments& adjustments) {
+		    adjustments.colorEnabled = true;
+
+		    if (which == "brightness") {
+			adjustments.brightness = amount;
+		    } else if (which == "contrast") {
+			adjustments.contrast = amount;
+		    } else if (which == "saturation") {
+			adjustments.saturation = amount;
+		    } else {
+			adjustments.hue = amount;
+		    }
+		});
+	    })
+	    .append ();
+    }
+
+    backgroundGroup.add_argument ("--color-options")
+	.help (
+	    "Turns Wallpaper Engine's color options (--brightness and friends, or the ones a preset carries) on or "
+	    "off. Giving any of them turns them on"
+	)
+	.choices ("on", "off")
+	.action ([updateImageAdjustments] (const std::string& value) -> void {
+	    updateImageAdjustments ([&value] (ImageAdjustments& adjustments) {
+		adjustments.colorEnabled = value == "on";
+	    });
+	})
+	.append ();
+
+    backgroundGroup.add_argument ("--flip")
+	.help (
+	    "Mirrors the wallpaper horizontally (Wallpaper Engine's \"Flip\" option), \"on\" or \"off\". Scene and "
+	    "video wallpapers only"
+	)
+	.choices ("on", "off")
+	.action ([updateImageAdjustments] (const std::string& value) -> void {
+	    updateImageAdjustments ([&value] (ImageAdjustments& adjustments) {
+		adjustments.flipHorizontal = value == "on";
+	    });
+	})
+	.append ();
+
+    backgroundGroup.add_argument ("--hdr")
+	.help (
+	    "Wayland-only: sends HDR (PQ, BT.2020) to monitors running in HDR mode and plays HDR videos in HDR, "
+	    "like Wallpaper Engine's \"HDR display\" setting. Needs a compositor with the color management "
+	    "protocol (wp_color_manager_v1, recent KDE Plasma); other monitors keep getting the usual SDR image"
+	)
+	.flag ()
+	.store_into (this->settings.render.hdr);
+
     backgroundGroup.add_argument ("--layer")
     backgroundGroup.add_argument ("--layer")
 	.help (
 	.help (
 	    "Wayland-only: which wlr-layer-shell layer to anchor the wallpaper to "
 	    "Wayland-only: which wlr-layer-shell layer to anchor the wallpaper to "
@@ -859,6 +981,35 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.flag ()
 	.action ([this] (const std::string& value) -> void { this->settings.general.expandCanvas = true; });
 	.action ([this] (const std::string& value) -> void { this->settings.general.expandCanvas = true; });
 
 
+    configurationGroup.add_argument ("--post-processing")
+	.help (
+	    "Wallpaper Engine's post processing quality: \"enabled\" (default) or \"ultra\". With ultra, scenes that "
+	    "turn on both bloom and hdr render in HDR with Wallpaper Engine's HDR bloom"
+	)
+	.choices ("enabled", "ultra")
+	.action ([this] (const std::string& value) -> void {
+	    this->settings.general.ultraPostProcessing = value == "ultra";
+	});
+
+    // same names and numbers as Wallpaper Engine's quality settings (sub_14010DAD0)
+    const auto qualityLevel = [] (const std::string& value) {
+	return value == "disabled" ? 0 : value == "low" ? 1 : value == "high" ? 3 : value == "ultra" ? 4 : 2;
+    };
+
+    configurationGroup.add_argument ("--volumetrics")
+	.help ("Wallpaper Engine's volumetric lighting quality: disabled, low, medium (default), high or ultra")
+	.choices ("disabled", "low", "medium", "high", "ultra")
+	.action ([this, qualityLevel] (const std::string& value) -> void {
+	    this->settings.general.volumetricsQuality = qualityLevel (value);
+	});
+
+    configurationGroup.add_argument ("--shadows")
+	.help ("Wallpaper Engine's shadow quality: disabled, low, medium (default), high or ultra")
+	.choices ("disabled", "low", "medium", "high", "ultra")
+	.action ([this, qualityLevel] (const std::string& value) -> void {
+	    this->settings.general.shadowQuality = qualityLevel (value);
+	});
+
     configurationGroup.add_argument ("--disable-animations")
     configurationGroup.add_argument ("--disable-animations")
 	.help ("Freezes all scene animation (scripts, particles, effects and puppet meshes) at its current frame")
 	.help ("Freezes all scene animation (scripts, particles, effects and puppet meshes) at its current frame")
 	.flag ()
 	.flag ()

+ 16 - 0
src/WallpaperEngine/Application/ApplicationContext.h

@@ -100,6 +100,8 @@ public:
 	glm::vec2 offset = { 0.0f, 0.0f };
 	glm::vec2 offset = { 0.0f, 0.0f };
 	/** Color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
 	/** Color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
 	glm::vec4 cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f };
 	glm::vec4 cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f };
+	/** Image filter, color options and flip, see --image-filter */
+	WallpaperEngine::Data::Model::ImageAdjustments imageAdjustments = {};
     };
     };
 
 
     struct {
     struct {
@@ -112,6 +114,11 @@ public:
 	    bool disableParticles;
 	    bool disableParticles;
 	    /** Grows a scene's render canvas to fit every image layer that extends past it, see --expand-canvas */
 	    /** Grows a scene's render canvas to fit every image layer that extends past it, see --expand-canvas */
 	    bool expandCanvas;
 	    bool expandCanvas;
+	    /** WE's "ultra" post processing quality: scenes with bloom and hdr render in HDR, see --post-processing */
+	    bool ultraPostProcessing = false;
+	    /** WE's volumetrics and shadow quality: 0 disabled, 1 low, 2 medium, 3 high, 4 ultra */
+	    int volumetricsQuality = 2;
+	    int shadowQuality = 2;
 	    /** Objects/layers to force-hide, matched by id or name */
 	    /** Objects/layers to force-hide, matched by id or name */
 	    std::vector<std::string> disabledObjects;
 	    std::vector<std::string> disabledObjects;
 	    /** Objects/layers to force-show, matched by id or name */
 	    /** Objects/layers to force-show, matched by id or name */
@@ -137,6 +144,8 @@ public:
 	    std::map<std::string, glm::vec2> screenOffsets;
 	    std::map<std::string, glm::vec2> screenOffsets;
 	    /** Corner color for different screens, shown outside the wallpaper's bounds when clamp is border */
 	    /** Corner color for different screens, shown outside the wallpaper's bounds when clamp is border */
 	    std::map<std::string, glm::vec4> screenCornerColors;
 	    std::map<std::string, glm::vec4> screenCornerColors;
+	    /** Image filter, color options and flip for different screens, see --image-filter */
+	    std::map<std::string, WallpaperEngine::Data::Model::ImageAdjustments> screenImageAdjustments;
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
 	    std::map<std::string, PlaylistDefinition> screenPlaylists;
 	    /** Playlist used in window mode */
 	    /** Playlist used in window mode */
 	    std::optional<PlaylistDefinition> defaultPlaylist;
 	    std::optional<PlaylistDefinition> defaultPlaylist;
@@ -173,6 +182,8 @@ public:
 	     * Example: "firefox" will match "org.mozilla.firefox".
 	     * Example: "firefox" will match "org.mozilla.firefox".
 	     */
 	     */
 	    std::vector<std::string> fullscreenPauseIgnoreAppIds;
 	    std::vector<std::string> fullscreenPauseIgnoreAppIds;
+	    /** Wayland-only: PQ output on monitors in HDR mode and HDR video, see --hdr */
+	    bool hdr;
 	    struct {
 	    struct {
 		bool baseOnly;
 		bool baseOnly;
 		bool noSolidFinal;
 		bool noSolidFinal;
@@ -196,6 +207,8 @@ public:
 		glm::vec2 offset;
 		glm::vec2 offset;
 		/** Corner color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
 		/** Corner color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
 		glm::vec4 cornerColor;
 		glm::vec4 cornerColor;
+		/** Image filter, color options and flip, see --image-filter */
+		WallpaperEngine::Data::Model::ImageAdjustments imageAdjustments;
 	    } window;
 	    } window;
 
 
 	    struct {
 	    struct {
@@ -250,6 +263,7 @@ public:
             .screenZooms = {},
             .screenZooms = {},
             .screenOffsets = {},
             .screenOffsets = {},
             .screenCornerColors = {},
             .screenCornerColors = {},
+            .screenImageAdjustments = {},
             .screenPlaylists = {},
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
             .defaultPlaylist = std::nullopt,
             .spanGroups = {},
             .spanGroups = {},
@@ -267,6 +281,7 @@ public:
             .pauseOnFullscreen = true,
             .pauseOnFullscreen = true,
             .pauseOnFullscreenOnlyWhenActive = false,
             .pauseOnFullscreenOnlyWhenActive = false,
             .fullscreenPauseIgnoreAppIds = {},
             .fullscreenPauseIgnoreAppIds = {},
+            .hdr = false,
             .debug = {
             .debug = {
                 .baseOnly = false,
                 .baseOnly = false,
                 .noSolidFinal = false,
                 .noSolidFinal = false,
@@ -284,6 +299,7 @@ public:
                 .zoom = 1.0f,
                 .zoom = 1.0f,
                 .offset = { 0.0f, 0.0f },
                 .offset = { 0.0f, 0.0f },
                 .cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f },
                 .cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f },
+                .imageAdjustments = {},
             },
             },
             .wayland = {
             .wayland = {
                 .layer = WAYLAND_LAYER_BOTTOM,
                 .layer = WAYLAND_LAYER_BOTTOM,

+ 179 - 5
src/WallpaperEngine/Application/WallpaperApplication.cpp

@@ -310,13 +310,80 @@ WallpaperApplication::ProjectSource WallpaperApplication::openProjectSource (con
     return { std::move (baseContainer), std::move (baseJson), json.optional ("preset") };
     return { std::move (baseContainer), std::move (baseJson), json.optional ("preset") };
 }
 }
 
 
+namespace {
+std::optional<float> presetNumber (const WallpaperEngine::Data::JSON::JSON& preset, const char* key) {
+    const auto it = preset.find (key);
+
+    if (it == preset.end ()) {
+	return std::nullopt;
+    }
+
+    if (it->is_number ()) {
+	return it->get<float> ();
+    }
+
+    if (it->is_string ()) {
+	try {
+	    return std::stof (it->get<std::string> ());
+	} catch (const std::exception&) {
+	}
+    }
+
+    return std::nullopt;
+}
+
+std::optional<bool> presetBool (const WallpaperEngine::Data::JSON::JSON& preset, const char* key) {
+    const auto it = preset.find (key);
+
+    if (it == preset.end ()) {
+	return std::nullopt;
+    }
+
+    if (it->is_boolean ()) {
+	return it->get<bool> ();
+    }
+
+    if (it->is_number ()) {
+	return it->get<float> () != 0.0f;
+    }
+
+    if (it->is_string ()) {
+	return it->get<std::string> () == "true" || it->get<std::string> () == "1";
+    }
+
+    return std::nullopt;
+}
+
+/** Wallpaper Engine's own settings a preset can carry, see wallpaper64.exe sub_140181F30 */
+ImageAdjustments parseImageAdjustments (const WallpaperEngine::Data::JSON::JSON& preset) {
+    ImageAdjustments result {
+	.colorEnabled = presetBool (preset, "wec_e"),
+	.brightness = presetNumber (preset, "wec_brs"),
+	.contrast = presetNumber (preset, "wec_con"),
+	.saturation = presetNumber (preset, "wec_sa"),
+	.hue = presetNumber (preset, "wec_hue"),
+	.filter = std::nullopt,
+	.filterStrength = presetNumber (preset, "wcc_amt"),
+	.flipHorizontal = presetBool (preset, "alignmentfliph"),
+    };
+
+    if (const auto it = preset.find ("wcc_v"); it != preset.end () && it->is_string ()) {
+	result.filter = it->get<std::string> ();
+    }
+
+    return result;
+}
+} // namespace
+
 void WallpaperApplication::applyPreset (
 void WallpaperApplication::applyPreset (
-    const Project& project, const WallpaperEngine::Data::JSON::JSON& preset, const std::filesystem::path& presetDir
+    Project& project, const WallpaperEngine::Data::JSON::JSON& preset, const std::filesystem::path& presetDir
 ) {
 ) {
+    project.imageAdjustments = parseImageAdjustments (preset);
+
     for (const auto& entry : preset.items ()) {
     for (const auto& entry : preset.items ()) {
 	const auto property = project.properties.find (entry.key ());
 	const auto property = project.properties.find (entry.key ());
 
 
-	// wec_* and friends are Wallpaper Engine's own per-preset settings, not wallpaper properties
+	// wec_* and friends are Wallpaper Engine's own settings (read above), not wallpaper properties
 	if (property == project.properties.end () || entry.value ().is_null ()) {
 	if (property == project.properties.end () || entry.value ().is_null ()) {
 	    continue;
 	    continue;
 	}
 	}
@@ -594,6 +661,9 @@ void WallpaperApplication::advancePlaylist (
 		const auto offset = this->resolveScreenOffset (screen);
 		const auto offset = this->resolveScreenOffset (screen);
 		wallpaper->setOffset (offset.x, offset.y);
 		wallpaper->setOffset (offset.x, offset.y);
 		wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
 		wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+		wallpaper->setImageAdjustments (
+		    this->resolveScreenImageAdjustments (screen, *this->m_backgrounds[screen])
+		);
 		this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 		this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 		this->applyAudioPolicy ();
 		this->applyAudioPolicy ();
 	    }
 	    }
@@ -679,6 +749,9 @@ struct HotswapRequest {
     std::optional<std::string> expandCanvas;
     std::optional<std::string> expandCanvas;
     /** hex RGB/RGBA color, e.g. "000000" or "#1a1a1aff" */
     /** hex RGB/RGBA color, e.g. "000000" or "#1a1a1aff" */
     std::optional<std::string> cornerColor;
     std::optional<std::string> cornerColor;
+    /** image-filter/image-filter-strength/brightness/contrast/saturation/hue/color-options/flip, see --image-filter */
+    ImageAdjustments imageAdjustments;
+    bool imageAdjustmentsProvided = false;
     /** floating-point playback speed multiplier, e.g. "0.5" */
     /** floating-point playback speed multiplier, e.g. "0.5" */
     std::optional<std::string> speed;
     std::optional<std::string> speed;
     /** screen name to restrict audio to, or "" to clear the restriction, see --audio-screen */
     /** screen name to restrict audio to, or "" to clear the restriction, see --audio-screen */
@@ -711,7 +784,8 @@ std::string trimHotswapToken (const std::string& value) {
 /**
 /**
  * Parses the control file. Supports the original bare-path-on-one-line format for backwards
  * Parses the control file. Supports the original bare-path-on-one-line format for backwards
  * compatibility, plus key=value lines (path/layers/disable-object/enable-object/volume/xray/scaling/zoom/
  * compatibility, plus key=value lines (path/layers/disable-object/enable-object/volume/xray/scaling/zoom/
- * offset/disable-parallax/corner-color/speed/audio-screen/ambient-volume/property) so a single request can
+ * offset/disable-parallax/corner-color/image-filter/image-filter-strength/brightness/contrast/saturation/hue/
+ * color-options/flip/speed/audio-screen/ambient-volume/property) so a single request can
  * carry more than just the background path. "property=name=value" (repeatable) carries
  * carry more than just the background path. "property=name=value" (repeatable) carries
  * --set-property-equivalent overrides.
  * --set-property-equivalent overrides.
  */
  */
@@ -776,6 +850,45 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 	    request.expandCanvas = value;
 	    request.expandCanvas = value;
 	} else if (key == "corner-color") {
 	} else if (key == "corner-color") {
 	    request.cornerColor = value;
 	    request.cornerColor = value;
+	} else if (key == "image-filter") {
+	    request.imageAdjustments.filter = value == "none" ? "" : value;
+	    request.imageAdjustmentsProvided = true;
+	} else if (key == "color-options" || key == "flip") {
+	    const bool enable = value == "on" || value == "1" || value == "true";
+
+	    if (!enable && value != "off" && value != "0" && value != "false") {
+		sLog.error ("Hotswap: ignoring invalid ", key, " value: ", value);
+	    } else {
+		(key == "flip" ? request.imageAdjustments.flipHorizontal : request.imageAdjustments.colorEnabled)
+		    = enable;
+		request.imageAdjustmentsProvided = true;
+	    }
+	} else if (key == "image-filter-strength" || key == "brightness" || key == "contrast" || key == "saturation"
+		   || key == "hue") {
+	    float amount;
+
+	    try {
+		amount = std::clamp (std::stof (value), 0.0f, 100.0f);
+	    } catch (const std::exception&) {
+		sLog.error ("Hotswap: ignoring invalid ", key, " value: ", value);
+		continue;
+	    }
+
+	    auto& adjustments = request.imageAdjustments;
+
+	    if (key == "image-filter-strength") {
+		adjustments.filterStrength = amount;
+	    } else if (key == "brightness") {
+		adjustments.brightness = amount;
+	    } else if (key == "contrast") {
+		adjustments.contrast = amount;
+	    } else if (key == "saturation") {
+		adjustments.saturation = amount;
+	    } else {
+		adjustments.hue = amount;
+	    }
+
+	    request.imageAdjustmentsProvided = true;
 	} else if (key == "speed") {
 	} else if (key == "speed") {
 	    request.speed = value;
 	    request.speed = value;
 	} else if (key == "audio-screen") {
 	} else if (key == "audio-screen") {
@@ -840,7 +953,7 @@ void WallpaperApplication::checkHotswapRequest () {
     if (!request.path.has_value () && !request.layersProvided && !request.volume.has_value ()
     if (!request.path.has_value () && !request.layersProvided && !request.volume.has_value ()
 	&& !request.xray.has_value () && !request.scaling.has_value () && !request.zoom.has_value ()
 	&& !request.xray.has_value () && !request.scaling.has_value () && !request.zoom.has_value ()
 	&& !request.offset.has_value () && !request.disableParallax.has_value () && !request.expandCanvas.has_value ()
 	&& !request.offset.has_value () && !request.disableParallax.has_value () && !request.expandCanvas.has_value ()
-	&& !request.cornerColor.has_value ()
+	&& !request.cornerColor.has_value () && !request.imageAdjustmentsProvided
 	&& !request.speed.has_value () && !request.audioScreen.has_value () && !request.ambientVolume.has_value ()
 	&& !request.speed.has_value () && !request.audioScreen.has_value () && !request.ambientVolume.has_value ()
 	&& !request.propertiesProvided && !request.audioSensitivityProvided && !request.soundVolumeProvided) {
 	&& !request.propertiesProvided && !request.audioSensitivityProvided && !request.soundVolumeProvided) {
 	sLog.error ("Hotswap requested but control file was empty");
 	sLog.error ("Hotswap requested but control file was empty");
@@ -860,6 +973,14 @@ void WallpaperApplication::checkHotswapRequest () {
 	general.properties.clear ();
 	general.properties.clear ();
 	general.audioSensitivity.clear ();
 	general.audioSensitivity.clear ();
 	general.soundVolume.clear ();
 	general.soundVolume.clear ();
+
+	// Wallpaper Engine keeps these per wallpaper too
+	this->m_context.settings.render.window.imageAdjustments = {};
+	general.screenImageAdjustments.clear ();
+
+	for (auto& spanGroup : general.spanGroups) {
+	    spanGroup.imageAdjustments = {};
+	}
     }
     }
 
 
     if (request.volume.has_value ()) {
     if (request.volume.has_value ()) {
@@ -894,6 +1015,10 @@ void WallpaperApplication::checkHotswapRequest () {
 	this->applyCornerColorHotswap (*request.cornerColor);
 	this->applyCornerColorHotswap (*request.cornerColor);
     }
     }
 
 
+    if (request.imageAdjustmentsProvided) {
+	this->applyImageAdjustmentsHotswap (request.imageAdjustments);
+    }
+
     if (request.speed.has_value ()) {
     if (request.speed.has_value ()) {
 	this->applySpeedHotswap (*request.speed);
 	this->applySpeedHotswap (*request.speed);
     }
     }
@@ -1003,6 +1128,7 @@ void WallpaperApplication::checkHotswapRequest () {
 		    const auto offset = this->resolveScreenOffset (screen);
 		    const auto offset = this->resolveScreenOffset (screen);
 		    wallpaper->setOffset (offset.x, offset.y);
 		    wallpaper->setOffset (offset.x, offset.y);
 		    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
 		    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+		    wallpaper->setImageAdjustments (this->resolveScreenImageAdjustments (screen, *background));
 		    this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 		    this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 		}
 		}
 	    } catch (...) {
 	    } catch (...) {
@@ -1083,6 +1209,28 @@ glm::vec4 WallpaperApplication::resolveScreenCornerColor (const std::string& scr
 	: this->m_context.settings.render.window.cornerColor;
 	: this->m_context.settings.render.window.cornerColor;
 }
 }
 
 
+ImageAdjustments
+WallpaperApplication::resolveScreenImageAdjustments (const std::string& screen, const Project& project) const {
+    const auto fallback = this->m_context.settings.render.window.imageAdjustments.over (project.imageAdjustments);
+
+    // span groups render under every member screen's name and are loaded as "span:<first screen>"
+    for (const auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	if (spanGroup.screens.empty ()) {
+	    continue;
+	}
+
+	if ("span:" + spanGroup.screens.front () == screen
+	    || std::find (spanGroup.screens.begin (), spanGroup.screens.end (), screen) != spanGroup.screens.end ()) {
+	    return spanGroup.imageAdjustments.over (fallback);
+	}
+    }
+
+    const auto it = this->m_context.settings.general.screenImageAdjustments.find (screen);
+
+    return it != this->m_context.settings.general.screenImageAdjustments.end () ? it->second.over (fallback)
+										: fallback;
+}
+
 glm::ivec2 WallpaperApplication::resolveScreenRenderSize (const std::string& screen) const {
 glm::ivec2 WallpaperApplication::resolveScreenRenderSize (const std::string& screen) const {
     const auto& viewports = this->m_renderContext->getOutput ().getViewports ();
     const auto& viewports = this->m_renderContext->getOutput ().getViewports ();
     const auto it = viewports.find (screen);
     const auto it = viewports.find (screen);
@@ -1281,6 +1429,30 @@ void WallpaperApplication::applyCornerColorHotswap (const std::string& value) {
     sLog.out ("Hotswap: applied corner color ", value, " live");
     sLog.out ("Hotswap: applied corner color ", value, " live");
 }
 }
 
 
+void WallpaperApplication::applyImageAdjustmentsHotswap (const ImageAdjustments& request) {
+    auto& window = this->m_context.settings.render.window.imageAdjustments;
+
+    window = request.over (window);
+
+    for (auto& [screen, adjustments] : this->m_context.settings.general.screenImageAdjustments) {
+	adjustments = request.over (adjustments);
+    }
+
+    for (auto& spanGroup : this->m_context.settings.general.spanGroups) {
+	spanGroup.imageAdjustments = request.over (spanGroup.imageAdjustments);
+    }
+
+    if (this->m_renderContext) {
+	for (const auto& [screen, wallpaper] : this->m_renderContext->getWallpapers ()) {
+	    wallpaper->setImageAdjustments (
+		this->resolveScreenImageAdjustments (screen, wallpaper->getWallpaperData ().project)
+	    );
+	}
+    }
+
+    sLog.out ("Hotswap: applied image filter and color options live");
+}
+
 void WallpaperApplication::applySpeedHotswap (const std::string& value) {
 void WallpaperApplication::applySpeedHotswap (const std::string& value) {
     float speed;
     float speed;
 
 
@@ -1853,7 +2025,7 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 
 
 	// bind the wallpaper's FBO to read from it directly
 	// bind the wallpaper's FBO to read from it directly
 	// this is more reliable than the default framebuffer on some drivers (NVIDIA/Wayland)
 	// this is more reliable than the default framebuffer on some drivers (NVIDIA/Wayland)
-	glBindFramebuffer (GL_FRAMEBUFFER, wallpaper->getWallpaperFramebuffer ());
+	glBindFramebuffer (GL_FRAMEBUFFER, wallpaper->renderAdjustedFramebuffer ());
 
 
 	glFinish ();
 	glFinish ();
 
 
@@ -2029,6 +2201,7 @@ void WallpaperApplication::prepareOutputs () {
 	const auto offset = this->resolveScreenOffset (background);
 	const auto offset = this->resolveScreenOffset (background);
 	wallpaper->setOffset (offset.x, offset.y);
 	wallpaper->setOffset (offset.x, offset.y);
 	wallpaper->setCornerColor (this->resolveScreenCornerColor (background));
 	wallpaper->setCornerColor (this->resolveScreenCornerColor (background));
+	wallpaper->setImageAdjustments (this->resolveScreenImageAdjustments (background, *info));
 	m_renderContext->setWallpaper (background, std::move (wallpaper));
 	m_renderContext->setWallpaper (background, std::move (wallpaper));
     }
     }
 
 
@@ -2090,6 +2263,7 @@ void WallpaperApplication::prepareOutputs () {
 	sharedWallpaper->setZoom (spanGroup.zoom);
 	sharedWallpaper->setZoom (spanGroup.zoom);
 	sharedWallpaper->setOffset (spanGroup.offset.x, spanGroup.offset.y);
 	sharedWallpaper->setOffset (spanGroup.offset.x, spanGroup.offset.y);
 	sharedWallpaper->setCornerColor (spanGroup.cornerColor);
 	sharedWallpaper->setCornerColor (spanGroup.cornerColor);
+	sharedWallpaper->setImageAdjustments (this->resolveScreenImageAdjustments (groupKey, *bgIt->second));
 
 
 	// Convert to shared_ptr so it can be registered for multiple viewports
 	// Convert to shared_ptr so it can be registered for multiple viewports
 	std::shared_ptr<WallpaperEngine::Render::CWallpaper> shared (std::move (sharedWallpaper));
 	std::shared_ptr<WallpaperEngine::Render::CWallpaper> shared (std::move (sharedWallpaper));

+ 5 - 1
src/WallpaperEngine/Application/WallpaperApplication.h

@@ -68,7 +68,7 @@ private:
     AssetLocatorUniquePtr setupAssetLocator (const std::string& bg, const std::filesystem::path& overlay = {}) const;
     AssetLocatorUniquePtr setupAssetLocator (const std::string& bg, const std::filesystem::path& overlay = {}) const;
     ProjectSource openProjectSource (const std::string& path) const;
     ProjectSource openProjectSource (const std::string& path) const;
     static void applyPreset (
     static void applyPreset (
-	const Project& project, const WallpaperEngine::Data::JSON::JSON& preset, const std::filesystem::path& presetDir
+	Project& project, const WallpaperEngine::Data::JSON::JSON& preset, const std::filesystem::path& presetDir
     );
     );
     void initializeSubsystems ();
     void initializeSubsystems ();
     void loadBackgrounds ();
     void loadBackgrounds ();
@@ -167,6 +167,8 @@ private:
      * currently rendered wallpaper. Only visible where clamp mode is border.
      * currently rendered wallpaper. Only visible where clamp mode is border.
      */
      */
     void applyCornerColorHotswap (const std::string& value);
     void applyCornerColorHotswap (const std::string& value);
+    /** Merges the fields set in the request into every screen's image settings and applies them live */
+    void applyImageAdjustmentsHotswap (const ImageAdjustments& request);
 
 
     /**
     /**
      * Pushes a playback speed multiplier (e.g. "0.5") live. Straight passthrough to
      * Pushes a playback speed multiplier (e.g. "0.5") live. Straight passthrough to
@@ -208,6 +210,8 @@ private:
     [[nodiscard]] float resolveScreenZoom (const std::string& screen) const;
     [[nodiscard]] float resolveScreenZoom (const std::string& screen) const;
     [[nodiscard]] glm::vec2 resolveScreenOffset (const std::string& screen) const;
     [[nodiscard]] glm::vec2 resolveScreenOffset (const std::string& screen) const;
     [[nodiscard]] glm::vec4 resolveScreenCornerColor (const std::string& screen) const;
     [[nodiscard]] glm::vec4 resolveScreenCornerColor (const std::string& screen) const;
+    /** Command line and control file settings for this screen (or span group) over what the preset carries */
+    [[nodiscard]] ImageAdjustments resolveScreenImageAdjustments (const std::string& screen, const Project& project) const;
     // The resolution a Web wallpaper on this screen will actually be rendered at - falls back to
     // The resolution a Web wallpaper on this screen will actually be rendered at - falls back to
     // the combined bounding box of every active screen if this one isn't registered yet.
     // the combined bounding box of every active screen if this one isn't registered yet.
     [[nodiscard]] glm::ivec2 resolveScreenRenderSize (const std::string& screen) const;
     [[nodiscard]] glm::ivec2 resolveScreenRenderSize (const std::string& screen) const;

+ 7 - 1
src/WallpaperEngine/Data/Assets/Texture.h

@@ -92,14 +92,18 @@ enum TextureFlags {
     TextureFlags_IsGif = 4,
     TextureFlags_IsGif = 4,
     TextureFlags_ClampUVsBorder = 8,
     TextureFlags_ClampUVsBorder = 8,
     TextureFlags_Video = 32,
     TextureFlags_Video = 32,
+    /** Volume texture (image filter LUTs), every mipmap carries a depth after its width and height */
+    TextureFlags_Volume = 64,
     TextureFlags_AlphaChannelPriority = 524288, // Indicates RG88/R8 format where alpha is in G/R channel
     TextureFlags_AlphaChannelPriority = 524288, // Indicates RG88/R8 format where alpha is in G/R channel
     TextureFlags_All = TextureFlags_NoInterpolation | TextureFlags_ClampUVs | TextureFlags_IsGif
     TextureFlags_All = TextureFlags_NoInterpolation | TextureFlags_ClampUVs | TextureFlags_IsGif
-	| TextureFlags_ClampUVsBorder | TextureFlags_Video | TextureFlags_AlphaChannelPriority,
+	| TextureFlags_ClampUVsBorder | TextureFlags_Video | TextureFlags_Volume | TextureFlags_AlphaChannelPriority,
 };
 };
 
 
 struct Mipmap {
 struct Mipmap {
     uint32_t width = 0;
     uint32_t width = 0;
     uint32_t height = 0;
     uint32_t height = 0;
+    /** Only set on volume textures */
+    uint32_t depth = 1;
     /** Whether the mipmap data is compressed */
     /** Whether the mipmap data is compressed */
     uint32_t compression = 0;
     uint32_t compression = 0;
     int uncompressedSize = 0;
     int uncompressedSize = 0;
@@ -136,6 +140,8 @@ struct Texture {
     /** Texture size in memory (power of 2), as opposed to real width/height above */
     /** Texture size in memory (power of 2), as opposed to real width/height above */
     uint32_t textureWidth = 0;
     uint32_t textureWidth = 0;
     uint32_t textureHeight = 0;
     uint32_t textureHeight = 0;
+    /** Only set on volume textures */
+    uint32_t depth = 1;
     uint32_t gifWidth = 0;
     uint32_t gifWidth = 0;
     uint32_t gifHeight = 0;
     uint32_t gifHeight = 0;
     TextureFormat format = TextureFormat_UNKNOWN;
     TextureFormat format = TextureFormat_UNKNOWN;

+ 43 - 0
src/WallpaperEngine/Data/Model/ImageAdjustments.h

@@ -0,0 +1,43 @@
+#pragma once
+
+#include <optional>
+#include <string>
+
+namespace WallpaperEngine::Data::Model {
+/**
+ * Wallpaper Engine's own per-wallpaper image settings from its browser's properties panel, in its slider units:
+ * brightness, contrast, saturation and hue go 0-100 with 50 as neutral, filter strength goes 0-100.
+ * Unset fields fall through to the next source (command line, then preset, then Wallpaper Engine's defaults).
+ */
+struct ImageAdjustments {
+    /** wec_e, the color options only apply while this is on */
+    std::optional<bool> colorEnabled;
+    /** wec_brs */
+    std::optional<float> brightness;
+    /** wec_con */
+    std::optional<float> contrast;
+    /** wec_sa */
+    std::optional<float> saturation;
+    /** wec_hue */
+    std::optional<float> hue;
+    /** wcc_v, a lut from materials/lut without the extension, empty for none */
+    std::optional<std::string> filter;
+    /** wcc_amt */
+    std::optional<float> filterStrength;
+    /** alignmentfliph */
+    std::optional<bool> flipHorizontal;
+
+    [[nodiscard]] ImageAdjustments over (const ImageAdjustments& fallback) const {
+	return {
+	    .colorEnabled = colorEnabled.has_value () ? colorEnabled : fallback.colorEnabled,
+	    .brightness = brightness.has_value () ? brightness : fallback.brightness,
+	    .contrast = contrast.has_value () ? contrast : fallback.contrast,
+	    .saturation = saturation.has_value () ? saturation : fallback.saturation,
+	    .hue = hue.has_value () ? hue : fallback.hue,
+	    .filter = filter.has_value () ? filter : fallback.filter,
+	    .filterStrength = filterStrength.has_value () ? filterStrength : fallback.filterStrength,
+	    .flipHorizontal = flipHorizontal.has_value () ? flipHorizontal : fallback.flipHorizontal,
+	};
+    }
+};
+} // namespace WallpaperEngine::Data::Model

+ 429 - 17
src/WallpaperEngine/Data/Model/Object.h

@@ -1,5 +1,6 @@
 #pragma once
 #pragma once
 
 
+#include <array>
 #include <map>
 #include <map>
 #include <optional>
 #include <optional>
 #include <string>
 #include <string>
@@ -12,6 +13,7 @@
 #include "Effect.h"
 #include "Effect.h"
 #include "Material.h"
 #include "Material.h"
 #include "Model.h"
 #include "Model.h"
+#include "PropertyAnimation.h"
 #include "Types.h"
 #include "Types.h"
 #include "UserSetting.h"
 #include "UserSetting.h"
 #include "WallpaperEngine/Data/Utils/TypeCaster.h"
 #include "WallpaperEngine/Data/Utils/TypeCaster.h"
@@ -36,6 +38,12 @@ struct ObjectData {
     UserSettingUniquePtr groupAngles;
     UserSettingUniquePtr groupAngles;
     UserSettingUniquePtr groupVisible;
     UserSettingUniquePtr groupVisible;
     UserSettingUniquePtr groupParallaxDepth;
     UserSettingUniquePtr groupParallaxDepth;
+    /** Cursor events skip the object entirely when off */
+    UserSettingUniquePtr solid;
+    /** A visible object hit by the cursor keeps the objects below it from getting cursor events */
+    UserSettingUniquePtr disablePropagation;
+    /** Drawn with a perspective camera in 2D scenes (general.perspectiveoverridefov), only the object's own flag counts */
+    UserSettingUniquePtr perspective;
 };
 };
 
 
 class Object : public TypeCaster, public ObjectData {
 class Object : public TypeCaster, public ObjectData {
@@ -95,6 +103,8 @@ struct ImageData {
     UserSettingUniquePtr brightness;
     UserSettingUniquePtr brightness;
     /** Forces UV clamping on this object's composite buffers regardless of the base texture's own flags */
     /** Forces UV clamping on this object's composite buffers regardless of the base texture's own flags */
     bool clampUVs;
     bool clampUVs;
+    /** Passthrough layers only: start from a copy of the scene behind them, otherwise from a transparent buffer */
+    UserSettingUniquePtr copyBackground;
     ModelUniquePtr model;
     ModelUniquePtr model;
     /** Applied after the material is rendered */
     /** Applied after the material is rendered */
     std::vector<ImageEffectUniquePtr> effects;
     std::vector<ImageEffectUniquePtr> effects;
@@ -132,9 +142,14 @@ public:
  */
  */
 struct ParticleControlPoint {
 struct ParticleControlPoint {
     int id;
     int id;
+    /**
+     * 1 follows the cursor, 2 offset is in world space, 4 follows the parent system's control point parentControlPoint,
+     * 8 (with 4) copies that control point as it is instead of moving it into this system's space
+     */
     uint32_t flags;
     uint32_t flags;
     glm::vec3 offset;
     glm::vec3 offset;
     bool lockToPointer;
     bool lockToPointer;
+    int parentControlPoint;
 };
 };
 
 
 /**
 /**
@@ -147,7 +162,7 @@ struct ParticleEmitter {
     glm::vec3 distanceMin;
     glm::vec3 distanceMin;
     glm::vec3 distanceMax;
     glm::vec3 distanceMax;
     glm::vec3 origin;
     glm::vec3 origin;
-    glm::ivec3 sign;
+    glm::vec3 sign;
     uint32_t instantaneous;
     uint32_t instantaneous;
     float speedMin;
     float speedMin;
     float speedMax;
     float speedMax;
@@ -178,10 +193,11 @@ public:
 
 
 class ColorRandomInitializer : public ParticleInitializerBase {
 class ColorRandomInitializer : public ParticleInitializerBase {
 public:
 public:
-    ColorRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max) :
-	min (std::move (min)), max (std::move (max)) { }
+    ColorRandomInitializer (UserSettingUniquePtr min, UserSettingUniquePtr max, UserSettingUniquePtr exponent) :
+	min (std::move (min)), max (std::move (max)), exponent (std::move (exponent)) { }
     UserSettingUniquePtr min;
     UserSettingUniquePtr min;
     UserSettingUniquePtr max;
     UserSettingUniquePtr max;
+    UserSettingUniquePtr exponent;
 };
 };
 
 
 class SizeRandomInitializer : public ParticleInitializerBase {
 class SizeRandomInitializer : public ParticleInitializerBase {
@@ -283,6 +299,169 @@ public:
     UserSettingUniquePtr speedMax;
     UserSettingUniquePtr speedMax;
 };
 };
 
 
+/** What inheritvaluefromevent/inheritinitialvaluefromevent take from the event's particle (WE's order) */
+enum class ParticleEventInput {
+    SetColor,
+    MultiplyColor,
+    SetOpacity,
+    MultiplyOpacity,
+    SetColorOpacity,
+    MultiplyColorOpacity,
+    SetVelocity,
+    AddVelocity,
+    SetSize,
+    MultiplySize,
+    SetRotation,
+    AddRotation,
+    SetAngularVelocity,
+    AddAngularVelocity,
+};
+
+class InheritInitialValueFromEventInitializer : public ParticleInitializerBase {
+public:
+    explicit InheritInitialValueFromEventInitializer (ParticleEventInput input) : input (input) { }
+    ParticleEventInput input;
+};
+
+class InheritControlPointVelocityInitializer : public ParticleInitializerBase {
+public:
+    InheritControlPointVelocityInitializer (int controlPoint, UserSettingUniquePtr min, UserSettingUniquePtr max) :
+	controlPoint (controlPoint), min (std::move (min)), max (std::move (max)) { }
+    int controlPoint;
+    UserSettingUniquePtr min;
+    UserSettingUniquePtr max;
+};
+
+class HsvColorRandomInitializer : public ParticleInitializerBase {
+public:
+    HsvColorRandomInitializer (
+	UserSettingUniquePtr hueMin, UserSettingUniquePtr hueMax, int hueSteps, UserSettingUniquePtr saturationMin,
+	UserSettingUniquePtr saturationMax, UserSettingUniquePtr valueMin, UserSettingUniquePtr valueMax
+    ) :
+	hueMin (std::move (hueMin)), hueMax (std::move (hueMax)), hueSteps (hueSteps),
+	saturationMin (std::move (saturationMin)), saturationMax (std::move (saturationMax)),
+	valueMin (std::move (valueMin)), valueMax (std::move (valueMax)) { }
+    UserSettingUniquePtr hueMin;
+    UserSettingUniquePtr hueMax;
+    int hueSteps;
+    UserSettingUniquePtr saturationMin;
+    UserSettingUniquePtr saturationMax;
+    UserSettingUniquePtr valueMin;
+    UserSettingUniquePtr valueMax;
+};
+
+class ColorListInitializer : public ParticleInitializerBase {
+public:
+    ColorListInitializer (
+	std::vector<glm::vec3> colors, UserSettingUniquePtr hueNoise, UserSettingUniquePtr saturationNoise,
+	UserSettingUniquePtr valueNoise
+    ) :
+	colors (std::move (colors)), hueNoise (std::move (hueNoise)), saturationNoise (std::move (saturationNoise)),
+	valueNoise (std::move (valueNoise)) { }
+    /** RGB as written in the file, entries that don't parse as three numbers are left out like WE does */
+    std::vector<glm::vec3> colors;
+    UserSettingUniquePtr hueNoise;
+    UserSettingUniquePtr saturationNoise;
+    UserSettingUniquePtr valueNoise;
+};
+
+/** Settings whose default depends on the scene: null means the key wasn't in the file */
+class PositionOffsetRandomInitializer : public ParticleInitializerBase {
+public:
+    PositionOffsetRandomInitializer (
+	UserSettingUniquePtr directions, UserSettingUniquePtr sign, UserSettingUniquePtr scale,
+	UserSettingUniquePtr distance, UserSettingUniquePtr timeScale, int octaves
+    ) :
+	directions (std::move (directions)), sign (std::move (sign)), scale (std::move (scale)),
+	distance (std::move (distance)), timeScale (std::move (timeScale)), octaves (octaves) { }
+    UserSettingUniquePtr directions;
+    UserSettingUniquePtr sign;
+    UserSettingUniquePtr scale;
+    UserSettingUniquePtr distance;
+    UserSettingUniquePtr timeScale;
+    int octaves;
+};
+
+class MapSequenceBetweenControlPointsInitializer : public ParticleInitializerBase {
+public:
+    MapSequenceBetweenControlPointsInitializer (
+	float count, glm::vec2 bounds, bool mirror, int controlPointStart, int controlPointEnd, uint32_t flags,
+	UserSettingUniquePtr arcAmount, UserSettingUniquePtr arcDirection, UserSettingUniquePtr sizeReductionAmount
+    ) :
+	count (count), bounds (bounds), mirror (mirror), controlPointStart (controlPointStart),
+	controlPointEnd (controlPointEnd), flags (flags), arcAmount (std::move (arcAmount)),
+	arcDirection (std::move (arcDirection)), sizeReductionAmount (std::move (sizeReductionAmount)) { }
+    float count;
+    glm::vec2 bounds;
+    /** limitbehavior "mirror", anything else repeats */
+    bool mirror;
+    int controlPointStart;
+    int controlPointEnd;
+    /** 1 shrink the offset from the line, 2 slow down, 4 shrink, 8 arc, 0x10 count follows instanceoverride count */
+    uint32_t flags;
+    UserSettingUniquePtr arcAmount;
+    UserSettingUniquePtr arcDirection;
+    UserSettingUniquePtr sizeReductionAmount;
+};
+
+/** remapvalue/remapinitialvalue values, WE's enum order */
+enum class ParticleRemapValue {
+    LifetimeFraction,
+    MaxLifetime,
+    Size,
+    Opacity,
+    Speed,
+    Rotation,
+    AngularSpeed,
+    DistanceToControlPoint,
+    PositionBetweenTwoControlPoints,
+    Runtime,
+    TimeOfDay,
+    ParticleSystemTime,
+    LayerTime,
+    Color,
+    Position,
+    Velocity,
+    ControlPoint,
+    DeltaToControlPoint,
+    DirectionToControlPoint,
+    LayerOrigin,
+    Unknown,
+};
+
+enum class ParticleRemapOperation { Remap, Multiply, Add, Subtract, Unknown };
+
+enum class ParticleRemapComponent { All, X, Y, Z, Sum, Average, Max, Min, Unknown };
+
+enum class ParticleRemapTransform { Linear, Sine, Square, Saw, Triangle, SimplexNoise, FbmNoise, Unknown };
+
+struct ParticleRemap {
+    ParticleRemapOperation operation;
+    ParticleRemapValue input;
+    ParticleRemapValue output;
+    ParticleRemapComponent inputComponent;
+    ParticleRemapComponent outputComponent;
+    ParticleRemapTransform transform;
+    /** 1 clamp the input to 0..1, 2 clamp the output to 0..1 */
+    uint32_t flags;
+    glm::vec3 inputRangeMin;
+    glm::vec3 inputRangeMax;
+    glm::vec3 outputRangeMin;
+    glm::vec3 outputRangeMax;
+    int inputControlPoint0;
+    int inputControlPoint1;
+    int outputControlPoint0;
+    int outputControlPoint1;
+    float transformInputScale;
+    int transformOctaves;
+};
+
+class RemapInitialValueInitializer : public ParticleInitializerBase {
+public:
+    explicit RemapInitialValueInitializer (ParticleRemap remap) : remap (remap) { }
+    ParticleRemap remap;
+};
+
 using ParticleInitializerUniquePtr = std::unique_ptr<ParticleInitializerBase>;
 using ParticleInitializerUniquePtr = std::unique_ptr<ParticleInitializerBase>;
 
 
 /**
 /**
@@ -295,10 +474,12 @@ public:
 
 
 class MovementOperator : public ParticleOperatorBase {
 class MovementOperator : public ParticleOperatorBase {
 public:
 public:
-    MovementOperator (UserSettingUniquePtr drag, UserSettingUniquePtr gravity) :
-	drag (std::move (drag)), gravity (std::move (gravity)) { }
+    MovementOperator (UserSettingUniquePtr drag, UserSettingUniquePtr gravity, uint32_t flags) :
+	drag (std::move (drag)), gravity (std::move (gravity)), flags (flags) { }
     UserSettingUniquePtr drag;
     UserSettingUniquePtr drag;
     UserSettingUniquePtr gravity;
     UserSettingUniquePtr gravity;
+    /** bit 0: gravity is turned by the system's frame (local systems only) */
+    uint32_t flags;
 };
 };
 
 
 class AngularMovementOperator : public ParticleOperatorBase {
 class AngularMovementOperator : public ParticleOperatorBase {
@@ -430,6 +611,14 @@ public:
     UserSettingUniquePtr audioProcessingFrequencyEnd;
     UserSettingUniquePtr audioProcessingFrequencyEnd;
 };
 };
 
 
+class InheritValueFromEventOperator : public ParticleOperatorBase {
+public:
+    InheritValueFromEventOperator (ParticleEventInput input, glm::vec4 blend) : input (input), blend (blend) { }
+    ParticleEventInput input;
+    /** blendinstart, blendinend, blendoutstart, blendoutend over the particle's life */
+    glm::vec4 blend;
+};
+
 class ControlPointAttractOperator : public ParticleOperatorBase {
 class ControlPointAttractOperator : public ParticleOperatorBase {
 public:
 public:
     ControlPointAttractOperator (
     ControlPointAttractOperator (
@@ -496,6 +685,121 @@ public:
     UserSettingUniquePtr mask;
     UserSettingUniquePtr mask;
 };
 };
 
 
+/** blendinstart, blendinend, blendoutstart, blendoutend: how much of an operator applies over the particle's life */
+struct ParticleBlendWindow {
+    float inStart;
+    float inEnd;
+    float outStart;
+    float outEnd;
+};
+
+class CapVelocityOperator : public ParticleOperatorBase {
+public:
+    CapVelocityOperator (UserSettingUniquePtr maxSpeed, ParticleBlendWindow blend) :
+	maxSpeed (std::move (maxSpeed)), blend (blend) { }
+    UserSettingUniquePtr maxSpeed;
+    ParticleBlendWindow blend;
+};
+
+class BoidsOperator : public ParticleOperatorBase {
+public:
+    BoidsOperator (
+	UserSettingUniquePtr separationThreshold, UserSettingUniquePtr neighborThreshold, UserSettingUniquePtr maxSpeed,
+	UserSettingUniquePtr separationFactor, UserSettingUniquePtr alignmentFactor,
+	UserSettingUniquePtr cohesionFactor, uint32_t flags
+    ) :
+	separationThreshold (std::move (separationThreshold)), neighborThreshold (std::move (neighborThreshold)),
+	maxSpeed (std::move (maxSpeed)), separationFactor (std::move (separationFactor)),
+	alignmentFactor (std::move (alignmentFactor)), cohesionFactor (std::move (cohesionFactor)), flags (flags) { }
+    UserSettingUniquePtr separationThreshold;
+    UserSettingUniquePtr neighborThreshold;
+    UserSettingUniquePtr maxSpeed;
+    UserSettingUniquePtr separationFactor;
+    UserSettingUniquePtr alignmentFactor;
+    UserSettingUniquePtr cohesionFactor;
+    /** 1 cap the speed at maxspeed */
+    uint32_t flags;
+};
+
+class RemapValueOperator : public ParticleOperatorBase {
+public:
+    RemapValueOperator (ParticleRemap remap, ParticleBlendWindow blend) : remap (remap), blend (blend) { }
+    ParticleRemap remap;
+    ParticleBlendWindow blend;
+};
+
+class MaintainDistanceToControlPointOperator : public ParticleOperatorBase {
+public:
+    MaintainDistanceToControlPointOperator (
+	int controlPoint, UserSettingUniquePtr distance, UserSettingUniquePtr variableStrength,
+	ParticleBlendWindow blend
+    ) :
+	controlPoint (controlPoint), distance (std::move (distance)), variableStrength (std::move (variableStrength)),
+	blend (blend) { }
+    int controlPoint;
+    UserSettingUniquePtr distance;
+    UserSettingUniquePtr variableStrength;
+    ParticleBlendWindow blend;
+};
+
+class MaintainDistanceBetweenControlPointsOperator : public ParticleOperatorBase {
+public:
+    MaintainDistanceBetweenControlPointsOperator (
+	int controlPointStart, int controlPointEnd, ParticleBlendWindow blend
+    ) : controlPointStart (controlPointStart), controlPointEnd (controlPointEnd), blend (blend) { }
+    int controlPointStart;
+    int controlPointEnd;
+    ParticleBlendWindow blend;
+};
+
+class ReduceMovementNearControlPointOperator : public ParticleOperatorBase {
+public:
+    ReduceMovementNearControlPointOperator (
+	int controlPoint, UserSettingUniquePtr distanceInner, UserSettingUniquePtr distanceOuter,
+	UserSettingUniquePtr reductionInner, UserSettingUniquePtr reductionOuter, ParticleBlendWindow blend
+    ) :
+	controlPoint (controlPoint), distanceInner (std::move (distanceInner)),
+	distanceOuter (std::move (distanceOuter)), reductionInner (std::move (reductionInner)),
+	reductionOuter (std::move (reductionOuter)), blend (blend) { }
+    int controlPoint;
+    UserSettingUniquePtr distanceInner;
+    UserSettingUniquePtr distanceOuter;
+    UserSettingUniquePtr reductionInner;
+    UserSettingUniquePtr reductionOuter;
+    ParticleBlendWindow blend;
+};
+
+enum class ParticleCollisionShape { Plane, Sphere, Box, Bounds, Quad, Model };
+
+enum class ParticleCollisionBehavior { Bounce, Slide, Stop, Delete };
+
+class CollisionOperator : public ParticleOperatorBase {
+public:
+    CollisionOperator (
+	ParticleCollisionShape shape, ParticleCollisionBehavior behavior, UserSettingUniquePtr bounceFactor,
+	uint32_t flags, int controlPoint, UserSettingUniquePtr plane, UserSettingUniquePtr distance,
+	UserSettingUniquePtr origin, UserSettingUniquePtr radius, UserSettingUniquePtr forward,
+	UserSettingUniquePtr size
+    ) :
+	shape (shape), behavior (behavior), bounceFactor (std::move (bounceFactor)), flags (flags),
+	controlPoint (controlPoint), plane (std::move (plane)), distance (std::move (distance)),
+	origin (std::move (origin)), radius (std::move (radius)), forward (std::move (forward)),
+	size (std::move (size)) { }
+    ParticleCollisionShape shape;
+    ParticleCollisionBehavior behavior;
+    UserSettingUniquePtr bounceFactor;
+    /** 1 the shape moves with the control point, 2 colliding particles stop spinning */
+    uint32_t flags;
+    int controlPoint;
+    /** Shape settings, null when the file doesn't set them (some defaults depend on the scene) */
+    UserSettingUniquePtr plane;
+    UserSettingUniquePtr distance;
+    UserSettingUniquePtr origin;
+    UserSettingUniquePtr radius;
+    UserSettingUniquePtr forward;
+    UserSettingUniquePtr size;
+};
+
 using ParticleOperatorUniquePtr = std::unique_ptr<ParticleOperatorBase>;
 using ParticleOperatorUniquePtr = std::unique_ptr<ParticleOperatorBase>;
 
 
 /**
 /**
@@ -515,19 +819,35 @@ struct ParticleRenderer {
     bool fadeSize; // ropetrail: fade size along trail
     bool fadeSize; // ropetrail: fade size along trail
 };
 };
 
 
+enum class ParticleChildType {
+    /** Always running, placed relative to the parent system */
+    Static,
+    /** Spawned on a particle's birth and moved along with it until it dies */
+    EventFollow,
+    /** Spawned where a particle is born */
+    EventSpawn,
+    /** Spawned where a particle dies */
+    EventDeath,
+};
+
 /**
 /**
  * Child particle system
  * Child particle system
  */
  */
 struct ParticleChild {
 struct ParticleChild {
-    std::string type;
+    ParticleChildType type;
+    /** Particle file the child system is loaded from */
     std::string name;
     std::string name;
+    /** Most instances of an event child alive at once (static children ignore it) */
     int maxCount;
     int maxCount;
-    int controlPointStartIndex;
+    /** Chance of an event spawning an instance, rolled per event */
     float probability;
     float probability;
-    glm::vec3 angles;
-    glm::vec3 origin;
-    glm::vec3 scale;
-    std::string particleFile;
+    /** Bit 0: the parent's particles drive the child's control points, from controlPointStartIndex up */
+    uint32_t flags;
+    int controlPointStartIndex;
+    /** Placement relative to the spawning particle (static: the parent system), in the parent's particle space */
+    glm::mat4 transform;
+    /** null if the file couldn't be loaded */
+    ParticleUniquePtr particle;
 };
 };
 
 
 /**
 /**
@@ -543,6 +863,8 @@ struct ParticleInstanceOverride {
     UserSettingUniquePtr count;
     UserSettingUniquePtr count;
     UserSettingUniquePtr color; // Replaces particle color
     UserSettingUniquePtr color; // Replaces particle color
     UserSettingUniquePtr colorn; // Multiplies particle color
     UserSettingUniquePtr colorn; // Multiplies particle color
+    /** colorn (or the legacy color) was given, WE keeps -1 in it otherwise */
+    bool hasColor = false;
 };
 };
 
 
 struct ParticleData {
 struct ParticleData {
@@ -568,6 +890,19 @@ struct ParticleData {
     std::vector<ParticleRenderer> renderers;
     std::vector<ParticleRenderer> renderers;
     std::vector<ParticleControlPoint> controlPoints;
     std::vector<ParticleControlPoint> controlPoints;
     std::vector<ParticleChild> children;
     std::vector<ParticleChild> children;
+    /**
+     * Bit per control point slot that a remapvalue/remapinitialvalue writes (output "controlpoint",
+     * outputcontrolpoint0). WE's loader flags those 0x10000: the engine stops moving them itself
+     */
+    uint8_t remapOutputControlPoints = 0;
+    /**
+     * The color the particle file's own color initializer (or colorchange) centers on, and whether it has one.
+     * An instanceoverride color is compared against it (wallpaper64.exe sub_1401C45F0)
+     */
+    glm::vec3 colorReference = glm::vec3 (1.0f);
+    bool hasColor = false;
+    /** The override color multiplies the spawn color: no color initializer or a scene older than version 5 */
+    bool overrideColorTints = true;
 
 
     ParticleInstanceOverride instanceOverride;
     ParticleInstanceOverride instanceOverride;
 };
 };
@@ -600,12 +935,18 @@ struct TextData {
     UserSettingUniquePtr alpha;
     UserSettingUniquePtr alpha;
     UserSettingUniquePtr visible;
     UserSettingUniquePtr visible;
     UserSettingUniquePtr parallaxDepth;
     UserSettingUniquePtr parallaxDepth;
-    /** Horizontal alignment: "left", "center", "right" */
-    std::string alignment;
-    /** Vertical alignment: "top", "center", "bottom" */
-    std::string verticalalign;
-    /** Padding inside the bounding box (x = horizontal, y = vertical) */
-    glm::vec2 padding;
+    /** "left", "center", "right" */
+    UserSettingUniquePtr horizontalAlign;
+    /** "top", "center", "bottom" */
+    UserSettingUniquePtr verticalAlign;
+    /** Keeps the text at its distance from a visible screen edge or corner: "none", "center", "top", "topright", ... */
+    UserSettingUniquePtr anchor;
+    /** Image blend mode, the text is composited through effectpassthrough with BLENDMODE (31 is additive) */
+    UserSettingUniquePtr colorBlendMode;
+    /** Room around the text box for effects and the opaque background (x = horizontal, y = vertical) */
+    UserSettingUniquePtr padding;
+    /** Added to every glyph's advance (x) and to the line pitch (y), raster pixels */
+    UserSettingUniquePtr spacing;
     /** Applied after the glyphs are rendered */
     /** Applied after the glyphs are rendered */
     std::vector<ImageEffectUniquePtr> effects;
     std::vector<ImageEffectUniquePtr> effects;
     /** Wrap lines wider than maxWidth (layout pixels, before the object's scale) */
     /** Wrap lines wider than maxWidth (layout pixels, before the object's scale) */
@@ -615,6 +956,26 @@ struct TextData {
     UserSettingUniquePtr limitRows;
     UserSettingUniquePtr limitRows;
     UserSettingUniquePtr maxRows;
     UserSettingUniquePtr maxRows;
     UserSettingUniquePtr limitUseEllipsis;
     UserSettingUniquePtr limitUseEllipsis;
+    /** Justifies wrapped lines to maxWidth by widening their spaces */
+    UserSettingUniquePtr blockAlign;
+    UserSettingUniquePtr opaqueBackground;
+    UserSettingUniquePtr backgroundColor;
+    /** Only used by WE's HDR scene rendering */
+    UserSettingUniquePtr backgroundBrightness;
+    /** Scales the text color, only in HDR scene rendering */
+    UserSettingUniquePtr brightness;
+    /** Glyphs as multi-channel signed distance fields, forced on by any of the outline/blur/drop shadow effects */
+    UserSettingUniquePtr msdf;
+    UserSettingUniquePtr outline;
+    UserSettingUniquePtr outlineThickness;
+    UserSettingUniquePtr outlineColor;
+    UserSettingUniquePtr blur;
+    UserSettingUniquePtr blurSize;
+    UserSettingUniquePtr dropShadow;
+    UserSettingUniquePtr dropShadowSize;
+    UserSettingUniquePtr dropShadowOpacity;
+    UserSettingUniquePtr dropShadowOffset;
+    UserSettingUniquePtr dropShadowColor;
 };
 };
 
 
 class Text : public Object, public TextData {
 class Text : public Object, public TextData {
@@ -635,6 +996,11 @@ struct LightData {
     UserSettingUniquePtr intensity;
     UserSettingUniquePtr intensity;
     UserSettingUniquePtr radius;
     UserSettingUniquePtr radius;
     UserSettingUniquePtr visible;
     UserSettingUniquePtr visible;
+    /** Light volumes glowing in the air around the light (LightingV1 lights) */
+    bool castVolumetrics = false;
+    bool castShadow = false;
+    UserSettingUniquePtr density;
+    UserSettingUniquePtr volumetricsExponent;
 };
 };
 
 
 class Light : public Object, public LightData {
 class Light : public Object, public LightData {
@@ -643,4 +1009,50 @@ public:
 	Object (std::move (data)), LightData (std::move (lightData)) { };
 	Object (std::move (data)), LightData (std::move (lightData)) { };
     ~Light () override = default;
     ~Light () override = default;
 };
 };
+/**
+ * One entry of a camera object's "path" file: keyframed eye/center/up (per component, missing ones keep the
+ * camera's own transform), zoom (2D scenes) and fov (3D scenes), played like a property animation
+ */
+struct CameraTimeline {
+    std::string name;
+    bool visible = true;
+    float fps = 30.0f;
+    /** Whole frames */
+    float length = 0.0f;
+    PropertyAnimation::Mode mode = PropertyAnimation::Mode::Single;
+    bool startPaused = false;
+    std::array<std::vector<AnimationKeyframe>, 3> center;
+    std::array<std::vector<AnimationKeyframe>, 3> eye;
+    std::array<std::vector<AnimationKeyframe>, 3> up;
+    std::vector<AnimationKeyframe> zoom;
+    std::vector<AnimationKeyframe> fov;
+};
+
+enum class CameraQueueMode { Random = 0, Sequential = 1 };
+
+/** A "camera" object, the last visible one drives the scene's view (fov only in 3D scenes) */
+struct SceneCameraData {
+    UserSettingUniquePtr fov;
+    UserSettingUniquePtr zoom;
+    std::vector<CameraTimeline> timelines;
+    CameraQueueMode queueMode = CameraQueueMode::Random;
+};
+
+class SceneCamera : public Object, public SceneCameraData {
+public:
+    explicit SceneCamera (ObjectData data, SceneCameraData cameraData) noexcept :
+	Object (std::move (data)), SceneCameraData (std::move (cameraData)) { };
+    ~SceneCamera () override = default;
+};
+/** A 3D model object ("model" pointing at a .mdl), placed with the base object's origin/scale/angles */
+struct MeshData {
+    std::string model;
+};
+
+class Mesh : public Object, public MeshData {
+public:
+    explicit Mesh (ObjectData data, MeshData meshData) noexcept :
+	Object (std::move (data)), MeshData (std::move (meshData)) { };
+    ~Mesh () override = default;
+};
 } // namespace WallpaperEngine::Data::Model
 } // namespace WallpaperEngine::Data::Model

+ 5 - 0
src/WallpaperEngine/Data/Model/Project.h

@@ -3,6 +3,7 @@
 #include <memory>
 #include <memory>
 #include <string>
 #include <string>
 
 
+#include "ImageAdjustments.h"
 #include "Types.h"
 #include "Types.h"
 #include "WallpaperEngine/Assets/AssetLocator.h"
 #include "WallpaperEngine/Assets/AssetLocator.h"
 
 
@@ -20,5 +21,9 @@ struct Project {
     Properties properties;
     Properties properties;
     WallpaperUniquePtr wallpaper;
     WallpaperUniquePtr wallpaper;
     AssetLocatorUniquePtr assetLocator;
     AssetLocatorUniquePtr assetLocator;
+    /** Image filter, color options and flip a preset carries (wcc_*, wec_*, alignmentfliph) */
+    ImageAdjustments imageAdjustments;
+    /** scene.json "version", 0 when missing */
+    int sceneVersion = 0;
 };
 };
 };
 };

+ 3 - 0
src/WallpaperEngine/Data/Model/PropertyAnimation.h

@@ -13,10 +13,13 @@ struct AnimationKeyframe {
 	float y = 0.0f;
 	float y = 0.0f;
     };
     };
 
 
+    /** Whole frames, WE reads them as integers */
     float frame = 0.0f;
     float frame = 0.0f;
     float value = 0.0f;
     float value = 0.0f;
     Handle back;
     Handle back;
     Handle front;
     Handle front;
+    /** Holds the previous key's value up to this one */
+    bool step = false;
 };
 };
 
 
 struct AnimationEvent {
 struct AnimationEvent {

+ 4 - 0
src/WallpaperEngine/Data/Model/Types.h

@@ -21,6 +21,8 @@ struct ImageEffectPassOverride;
 class Particle;
 class Particle;
 class Text;
 class Text;
 class Light;
 class Light;
+class SceneCamera;
+class Mesh;
 class DynamicValue;
 class DynamicValue;
 class Property;
 class Property;
 class PropertySlider;
 class PropertySlider;
@@ -53,6 +55,8 @@ using ImageUniquePtr = std::unique_ptr<Image>;
 using ParticleUniquePtr = std::unique_ptr<Particle>;
 using ParticleUniquePtr = std::unique_ptr<Particle>;
 using TextUniquePtr = std::unique_ptr<Text>;
 using TextUniquePtr = std::unique_ptr<Text>;
 using LightUniquePtr = std::unique_ptr<Light>;
 using LightUniquePtr = std::unique_ptr<Light>;
+using SceneCameraUniquePtr = std::unique_ptr<SceneCamera>;
+using MeshUniquePtr = std::unique_ptr<Mesh>;
 using MaterialUniquePtr = std::unique_ptr<Material>;
 using MaterialUniquePtr = std::unique_ptr<Material>;
 using MaterialPassUniquePtr = std::unique_ptr<MaterialPass>;
 using MaterialPassUniquePtr = std::unique_ptr<MaterialPass>;
 using EffectUniquePtr = std::unique_ptr<Effect>;
 using EffectUniquePtr = std::unique_ptr<Effect>;

+ 45 - 0
src/WallpaperEngine/Data/Model/Wallpaper.h

@@ -37,12 +37,42 @@ public:
     ~Web () override = default;
     ~Web () override = default;
 };
 };
 
 
+/** A key of a scene camera path: where the camera is at time seconds into the path */
+struct CameraPathKey {
+    float time;
+    glm::vec3 eye;
+    glm::vec3 center;
+    glm::vec3 up;
+    float zoom;
+};
+
+/** One path of the files listed in scene.json's "camera" "paths", played one after another */
+struct CameraPath {
+    std::vector<CameraPathKey> keys;
+    float duration;
+};
+
 struct SceneData {
 struct SceneData {
     struct {
     struct {
 	UserSettingUniquePtr ambient;
 	UserSettingUniquePtr ambient;
 	UserSettingUniquePtr skylight;
 	UserSettingUniquePtr skylight;
 	UserSettingUniquePtr clear;
 	UserSettingUniquePtr clear;
     } colors;
     } colors;
+    /** Distance and height fog ("fogdistance*"/"fogheight*" in general) */
+    struct {
+	UserSettingUniquePtr distanceEnabled;
+	UserSettingUniquePtr distanceColor;
+	UserSettingUniquePtr distanceStart;
+	UserSettingUniquePtr distanceEnd;
+	UserSettingUniquePtr distanceStartDensity;
+	UserSettingUniquePtr distanceEndDensity;
+	UserSettingUniquePtr heightEnabled;
+	UserSettingUniquePtr heightColor;
+	UserSettingUniquePtr heightStart;
+	UserSettingUniquePtr heightEnd;
+	UserSettingUniquePtr heightStartDensity;
+	UserSettingUniquePtr heightEndDensity;
+    } fog;
     struct Camera {
     struct Camera {
 	UserSettingUniquePtr fade;
 	UserSettingUniquePtr fade;
 	/** Whether the software's preview UI is allowed to show this background */
 	/** Whether the software's preview UI is allowed to show this background */
@@ -52,6 +82,14 @@ struct SceneData {
 	    UserSettingUniquePtr enabled;
 	    UserSettingUniquePtr enabled;
 	    UserSettingUniquePtr strength;
 	    UserSettingUniquePtr strength;
 	    UserSettingUniquePtr threshold;
 	    UserSettingUniquePtr threshold;
+	    /** "hdr": HDR rendering and bloom, only with WE's ultra post processing */
+	    UserSettingUniquePtr hdr;
+	    UserSettingUniquePtr hdrStrength;
+	    UserSettingUniquePtr hdrThreshold;
+	    UserSettingUniquePtr hdrFeather;
+	    UserSettingUniquePtr hdrScatter;
+	    UserSettingUniquePtr hdrIterations;
+	    UserSettingUniquePtr tint;
 	} bloom;
 	} bloom;
 	struct {
 	struct {
 	    UserSettingUniquePtr enabled;
 	    UserSettingUniquePtr enabled;
@@ -73,13 +111,20 @@ struct SceneData {
 	    glm::vec3 up;
 	    glm::vec3 up;
 	} configuration;
 	} configuration;
 
 
+	std::vector<CameraPath> paths;
+
 	struct {
 	struct {
 	    int width;
 	    int width;
 	    int height;
 	    int height;
 	    bool isAuto;
 	    bool isAuto;
+	    bool isPerspective;
 	    UserSettingUniquePtr nearz;
 	    UserSettingUniquePtr nearz;
 	    UserSettingUniquePtr farz;
 	    UserSettingUniquePtr farz;
 	    UserSettingUniquePtr fov;
 	    UserSettingUniquePtr fov;
+	    /** fov of the perspective layers in a 2D scene */
+	    UserSettingUniquePtr perspectiveOverrideFov;
+	    /** 2D scenes: magnification around the scene center, times the active camera object's zoom */
+	    UserSettingUniquePtr zoom;
 	} projection;
 	} projection;
     } camera;
     } camera;
 
 

+ 106 - 36
src/WallpaperEngine/Data/Parsers/DynamicValueParser.cpp

@@ -32,45 +32,11 @@ bool isNumericVector (const std::string& str) {
 std::shared_ptr<const PropertyAnimation> parseAnimation (const json& data) {
 std::shared_ptr<const PropertyAnimation> parseAnimation (const json& data) {
     auto animation = std::make_shared<PropertyAnimation> ();
     auto animation = std::make_shared<PropertyAnimation> ();
 
 
-    const auto readHandle = [] (const json& source, const char* key) {
-	AnimationKeyframe::Handle handle;
-	const auto it = source.find (key);
-
-	if (it == source.end () || !it->is_object ()) {
-	    return handle;
-	}
-
-	if (const auto enabled = it->find ("enabled"); enabled != it->end () && enabled->is_boolean ()) {
-	    handle.enabled = enabled->get<bool> ();
-	}
-	if (const auto x = it->find ("x"); x != it->end () && x->is_number ()) {
-	    handle.x = x->get<float> ();
-	}
-	if (const auto y = it->find ("y"); y != it->end () && y->is_number ()) {
-	    handle.y = y->get<float> ();
-	}
-
-	return handle;
-    };
-
     for (size_t component = 0; component < animation->curves.size (); component++) {
     for (size_t component = 0; component < animation->curves.size (); component++) {
 	const auto curve = data.find ("c" + std::to_string (component));
 	const auto curve = data.find ("c" + std::to_string (component));
 
 
-	if (curve == data.end () || !curve->is_array ()) {
-	    continue;
-	}
-
-	for (const auto& key : *curve) {
-	    if (!key.is_object () || !key.contains ("frame") || !key.contains ("value")) {
-		continue;
-	    }
-
-	    animation->curves[component].push_back (AnimationKeyframe {
-		.frame = key["frame"].get<float> (),
-		.value = key["value"].get<float> (),
-		.back = readHandle (key, "back"),
-		.front = readHandle (key, "front"),
-	    });
+	if (curve != data.end ()) {
+	    animation->curves[component] = DynamicValueParser::parseKeyframes (*curve);
 	}
 	}
     }
     }
 
 
@@ -128,8 +94,112 @@ std::shared_ptr<const PropertyAnimation> parseAnimation (const json& data) {
 	}
 	}
     }
     }
 
 
+    if (animation->wrapLoop) {
+	for (auto& curve : animation->curves) {
+	    DynamicValueParser::closeLoop (curve, static_cast<int> (animation->length));
+	}
+    }
+
     return animation;
     return animation;
 }
 }
+} // namespace
+
+std::vector<AnimationKeyframe> DynamicValueParser::parseKeyframes (const json& curve) {
+    std::vector<AnimationKeyframe> keys;
+
+    if (!curve.is_array ()) {
+	return keys;
+    }
+
+    // a handle object without "enabled" counts as enabled, a disabled one collapses onto its key
+    const auto readHandle = [] (const json& source, const char* name) {
+	AnimationKeyframe::Handle handle;
+	const auto it = source.find (name);
+
+	if (it == source.end () || !it->is_object ()) {
+	    return handle;
+	}
+
+	const auto enabled = it->find ("enabled");
+	handle.enabled = enabled == it->end () || !enabled->is_boolean () || enabled->get<bool> ();
+
+	if (!handle.enabled) {
+	    return handle;
+	}
+
+	if (const auto x = it->find ("x"); x != it->end () && x->is_number ()) {
+	    handle.x = x->get<float> ();
+	}
+	if (const auto y = it->find ("y"); y != it->end () && y->is_number ()) {
+	    handle.y = y->get<float> ();
+	}
+
+	return handle;
+    };
+
+    int lastFrame = -1;
+
+    for (const auto& key : curve) {
+	if (!key.is_object ()) {
+	    continue;
+	}
+
+	const auto frame = key.find ("frame");
+	const auto value = key.find ("value");
+
+	if (frame == key.end () || value == key.end () || !frame->is_number () || !value->is_number ()) {
+	    continue;
+	}
+
+	// frames are whole numbers and only ever increase, anything else is dropped
+	const int whole = static_cast<int> (frame->get<double> ());
+
+	if (whole <= lastFrame) {
+	    continue;
+	}
+
+	lastFrame = whole;
+
+	const auto step = key.find ("step");
+	const bool stepped = step != key.end () && step->is_boolean () && step->get<bool> ();
+
+	keys.push_back (AnimationKeyframe {
+	    .frame = static_cast<float> (whole),
+	    .value = value->get<float> (),
+	    .back = stepped ? AnimationKeyframe::Handle {} : readHandle (key, "back"),
+	    .front = stepped ? AnimationKeyframe::Handle {} : readHandle (key, "front"),
+	    .step = stepped,
+	});
+    }
+
+    return keys;
+}
+
+void DynamicValueParser::closeLoop (std::vector<AnimationKeyframe>& keys, const int frameCount) {
+    if (keys.size () <= 1) {
+	return;
+    }
+
+    const AnimationKeyframe first = keys.front ();
+
+    while (keys.size () > 1 && keys.back ().frame > static_cast<float> (frameCount)) {
+	keys.pop_back ();
+    }
+
+    if (keys.size () <= 1) {
+	return;
+    }
+
+    if (keys.back ().frame != static_cast<float> (frameCount)) {
+	keys.push_back (AnimationKeyframe { .frame = static_cast<float> (frameCount) });
+    }
+
+    // the closing key takes the first one's value, its back handle mirrors the first one's front handle
+    auto& last = keys.back ();
+    last.value = first.value;
+    last.back = first.front.enabled
+	? AnimationKeyframe::Handle { .enabled = true, .x = -first.front.x, .y = -first.front.y }
+	: AnimationKeyframe::Handle { .enabled = false, .x = last.back.x, .y = last.back.y };
 }
 }
 
 
 DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Properties& properties, bool expectColor) {
 DynamicValueUniquePtr DynamicValueParser::parse (const json& data, const Properties& properties, bool expectColor) {

+ 5 - 0
src/WallpaperEngine/Data/Parsers/DynamicValueParser.h

@@ -1,6 +1,7 @@
 #pragma once
 #pragma once
 
 
 #include "WallpaperEngine/Data/JSON.h"
 #include "WallpaperEngine/Data/JSON.h"
+#include "WallpaperEngine/Data/Model/PropertyAnimation.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 
 
 namespace WallpaperEngine::Data::Parsers {
 namespace WallpaperEngine::Data::Parsers {
@@ -10,5 +11,9 @@ using namespace WallpaperEngine::Data::Model;
 class DynamicValueParser {
 class DynamicValueParser {
 public:
 public:
     static Model::DynamicValueUniquePtr parse (const json& data, const Properties& properties, bool expectColor);
     static Model::DynamicValueUniquePtr parse (const json& data, const Properties& properties, bool expectColor);
+    /** One "cN" keyframe array like wallpaper64.exe's sub_1401A8CE0: whole frames, strictly increasing */
+    static std::vector<Model::AnimationKeyframe> parseKeyframes (const json& curve);
+    /** "wraploop": the curve ends in a copy of its first key at frame frameCount (sub_1401A98B0) */
+    static void closeLoop (std::vector<Model::AnimationKeyframe>& keys, int frameCount);
 };
 };
 }
 }

+ 4 - 3
src/WallpaperEngine/Data/Parsers/MaterialParser.cpp

@@ -45,9 +45,10 @@ MaterialPassUniquePtr MaterialParser::parsePass (const JSON& it, const Project&
     return std::make_unique<MaterialPass> (MaterialPass {
     return std::make_unique<MaterialPass> (MaterialPass {
 	// TODO: avoid this std::string construction
 	// TODO: avoid this std::string construction
 	.blending = parseBlendMode (it.optional ("blending", std::string ("normal"))),
 	.blending = parseBlendMode (it.optional ("blending", std::string ("normal"))),
-	.cullmode = parseCullMode (it.optional ("cullmode", std::string ("nocull"))),
-	.depthtest = parseDepthtestMode (it.optional ("depthtest", std::string ("disabled"))),
-	.depthwrite = parseDepthwriteMode (it.optional ("depthwrite", std::string ("disabled"))),
+	// left unknown when missing, which draws like nocull/disabled, 3D models pick their own defaults
+	.cullmode = it.contains ("cullmode") ? parseCullMode (it["cullmode"]) : CullingMode_Unknown,
+	.depthtest = it.contains ("depthtest") ? parseDepthtestMode (it["depthtest"]) : DepthtestMode_Unknown,
+	.depthwrite = it.contains ("depthwrite") ? parseDepthwriteMode (it["depthwrite"]) : DepthwriteMode_Unknown,
 	.shader = it.require<std::string> ("shader", "Material pass must have a shader"),
 	.shader = it.require<std::string> ("shader", "Material pass must have a shader"),
 	.textures = textures.has_value () ? TextureParser::parseTextureMap (*textures) : TextureMap {},
 	.textures = textures.has_value () ? TextureParser::parseTextureMap (*textures) : TextureMap {},
 	.usertextures = usertextures.has_value () ? TextureParser::parseTextureMap (*usertextures) : TextureMap {},
 	.usertextures = usertextures.has_value () ? TextureParser::parseTextureMap (*usertextures) : TextureMap {},

+ 665 - 43
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -1,4 +1,5 @@
 #include "ObjectParser.h"
 #include "ObjectParser.h"
+#include "DynamicValueParser.h"
 #include "EffectParser.h"
 #include "EffectParser.h"
 #include "MaterialParser.h"
 #include "MaterialParser.h"
 #include "ModelParser.h"
 #include "ModelParser.h"
@@ -10,21 +11,248 @@
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Maths.h"
 
 
+#include <cmath>
 #include <glm/gtc/constants.hpp>
 #include <glm/gtc/constants.hpp>
+#include <glm/gtc/matrix_transform.hpp>
 #include <sstream>
 #include <sstream>
 
 
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::Data::Model;
 
 
+namespace {
+/** For settings whose default depends on the scene (wallpaper64.exe picks 2D or 3D defaults), null if not set */
+UserSettingUniquePtr userIfSet (const JSON& it, const std::string& key, const Properties& properties) {
+    return it.optional (key).has_value () ? it.user (key, properties) : nullptr;
+}
+
+/** wallpaper64.exe reads control point indices unsigned and clamps them to 7 */
+int controlPointIndex (const JSON& it, const std::string& key, int fallback) {
+    const auto value = it.optional (key);
+    if (!value.has_value () || !value->is_number ()) {
+	return fallback;
+    }
+    const auto index = value->get<int64_t> ();
+    return index < 0 || index > 7 ? 7 : static_cast<int> (index);
+}
+
+/** WE's "x y z" reader: atof, skip to the next space, missing components are 0 */
+glm::vec3 parseFloats (const std::string& text) {
+    glm::vec3 result (0.0f);
+    const char* cursor = text.c_str ();
+    for (int i = 0; i < 3; i++) {
+	result[i] = static_cast<float> (std::atof (cursor));
+	while (*cursor != '\0' && *cursor != ' ') {
+	    cursor++;
+	}
+	while (*cursor == ' ') {
+	    cursor++;
+	}
+    }
+    return result;
+}
+
+/**
+ * A number fills every component, a string is read as up to three numbers split by spaces and the missing ones are
+ * 0 (the remap ranges in sub_1401C5490)
+ */
+glm::vec3 parseRemapRange (const JSON& it, const std::string& key, float fallback) {
+    const auto value = it.optional (key);
+    if (!value.has_value ()) {
+	return glm::vec3 (fallback);
+    }
+    if (value->is_number ()) {
+	return glm::vec3 (value->get<float> ());
+    }
+    if (!value->is_string ()) {
+	return glm::vec3 (fallback);
+    }
+
+    return parseFloats (value->get<std::string> ());
+}
+
+template <typename T, size_t N>
+T parseName (const JSON& it, const std::string& key, const char* fallback, const char* const (&names)[N], T unknown) {
+    const std::string name = it.optional<std::string> (key, fallback);
+    for (size_t i = 0; i < N; i++) {
+	if (name == names[i]) {
+	    return static_cast<T> (i);
+	}
+    }
+    sLog.error ("Unknown particle ", key, " ", name);
+    return unknown;
+}
+
+ParticleRemap parseRemap (const JSON& it, const char* defaultInput) {
+    // wallpaper64.exe off_140484E80 and sub_140260FB0, sub_140261030, sub_140261120
+    static constexpr const char* values[] = {
+	"lifetimefraction",
+	"maxlifetime",
+	"size",
+	"opacity",
+	"speed",
+	"rotation",
+	"angularspeed",
+	"distancetocontrolpoint",
+	"positionbetweentwocontrolpoints",
+	"runtime",
+	"timeofday",
+	"particlesystemtime",
+	"layertime",
+	"color",
+	"position",
+	"velocity",
+	"controlpoint",
+	"deltatocontrolpoint",
+	"directiontocontrolpoint",
+	"layerorigin",
+    };
+    static constexpr const char* operations[] = { "remap", "multiply", "add", "subtract" };
+    static constexpr const char* components[] = { "all", "x", "y", "z", "sum", "average", "max", "min" };
+    static constexpr const char* transforms[] = { "none",	  "sine",	  "square",	 "saw",
+						  "triangle", "simplexnoise", "fbmnoise" };
+
+    return ParticleRemap {
+	.operation = parseName (it, "operation", "multiply", operations, ParticleRemapOperation::Unknown),
+	.input = parseName (it, "input", defaultInput, values, ParticleRemapValue::Unknown),
+	.output = parseName (it, "output", "size", values, ParticleRemapValue::Unknown),
+	.inputComponent = parseName (it, "inputcomponent", "all", components, ParticleRemapComponent::Unknown),
+	.outputComponent = parseName (it, "outputcomponent", "all", components, ParticleRemapComponent::Unknown),
+	.transform = parseName (it, "transformfunction", "none", transforms, ParticleRemapTransform::Unknown),
+	.flags = it.optional ("flags", 1u),
+	.inputRangeMin = parseRemapRange (it, "inputrangemin", 0.0f),
+	.inputRangeMax = parseRemapRange (it, "inputrangemax", 1.0f),
+	.outputRangeMin = parseRemapRange (it, "outputrangemin", 0.0f),
+	.outputRangeMax = parseRemapRange (it, "outputrangemax", 1.0f),
+	.inputControlPoint0 = controlPointIndex (it, "inputcontrolpoint0", 0),
+	.inputControlPoint1 = controlPointIndex (it, "inputcontrolpoint1", 1),
+	.outputControlPoint0 = controlPointIndex (it, "outputcontrolpoint0", 0),
+	.outputControlPoint1 = controlPointIndex (it, "outputcontrolpoint1", 1),
+	.transformInputScale = it.optional ("transforminputscale", 2.0f),
+	.transformOctaves = it.optional ("transformoctaves", 3),
+    };
+}
+
+ParticleBlendWindow parseBlendWindow (const JSON& it) {
+    return ParticleBlendWindow {
+	.inStart = it.optional ("blendinstart", 0.0f),
+	.inEnd = it.optional ("blendinend", 0.0f),
+	.outStart = it.optional ("blendoutstart", 1.0f),
+	.outEnd = it.optional ("blendoutend", 1.0f),
+    };
+}
+
+/** "r g b" strings with all three numbers, sub_1401C5490 skips anything else */
+std::vector<glm::vec3> parseColorList (const JSON& it) {
+    std::vector<glm::vec3> colors;
+    const auto list = it.optional ("colors");
+    if (!list.has_value ()) {
+	colors.emplace_back (1.0f);
+	return colors;
+    }
+    if (!list->is_array ()) {
+	return colors;
+    }
+
+    for (const auto& entry : *list) {
+	if (!entry.is_string ()) {
+	    continue;
+	}
+	std::istringstream stream (entry.get<std::string> ());
+	glm::vec3 color;
+	if (stream >> color.r >> color.g >> color.b) {
+	    colors.push_back (color);
+	}
+    }
+    return colors;
+}
+
+float numberOr (const JSON& it, const char* key, float fallback) {
+    const auto value = it.find (key);
+    return value != it.end () && value->is_number () ? value->get<float> () : fallback;
+}
+
+std::string stringOr (const JSON& it, const char* key, const char* fallback) {
+    const auto value = it.find (key);
+    return value != it.end () && value->is_string () ? value->get<std::string> () : fallback;
+}
+
+/**
+ * wallpaper64.exe sub_1401C45F0: the color a particle file's color initializer centers on. The last colorrandom,
+ * hsvcolorrandom or colorlist wins, without one the first colorchange's start value. Saved as "%.5f" and read back
+ */
+std::pair<glm::vec3, bool> deriveColorReference (const JSON& particle) {
+    glm::vec3 reference (1.0f);
+    bool hasColor = false;
+
+    const auto nameOf = [] (const JSON& entry) {
+	return entry.is_object () ? stringOr (entry, "name", "") : std::string ();
+    };
+
+    if (const auto initializers = particle.find ("initializer");
+	initializers != particle.end () && initializers->is_array ()) {
+	for (const auto& entry : *initializers) {
+	    const std::string name = nameOf (entry);
+
+	    if (name == "colorrandom") {
+		const glm::vec3 min = parseFloats (stringOr (entry, "min", "0 0 0"));
+		const glm::vec3 max = parseFloats (stringOr (entry, "max", "255 255 255"));
+		reference = ((max - min) * 0.5f + min) * 0.0039215689f;
+		hasColor = true;
+	    } else if (name == "hsvcolorrandom") {
+		const auto middle = [&entry] (const char* low, float lowDefault, const char* high, float highDefault) {
+		    return std::clamp ((numberOr (entry, high, highDefault) + numberOr (entry, low, lowDefault)) * 0.5f, 0.0f, 1.0f);
+		};
+		reference = WallpaperEngine::Maths::hsvToRgb (glm::vec3 (
+		    middle ("huemin", 0.0f, "huemax", 1.0f), middle ("saturationmin", 0.5f, "saturationmax", 1.0f),
+		    middle ("valuemin", 0.5f, "valuemax", 1.0f)
+		));
+		hasColor = true;
+	    } else if (name == "colorlist") {
+		const auto colors = entry.find ("colors");
+		if (colors == entry.end () || !colors->is_array ()) {
+		    continue;
+		}
+		for (const auto& color : *colors) {
+		    if (color.is_string ()) {
+			reference = parseFloats (color.get<std::string> ());
+			hasColor = true;
+			break;
+		    }
+		}
+	    }
+	}
+    }
+
+    if (!hasColor) {
+	if (const auto operators = particle.find ("operator"); operators != particle.end () && operators->is_array ()) {
+	    for (const auto& entry : *operators) {
+		if (nameOf (entry) == "colorchange") {
+		    reference = parseFloats (stringOr (entry, "startvalue", "1 1 1"));
+		    hasColor = true;
+		    break;
+		}
+	    }
+	}
+    }
+
+    char text[196];
+    std::snprintf (text, sizeof (text), "%.5f %.5f %.5f", reference.r, reference.g, reference.b);
+    return { parseFloats (text), hasColor };
+}
+} // namespace
+
 ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     const auto imageIt = it.find ("image");
     const auto imageIt = it.find ("image");
     const auto soundIt = it.find ("sound");
     const auto soundIt = it.find ("sound");
     const auto particleIt = it.find ("particle");
     const auto particleIt = it.find ("particle");
     const auto textIt = it.find ("text");
     const auto textIt = it.find ("text");
     const auto lightIt = it.find ("light");
     const auto lightIt = it.find ("light");
+    const auto modelIt = it.find ("model");
     // "shape" refers to VolumeLight
     // "shape" refers to VolumeLight
     const auto shapeIt = it.find ("shape");
     const auto shapeIt = it.find ("shape");
+    const auto cameraIt = it.find ("camera");
 
 
     // some particle objects have numeric 'name' fields, so handle type mismatches gracefully
     // some particle objects have numeric 'name' fields, so handle type mismatches gracefully
     ObjectData basedata;
     ObjectData basedata;
@@ -41,6 +269,9 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
 	    .groupVisible = it.user ("visible", project.properties, true),
 	    .groupVisible = it.user ("visible", project.properties, true),
 	    .groupParallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
 	    .groupParallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
+	    .solid = it.user ("solid", project.properties, true),
+	    .disablePropagation = it.user ("disablepropagation", project.properties, false),
+	    .perspective = it.user ("perspective", project.properties, false),
 	};
 	};
     } catch (const std::exception& e) {
     } catch (const std::exception& e) {
 	sLog.error ("Error parsing object base data: ", e.what ());
 	sLog.error ("Error parsing object base data: ", e.what ());
@@ -67,6 +298,9 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
 	    .groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
 	    .groupVisible = it.user ("visible", project.properties, true),
 	    .groupVisible = it.user ("visible", project.properties, true),
 	    .groupParallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
 	    .groupParallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
+	    .solid = it.user ("solid", project.properties, true),
+	    .disablePropagation = it.user ("disablepropagation", project.properties, false),
+	    .perspective = it.user ("perspective", project.properties, false),
 	};
 	};
     }
     }
 
 
@@ -80,6 +314,10 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	return parseText (it, project, std::move (basedata));
 	return parseText (it, project, std::move (basedata));
     } else if (lightIt != it.end () && lightIt->is_string ()) {
     } else if (lightIt != it.end () && lightIt->is_string ()) {
 	return parseLight (it, project, std::move (basedata));
 	return parseLight (it, project, std::move (basedata));
+    } else if (modelIt != it.end () && modelIt->is_string ()) {
+	return parseMesh (it, std::move (basedata));
+    } else if (cameraIt != it.end () && cameraIt->is_string ()) {
+	return parseCamera (it, project, std::move (basedata));
     } else if (shapeIt != it.end () && !shapeIt->is_null ()) {
     } else if (shapeIt != it.end () && !shapeIt->is_null ()) {
 	sLog.error ("VolumeLight objects are not supported yet");
 	sLog.error ("VolumeLight objects are not supported yet");
     } else {
     } else {
@@ -92,6 +330,127 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     return std::make_unique<Object> (std::move (basedata));
     return std::make_unique<Object> (std::move (basedata));
 }
 }
 
 
+namespace {
+// the "path" file of a camera object (sub_1401F2030): entries without options or with an invalid length/fps are
+// dropped, tracks use the property animation keyframe format
+std::vector<CameraTimeline> parseCameraTimelines (const std::string& filename, const Project& project) {
+    std::vector<CameraTimeline> timelines;
+    JSON file;
+
+    try {
+	file = JSON::parseAsset (project.assetLocator->readString (filename));
+    } catch (const std::exception& e) {
+	sLog.error ("Cannot load camera path file ", filename, ": ", e.what ());
+	return timelines;
+    }
+
+    const auto list = file.find ("paths");
+
+    if (list == file.end () || !list->is_array ()) {
+	return timelines;
+    }
+
+    for (const auto& entry : *list) {
+	if (!entry.is_object ()) {
+	    continue;
+	}
+
+	const auto options = entry.find ("options");
+
+	if (options == entry.end () || !options->is_object ()) {
+	    continue;
+	}
+
+	const auto length = options->find ("length");
+	const auto fps = options->find ("fps");
+
+	if (length == options->end () || fps == options->end () || !length->is_number () || !fps->is_number ()
+	    || fps->get<float> () <= 0.0f || length->get<int> () <= 0) {
+	    continue;
+	}
+
+	CameraTimeline timeline;
+	timeline.fps = fps->get<float> ();
+	timeline.length = static_cast<float> (length->get<int> ());
+
+	if (const auto name = entry.find ("name"); name != entry.end () && name->is_string ()) {
+	    timeline.name = name->get<std::string> ();
+	}
+	if (const auto visible = entry.find ("visible"); visible != entry.end () && visible->is_boolean ()) {
+	    timeline.visible = visible->get<bool> ();
+	}
+	if (const auto mode = options->find ("mode"); mode != options->end () && mode->is_string ()) {
+	    const auto name = mode->get<std::string> ();
+	    timeline.mode = name == "mirror" ? PropertyAnimation::Mode::Mirror
+		: name == "single"           ? PropertyAnimation::Mode::Single
+					     : PropertyAnimation::Mode::Loop;
+	} else {
+	    timeline.mode = PropertyAnimation::Mode::Loop;
+	}
+	if (const auto paused = options->find ("startpaused"); paused != options->end () && paused->is_boolean ()) {
+	    timeline.startPaused = paused->get<bool> ();
+	}
+
+	const auto wrap = options->find ("wraploop");
+	const bool wrapLoop = wrap != options->end () && wrap->is_boolean () && wrap->get<bool> ();
+	const int frames = static_cast<int> (timeline.length);
+
+	const auto readCurve = [&] (const JSON& curve, std::vector<AnimationKeyframe>& keys) {
+	    keys = DynamicValueParser::parseKeyframes (curve);
+
+	    if (wrapLoop) {
+		DynamicValueParser::closeLoop (keys, frames);
+	    }
+	};
+	const auto readTrack = [&] (const char* key, std::array<std::vector<AnimationKeyframe>, 3>& track) {
+	    const auto it = entry.find (key);
+
+	    if (it == entry.end () || !it->is_object ()) {
+		return;
+	    }
+
+	    for (int component = 0; component < 3; component++) {
+		if (const auto curve = it->find ("c" + std::to_string (component)); curve != it->end ()) {
+		    readCurve (*curve, track[component]);
+		}
+	    }
+	};
+
+	readTrack ("center", timeline.center);
+	readTrack ("eye", timeline.eye);
+	readTrack ("up", timeline.up);
+
+	if (const auto zoom = entry.find ("zoom"); zoom != entry.end ()) {
+	    readCurve (*zoom, timeline.zoom);
+	}
+	if (const auto fov = entry.find ("fov"); fov != entry.end ()) {
+	    readCurve (*fov, timeline.fov);
+	}
+
+	timelines.push_back (std::move (timeline));
+    }
+
+    return timelines;
+}
+} // namespace
+
+SceneCameraUniquePtr ObjectParser::parseCamera (const JSON& it, const Project& project, ObjectData base) {
+    // defaults from the camera object's constructor, wallpaper64.exe 2.8.42 sub_14018FF60
+    SceneCameraData data {
+	.fov = it.user ("fov", project.properties, 50.0f),
+	.zoom = it.user ("zoom", project.properties, 1.0f),
+	.timelines = {},
+	.queueMode = it.optional ("queuemode", std::string ("random")) == "sequential" ? CameraQueueMode::Sequential
+										: CameraQueueMode::Random,
+    };
+
+    if (const auto path = it.optional<std::string> ("path"); path.has_value ()) {
+	data.timelines = parseCameraTimelines (*path, project);
+    }
+
+    return std::make_unique<SceneCamera> (std::move (base), std::move (data));
+}
+
 LightUniquePtr ObjectParser::parseLight (const JSON& it, const Project& project, ObjectData base) {
 LightUniquePtr ObjectParser::parseLight (const JSON& it, const Project& project, ObjectData base) {
     const auto name = it.require<std::string> ("light", "Light must have a type");
     const auto name = it.require<std::string> ("light", "Light must have a type");
     const std::map<std::string, LightType> types = {
     const std::map<std::string, LightType> types = {
@@ -113,10 +472,21 @@ LightUniquePtr ObjectParser::parseLight (const JSON& it, const Project& project,
 	    .intensity = it.user ("intensity", project.properties, 1.0f),
 	    .intensity = it.user ("intensity", project.properties, 1.0f),
 	    .radius = it.user ("radius", project.properties, 1.0f),
 	    .radius = it.user ("radius", project.properties, 1.0f),
 	    .visible = it.user ("visible", project.properties, true),
 	    .visible = it.user ("visible", project.properties, true),
+	    // defaults from the light's constructor (sub_14018FF60) and property table (sub_14025DA80)
+	    .castVolumetrics = it.optional ("castvolumetrics", false),
+	    .castShadow = it.optional ("castshadow", false),
+	    .density = it.user ("density", project.properties, 2.0f),
+	    .volumetricsExponent = it.user ("volumetricsexponent", project.properties, 1.0f),
 	}
 	}
     );
     );
 }
 }
 
 
+MeshUniquePtr ObjectParser::parseMesh (const JSON& it, ObjectData base) {
+    return std::make_unique<Mesh> (
+	std::move (base), MeshData { .model = it.require<std::string> ("model", "Model object must have a model") }
+    );
+}
+
 std::vector<int> ObjectParser::parseDependencies (const JSON& it) {
 std::vector<int> ObjectParser::parseDependencies (const JSON& it) {
     const auto dependenciesIt = it.find ("dependencies");
     const auto dependenciesIt = it.find ("dependencies");
 
 
@@ -184,12 +554,14 @@ uint32_t ObjectParser::parseAlignment (const std::string& alignment) {
 
 
 TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, ObjectData base) {
 TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, ObjectData base) {
     const auto& effects = it.optional ("effects");
     const auto& effects = it.optional ("effects");
+    const auto& properties = project.properties;
 
 
+    // defaults are the text object constructor's in wallpaper64.exe 2.8.42 (sub_140256AE0)
     return std::make_unique<Text> (
     return std::make_unique<Text> (
 	std::move (base),
 	std::move (base),
 	TextData {
 	TextData {
 	    .text = it.user ("text", project.properties),
 	    .text = it.user ("text", project.properties),
-	    .font = it.optional ("font", std::string ()),
+	    .font = it.optional ("font", std::string ("systemfont_arial")),
 	    .pointSize = it.user ("pointsize", project.properties, 32.0f),
 	    .pointSize = it.user ("pointsize", project.properties, 32.0f),
 	    .size = it.optional ("size", glm::vec2 (0.0f)),
 	    .size = it.optional ("size", glm::vec2 (0.0f)),
 	    .scale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
 	    .scale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
@@ -197,15 +569,34 @@ TextUniquePtr ObjectParser::parseText (const JSON& it, const Project& project, O
 	    .alpha = it.user ("alpha", project.properties, 1.0f),
 	    .alpha = it.user ("alpha", project.properties, 1.0f),
 	    .visible = it.user ("visible", project.properties, true),
 	    .visible = it.user ("visible", project.properties, true),
 	    .parallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
 	    .parallaxDepth = it.user ("parallaxDepth", project.properties, glm::vec2 (1.0f)),
-	    .alignment = it.optional ("horizontalalign", it.optional ("alignment", std::string ("center"))),
-	    .verticalalign = it.optional ("verticalalign", std::string ("center")),
-	    .padding = it.optional ("padding", glm::vec2 (0.0f)),
+	    .horizontalAlign = it.user ("horizontalalign", properties, it.optional ("alignment", std::string ("center"))),
+	    .verticalAlign = it.user ("verticalalign", properties, std::string ("center")),
+	    .anchor = it.user ("anchor", properties, std::string ("none")),
+	    .colorBlendMode = it.user ("colorBlendMode", properties, 0),
+	    .padding = it.user ("padding", properties, glm::vec2 (32.0f)),
+	    .spacing = it.user ("spacing", properties, glm::vec2 (0.0f)),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
-	    .limitWidth = it.user ("limitwidth", project.properties, false),
-	    .maxWidth = it.user ("maxwidth", project.properties, 500.0f),
-	    .limitRows = it.user ("limitrows", project.properties, false),
-	    .maxRows = it.user ("maxrows", project.properties, 1),
-	    .limitUseEllipsis = it.user ("limituseellipsis", project.properties, false),
+	    .limitWidth = it.user ("limitwidth", properties, false),
+	    .maxWidth = it.user ("maxwidth", properties, 512.0f),
+	    .limitRows = it.user ("limitrows", properties, false),
+	    .maxRows = it.user ("maxrows", properties, 1),
+	    .limitUseEllipsis = it.user ("limituseellipsis", properties, false),
+	    .blockAlign = it.user ("blockalign", properties, false),
+	    .opaqueBackground = it.user ("opaquebackground", properties, false),
+	    .backgroundColor = it.color ("backgroundcolor", properties, Builders::ColorBuilder::Black),
+	    .backgroundBrightness = it.user ("backgroundbrightness", properties, 1.0f),
+	    .brightness = it.user ("brightness", properties, 1.0f),
+	    .msdf = it.user ("msdf", properties, false),
+	    .outline = it.user ("outline", properties, false),
+	    .outlineThickness = it.user ("outlinethickness", properties, 4.0f),
+	    .outlineColor = it.color ("outlinecolor", properties, Builders::ColorBuilder::Black),
+	    .blur = it.user ("blur", properties, false),
+	    .blurSize = it.user ("blursize", properties, 6.0f),
+	    .dropShadow = it.user ("dropshadow", properties, false),
+	    .dropShadowSize = it.user ("dropshadowsize", properties, 6.0f),
+	    .dropShadowOpacity = it.user ("dropshadowopacity", properties, 1.0f),
+	    .dropShadowOffset = it.user ("dropshadowoffset", properties, glm::vec2 (4.0f)),
+	    .dropShadowColor = it.color ("dropshadowcolor", properties, Builders::ColorBuilder::Black),
 	}
 	}
     );
     );
 }
 }
@@ -232,6 +623,7 @@ ObjectParser::parseImage (const JSON& it, const Project& project, ObjectData bas
 	    .colorBlendMode = it.user ("colorBlendMode", properties, 0),
 	    .colorBlendMode = it.user ("colorBlendMode", properties, 0),
 	    .brightness = it.user ("brightness", properties, 1.0f),
 	    .brightness = it.user ("brightness", properties, 1.0f),
 	    .clampUVs = it.optional ("clampuvs", false),
 	    .clampUVs = it.optional ("clampuvs", false),
+	    .copyBackground = it.user ("copybackground", properties, true),
 	    .model = ModelParser::load (project, image),
 	    .model = ModelParser::load (project, image),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .animationLayers = animationLayers.has_value () ? parseAnimationLayers (*animationLayers, project)
 	    .animationLayers = animationLayers.has_value () ? parseAnimationLayers (*animationLayers, project)
@@ -362,7 +754,7 @@ ImageAnimationLayerUniquePtr ObjectParser::parseAnimationLayer (const JSON& it,
     });
     });
 }
 }
 
 
-ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& project, ObjectData base) {
+ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& project, ObjectData base, int depth) {
     try {
     try {
 	const auto& properties = project.properties;
 	const auto& properties = project.properties;
 	const auto particleIt = it.find ("particle");
 	const auto particleIt = it.find ("particle");
@@ -454,6 +846,30 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	    }
 	}
 	}
 
 
+	// wallpaper64.exe sub_1401C5490: control points a remap component outputs to are left to it
+	uint8_t remapOutputControlPoints = 0;
+	for (const auto* list : { &initializersIt, &operatorsIt }) {
+	    if (*list == particleJson.end () || !(*list)->is_array ()) {
+		continue;
+	    }
+	    for (const auto& component : **list) {
+		const auto nameIt = component.find ("name");
+		const auto outputIt = component.find ("output");
+		if (nameIt == component.end () || !nameIt->is_string () || outputIt == component.end ()
+		    || !outputIt->is_string () || outputIt->get<std::string> () != "controlpoint") {
+		    continue;
+		}
+		const std::string name = nameIt->get<std::string> ();
+		if (name != "remapvalue" && name != "remapinitialvalue") {
+		    continue;
+		}
+		const auto cpIt = component.find ("outputcontrolpoint0");
+		const int64_t cp = cpIt != component.end () && cpIt->is_number () ? cpIt->get<int64_t> () : 0;
+		// clamped as unsigned, so negative indices end up on the last slot too
+		remapOutputControlPoints |= static_cast<uint8_t> (1u << (cp < 0 || cp > 7 ? 7 : cp));
+	    }
+	}
+
 	// field is named "renderer" not "renderers"
 	// field is named "renderer" not "renderers"
 	std::vector<ParticleRenderer> renderers;
 	std::vector<ParticleRenderer> renderers;
 	const auto renderersIt = particleJson.find ("renderer");
 	const auto renderersIt = particleJson.find ("renderer");
@@ -490,11 +906,13 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    }
 	    }
 	}
 	}
 
 
+	// real WE stops creating child systems 10 levels down, which also ends a file listing itself as a child
+	constexpr int maxChildDepth = 10;
 	std::vector<ParticleChild> children;
 	std::vector<ParticleChild> children;
 	const auto childrenIt = particleJson.optional ("children");
 	const auto childrenIt = particleJson.optional ("children");
-	if (childrenIt.has_value () && childrenIt->is_array ()) {
+	if (depth < maxChildDepth && childrenIt.has_value () && childrenIt->is_array ()) {
 	    for (const auto& child : *childrenIt) {
 	    for (const auto& child : *childrenIt) {
-		children.push_back (parseParticleChild (child, project));
+		children.push_back (parseParticleChild (child, it, project, base, children.size (), depth + 1));
 	    }
 	    }
 	}
 	}
 
 
@@ -567,6 +985,8 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 	    startTime = startTimeIt->get<float> ();
 	    startTime = startTimeIt->get<float> ();
 	}
 	}
 
 
+	const auto [colorReference, hasColor] = deriveColorReference (particleJson);
+
 	const auto flagsIt = particleJson.find ("flags");
 	const auto flagsIt = particleJson.find ("flags");
 	if (flagsIt != particleJson.end () && flagsIt->is_number ()) {
 	if (flagsIt != particleJson.end () && flagsIt->is_number ()) {
 	    flags = flagsIt->get<uint32_t> ();
 	    flags = flagsIt->get<uint32_t> ();
@@ -592,6 +1012,10 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		.renderers = std::move (renderers),
 		.renderers = std::move (renderers),
 		.controlPoints = std::move (controlPoints),
 		.controlPoints = std::move (controlPoints),
 		.children = std::move (children),
 		.children = std::move (children),
+		.remapOutputControlPoints = remapOutputControlPoints,
+		.colorReference = colorReference,
+		.hasColor = hasColor,
+		.overrideColorTints = !hasColor || project.sceneVersion < 5,
 		.instanceOverride = std::move (instanceOverride),
 		.instanceOverride = std::move (instanceOverride),
 	    }
 	    }
 	);
 	);
@@ -629,17 +1053,6 @@ ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
 	return defaultValue;
 	return defaultValue;
     };
     };
 
 
-    auto parseIVec3 = [&] (const char* fieldName, const glm::ivec3& defaultValue) -> glm::ivec3 {
-	const auto fieldIt = it.find (fieldName);
-	if (fieldIt == it.end ()) {
-	    return defaultValue;
-	}
-	if (fieldIt->is_array () && fieldIt->size () >= 3) {
-	    return glm::ivec3 ((*fieldIt)[0].get<int> (), (*fieldIt)[1].get<int> (), (*fieldIt)[2].get<int> ());
-	}
-	return defaultValue;
-    };
-
     auto parseVec2 = [&] (const char* fieldName, const glm::vec2& defaultValue) -> glm::vec2 {
     auto parseVec2 = [&] (const char* fieldName, const glm::vec2& defaultValue) -> glm::vec2 {
 	const auto fieldIt = it.find (fieldName);
 	const auto fieldIt = it.find (fieldName);
 	if (fieldIt == it.end ()) {
 	if (fieldIt == it.end ()) {
@@ -662,7 +1075,7 @@ ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
 	    .distanceMin = parseVec3 ("distancemin", glm::vec3 (0.0f, 0.0f, 0.0f)),
 	    .distanceMin = parseVec3 ("distancemin", glm::vec3 (0.0f, 0.0f, 0.0f)),
 	    .distanceMax = parseVec3 ("distancemax", glm::vec3 (256.0f, 256.0f, 0.0f)),
 	    .distanceMax = parseVec3 ("distancemax", glm::vec3 (256.0f, 256.0f, 0.0f)),
 	    .origin = parseVec3 ("origin", glm::vec3 (0.0f)),
 	    .origin = parseVec3 ("origin", glm::vec3 (0.0f)),
-	    .sign = parseIVec3 ("sign", glm::ivec3 (0)),
+	    .sign = parseVec3 ("sign", glm::vec3 (0.0f)),
 	    .instantaneous = it.optional ("instantaneous", 0u),
 	    .instantaneous = it.optional ("instantaneous", 0u),
 	    .speedMin = it.optional ("speedmin", 0.0f),
 	    .speedMin = it.optional ("speedmin", 0.0f),
 	    .speedMax = it.optional ("speedmax", 0.0f),
 	    .speedMax = it.optional ("speedmax", 0.0f),
@@ -695,7 +1108,7 @@ ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON&
     if (name == "colorrandom") {
     if (name == "colorrandom") {
 	return std::make_unique<ColorRandomInitializer> (
 	return std::make_unique<ColorRandomInitializer> (
 	    it.color ("min", properties, Builders::ColorBuilder::Black),
 	    it.color ("min", properties, Builders::ColorBuilder::Black),
-	    it.color ("max", properties, Builders::ColorBuilder::White)
+	    it.color ("max", properties, Builders::ColorBuilder::White), it.user ("exponent", properties, 1.0f)
 	);
 	);
     } else if (name == "sizerandom") {
     } else if (name == "sizerandom") {
 	return std::make_unique<SizeRandomInitializer> (
 	return std::make_unique<SizeRandomInitializer> (
@@ -736,11 +1149,56 @@ ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON&
 	    it.user ("audioprocessingfrequencystart", properties, 0),
 	    it.user ("audioprocessingfrequencystart", properties, 0),
 	    it.user ("audioprocessingfrequencyend", properties, 1)
 	    it.user ("audioprocessingfrequencyend", properties, 1)
 	);
 	);
+    } else if (name == "inheritinitialvaluefromevent") {
+	return std::make_unique<InheritInitialValueFromEventInitializer> (
+	    parseParticleEventInput (it, ParticleEventInput::SetColor)
+	);
+    } else if (name == "inheritcontrolpointvelocity") {
+	return std::make_unique<InheritControlPointVelocityInitializer> (
+	    it.optional ("controlpoint", 0), it.user ("min", properties, 0.1f), it.user ("max", properties, 0.2f)
+	);
     } else if (name == "mapsequencearoundcontrolpoint") {
     } else if (name == "mapsequencearoundcontrolpoint") {
 	return std::make_unique<MapSequenceAroundControlPointInitializer> (
 	return std::make_unique<MapSequenceAroundControlPointInitializer> (
 	    it.user ("controlpoint", properties, 0), it.user ("count", properties, 1),
 	    it.user ("controlpoint", properties, 0), it.user ("count", properties, 1),
 	    it.user ("speedmin", properties, glm::vec3 (0.0f)), it.user ("speedmax", properties, glm::vec3 (100.0f))
 	    it.user ("speedmin", properties, glm::vec3 (0.0f)), it.user ("speedmax", properties, glm::vec3 (100.0f))
 	);
 	);
+    } else if (name == "hsvcolorrandom") {
+	// defaults from wallpaper64.exe sub_1401BA3E0
+	return std::make_unique<HsvColorRandomInitializer> (
+	    it.user ("huemin", properties, 0.0f), it.user ("huemax", properties, 1.0f),
+	    std::max (0, it.optional ("huesteps", 6)), it.user ("saturationmin", properties, 0.5f),
+	    it.user ("saturationmax", properties, 1.0f), it.user ("valuemin", properties, 0.5f),
+	    it.user ("valuemax", properties, 1.0f)
+	);
+    } else if (name == "colorlist") {
+	return std::make_unique<ColorListInitializer> (
+	    parseColorList (it), it.user ("huenoise", properties, 0.0f), it.user ("saturationnoise", properties, 0.0f),
+	    it.user ("valuenoise", properties, 0.0f)
+	);
+    } else if (name == "positionoffsetrandom") {
+	// octaves clamped to 1..8 like sub_1401C5490
+	const int octaves = it.optional ("octaves", 6);
+	return std::make_unique<PositionOffsetRandomInitializer> (
+	    userIfSet (it, "directions", properties), it.user ("sign", properties, glm::vec3 (0.0f)),
+	    userIfSet (it, "scale", properties), userIfSet (it, "distance", properties),
+	    it.user ("timescale", properties, 1.0f), octaves >= 8 ? 8 : (octaves == 0 ? 1 : octaves)
+	);
+    } else if (name == "mapsequencebetweencontrolpoints") {
+	glm::vec2 bounds (0.0f, 1.0f);
+	const auto boundsIt = it.optional ("bounds");
+	if (boundsIt.has_value () && boundsIt->is_string ()) {
+	    const glm::vec3 parsed = parseRemapRange (it, "bounds", 0.0f);
+	    bounds = glm::vec2 (parsed.x, parsed.y);
+	}
+	return std::make_unique<MapSequenceBetweenControlPointsInitializer> (
+	    it.optional ("count", 32.0f), bounds, it.optional<std::string> ("limitbehavior", "repeat") == "mirror",
+	    controlPointIndex (it, "controlpointstart", 0), controlPointIndex (it, "controlpointend", 1),
+	    it.optional ("flags", 0u), it.user ("arcamount", properties, 0.3f),
+	    it.user ("arcdirection", properties, glm::vec3 (0.0f, 1.0f, 0.0f)),
+	    it.user ("sizereductionamount", properties, 0.9f)
+	);
+    } else if (name == "remapinitialvalue") {
+	return std::make_unique<RemapInitialValueInitializer> (parseRemap (it, "maxlifetime"));
     }
     }
 
 
     return nullptr;
     return nullptr;
@@ -751,7 +1209,8 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 
 
     if (name == "movement") {
     if (name == "movement") {
 	return std::make_unique<MovementOperator> (
 	return std::make_unique<MovementOperator> (
-	    it.user ("drag", properties, 0.0f), it.user ("gravity", properties, glm::vec3 (0.0f))
+	    it.user ("drag", properties, 0.0f), it.user ("gravity", properties, glm::vec3 (0.0f)),
+	    it.optional ("flags", 0u)
 	);
 	);
     } else if (name == "angularmovement") {
     } else if (name == "angularmovement") {
 	return std::make_unique<AngularMovementOperator> (
 	return std::make_unique<AngularMovementOperator> (
@@ -803,6 +1262,14 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    it.user ("audioprocessingfrequencystart", properties, 0),
 	    it.user ("audioprocessingfrequencystart", properties, 0),
 	    it.user ("audioprocessingfrequencyend", properties, 1)
 	    it.user ("audioprocessingfrequencyend", properties, 1)
 	);
 	);
+    } else if (name == "inheritvaluefromevent") {
+	return std::make_unique<InheritValueFromEventOperator> (
+	    parseParticleEventInput (it, ParticleEventInput::SetColorOpacity),
+	    glm::vec4 (
+		it.optional ("blendinstart", 0.0f), it.optional ("blendinend", 0.0f), it.optional ("blendoutstart", 1.0f),
+		it.optional ("blendoutend", 1.0f)
+	    )
+	);
     } else if (name == "controlpointattract") {
     } else if (name == "controlpointattract") {
 	return std::make_unique<ControlPointAttractOperator> (
 	return std::make_unique<ControlPointAttractOperator> (
 	    it.optional ("controlpoint", 0), it.user ("origin", properties, glm::vec3 (0.0f)),
 	    it.optional ("controlpoint", 0), it.user ("origin", properties, glm::vec3 (0.0f)),
@@ -827,6 +1294,70 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    it.user ("phasemin", properties, 0.0f), it.user ("phasemax", properties, glm::two_pi<float> ()),
 	    it.user ("phasemin", properties, 0.0f), it.user ("phasemax", properties, glm::two_pi<float> ()),
 	    it.user ("mask", properties, glm::vec3 (1.0f, 1.0f, 0.0f))
 	    it.user ("mask", properties, glm::vec3 (1.0f, 1.0f, 0.0f))
 	);
 	);
+    } else if (name == "capvelocity") {
+	return std::make_unique<CapVelocityOperator> (userIfSet (it, "maxspeed", properties), parseBlendWindow (it));
+    } else if (name == "boids") {
+	// defaults from wallpaper64.exe sub_1401BF700
+	return std::make_unique<BoidsOperator> (
+	    userIfSet (it, "separationthreshold", properties), userIfSet (it, "neighborthreshold", properties),
+	    userIfSet (it, "maxspeed", properties), it.user ("separationfactor", properties, 15.0f),
+	    it.user ("alignmentfactor", properties, 1.0f), it.user ("cohesionfactor", properties, 2.0f),
+	    it.optional ("flags", 1u)
+	);
+    } else if (name == "remapvalue") {
+	return std::make_unique<RemapValueOperator> (parseRemap (it, "lifetimefraction"), parseBlendWindow (it));
+    } else if (name == "maintaindistancetocontrolpoint") {
+	return std::make_unique<MaintainDistanceToControlPointOperator> (
+	    controlPointIndex (it, "controlpoint", 0), userIfSet (it, "distance", properties),
+	    it.user ("variablestrength", properties, 0.0f), parseBlendWindow (it)
+	);
+    } else if (name == "maintaindistancebetweencontrolpoints") {
+	return std::make_unique<MaintainDistanceBetweenControlPointsOperator> (
+	    controlPointIndex (it, "controlpointstart", 0), controlPointIndex (it, "controlpointend", 1),
+	    parseBlendWindow (it)
+	);
+    } else if (name == "reducemovementnearcontrolpoint") {
+	// defaults from wallpaper64.exe sub_1401BE810
+	return std::make_unique<ReduceMovementNearControlPointOperator> (
+	    controlPointIndex (it, "controlpoint", 0), userIfSet (it, "distanceinner", properties),
+	    userIfSet (it, "distanceouter", properties), it.user ("reductioninner", properties, 100.0f),
+	    it.user ("reductionouter", properties, 0.0f), parseBlendWindow (it)
+	);
+    } else if (
+	name == "collisionplane" || name == "collisionsphere" || name == "collisionbox" || name == "collisionbounds"
+	|| name == "collisionquad" || name == "collisionmodel"
+    ) {
+	static constexpr const char* behaviors[] = { "bounce", "slide", "stop", "delete" };
+	ParticleCollisionShape shape = ParticleCollisionShape::Plane;
+	if (name == "collisionsphere") {
+	    shape = ParticleCollisionShape::Sphere;
+	} else if (name == "collisionbox") {
+	    shape = ParticleCollisionShape::Box;
+	} else if (name == "collisionbounds") {
+	    shape = ParticleCollisionShape::Bounds;
+	} else if (name == "collisionquad") {
+	    shape = ParticleCollisionShape::Quad;
+	} else if (name == "collisionmodel") {
+	    shape = ParticleCollisionShape::Model;
+	}
+
+	// sub_1401C03F0: anything but slide, stop or delete bounces
+	ParticleCollisionBehavior behavior = ParticleCollisionBehavior::Bounce;
+	const std::string behaviorName = it.optional<std::string> ("collisionbehavior", "bounce");
+	for (size_t i = 0; i < std::size (behaviors); i++) {
+	    if (behaviorName == behaviors[i]) {
+		behavior = static_cast<ParticleCollisionBehavior> (i);
+	    }
+	}
+
+	// defaults from wallpaper64.exe sub_1401C00A0, sub_1401C0540, sub_1401C0740 and sub_1401C0870
+	return std::make_unique<CollisionOperator> (
+	    shape, behavior, it.user ("bouncefactor", properties, 0.5f), it.optional ("flags", 0u),
+	    controlPointIndex (it, "controlpoint", 0), it.user ("plane", properties, glm::vec3 (0.0f, 1.0f, 0.0f)),
+	    userIfSet (it, "distance", properties), userIfSet (it, "origin", properties),
+	    userIfSet (it, "radius", properties), it.user ("forward", properties, glm::vec3 (0.0f, 0.0f, 1.0f)),
+	    userIfSet (it, "size", properties)
+	);
     }
     }
 
 
     return nullptr;
     return nullptr;
@@ -857,6 +1388,30 @@ ParticleRenderer ObjectParser::parseParticleRenderer (const JSON& it) {
     };
     };
 }
 }
 
 
+ParticleEventInput ObjectParser::parseParticleEventInput (const JSON& it, ParticleEventInput fallback) {
+    // wallpaper64.exe off_140484D90
+    static constexpr const char* names[] = {
+	"setcolor", "multiplycolor", "setopacity", "multiplyopacity", "setcoloropacity", "multiplycoloropacity",
+	"setvelocity", "addvelocity", "setsize", "multiplysize", "setrotation", "addrotation", "setangularvelocity",
+	"addangularvelocity",
+    };
+
+    const auto inputIt = it.find ("input");
+    if (inputIt == it.end () || !inputIt->is_string ()) {
+	return fallback;
+    }
+
+    const std::string input = inputIt->get<std::string> ();
+    for (size_t i = 0; i < std::size (names); i++) {
+	if (input == names[i]) {
+	    return static_cast<ParticleEventInput> (i);
+	}
+    }
+
+    sLog.error ("Unknown particle event input ", input);
+    return fallback;
+}
+
 ParticleControlPoint ObjectParser::parseParticleControlPoint (const JSON& it) {
 ParticleControlPoint ObjectParser::parseParticleControlPoint (const JSON& it) {
     // offset can be string "x y z" or array [x,y,z]
     // offset can be string "x y z" or array [x,y,z]
     glm::vec3 offset (0.0f);
     glm::vec3 offset (0.0f);
@@ -880,20 +1435,27 @@ ParticleControlPoint ObjectParser::parseParticleControlPoint (const JSON& it) {
 	.flags = it.optional ("flags", 0u),
 	.flags = it.optional ("flags", 0u),
 	.offset = offset,
 	.offset = offset,
 	.lockToPointer = it.optional ("locktopointer", false),
 	.lockToPointer = it.optional ("locktopointer", false),
+	.parentControlPoint = it.optional ("parentcontrolpoint", 0),
     };
     };
 }
 }
 
 
-ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& project) {
-    std::string particleFile = "";
-    const auto particleIt = it.find ("particle");
-    if (particleIt != it.end () && particleIt->is_string ()) {
-	particleFile = particleIt->get<std::string> ();
+ParticleChild ObjectParser::parseParticleChild (
+    const JSON& it, const JSON& owner, const Project& project, const ObjectData& ownerBase, size_t index, int depth
+) {
+    // defaults and type names per wallpaper64.exe sub_1401C1430 / sub_1401C5490
+    ParticleChildType type = ParticleChildType::Static;
+    std::string typeName = "static";
+    if (const auto typeIt = it.find ("type"); typeIt != it.end () && typeIt->is_string ()) {
+	typeName = typeIt->get<std::string> ();
     }
     }
-
-    std::string type = "static";
-    const auto typeIt = it.find ("type");
-    if (typeIt != it.end () && typeIt->is_string ()) {
-	type = typeIt->get<std::string> ();
+    if (typeName == "eventfollow") {
+	type = ParticleChildType::EventFollow;
+    } else if (typeName == "eventspawn") {
+	type = ParticleChildType::EventSpawn;
+    } else if (typeName == "eventdeath") {
+	type = ParticleChildType::EventDeath;
+    } else if (typeName != "static") {
+	sLog.error ("Unknown particle child type ", typeName, ", treating it as static");
     }
     }
 
 
     std::string name = "";
     std::string name = "";
@@ -912,7 +1474,6 @@ ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& p
 	    return it.optional (fieldName, defaultValue);
 	    return it.optional (fieldName, defaultValue);
 	}
 	}
 	if (fieldIt->is_number ()) {
 	if (fieldIt->is_number ()) {
-	    // Single number - use for all components
 	    float val = fieldIt->get<float> ();
 	    float val = fieldIt->get<float> ();
 	    return glm::vec3 (val, val, val);
 	    return glm::vec3 (val, val, val);
 	}
 	}
@@ -922,16 +1483,76 @@ ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& p
 	return defaultValue;
 	return defaultValue;
     };
     };
 
 
+    const glm::vec3 angles = parseVec3 ("angles", glm::vec3 (0.0f));
+    const glm::vec3 origin = parseVec3 ("origin", glm::vec3 (0.0f));
+    const glm::vec3 scale = parseVec3 ("scale", glm::vec3 (1.0f));
+
+    // WE's row vector matrix: rotation rows from z/y/x angles, each row scaled by its scale component, origin last
+    const float cz = std::cos (angles.z), sz = std::sin (angles.z);
+    const float cy = std::cos (angles.y), sy = std::sin (angles.y);
+    const float cx = std::cos (angles.x), sx = std::sin (angles.x);
+    const glm::vec3 row0 (cy * cz, cy * sz, -sy);
+    const glm::vec3 row1 (sy * cz * sx - cx * sz, sy * sz * sx + cx * cz, sx * cy);
+    const glm::vec3 row2 (sx * sz + sy * cx * cz, sy * cx * sz - sx * cz, cx * cy);
+    const glm::mat4 weTransform (
+	glm::vec4 (row0 * scale.x, 0.0f), glm::vec4 (row1 * scale.y, 0.0f), glm::vec4 (row2 * scale.z, 0.0f),
+	glm::vec4 (origin, 1.0f)
+    );
+    // particle space here is WE's with y flipped, same as emitter origins and velocities
+    const glm::mat4 flipY = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f));
+
+    // the child is loaded like a particle object of its own that shares the owner's instance override
+    JSON childObject = JSON::object ();
+    childObject["particle"] = name;
+    if (const auto overrideIt = owner.find ("instanceoverride"); overrideIt != owner.end ()) {
+	childObject["instanceoverride"] = *overrideIt;
+    }
+
+    // a stable id of its own keeps its random seed (LWE_FIXED_TIMESTEP) apart from the owner's
+    const uint32_t hashed = (static_cast<uint32_t> (ownerBase.id) * 31u + static_cast<uint32_t> (index) + 1u) * 2654435761u;
+
+    ParticleUniquePtr particle = nullptr;
+    if (!name.empty ()) {
+	try {
+	    particle = parseParticle (
+		childObject, project,
+		ObjectData {
+		    .id = -1 - static_cast<int> (hashed & 0x3FFFFFFFu),
+		    .name = ownerBase.name + " > " + name,
+		    .dependencies = {},
+		    .parent = std::nullopt,
+		    .attachment = std::nullopt,
+		    .sortOrder = std::nullopt,
+		    .origin = Builders::UserSettingBuilder::fromValue (glm::vec3 (0.0f)),
+		    .groupScale = Builders::UserSettingBuilder::fromValue (glm::vec3 (1.0f)),
+		    .groupAngles = Builders::UserSettingBuilder::fromValue (glm::vec3 (0.0f)),
+		    .groupVisible = Builders::UserSettingBuilder::fromValue (true),
+		    .groupParallaxDepth = Builders::UserSettingBuilder::fromValue (glm::vec2 (1.0f)),
+		    .solid = Builders::UserSettingBuilder::fromValue (true),
+		    .disablePropagation = Builders::UserSettingBuilder::fromValue (false),
+		    .perspective = Builders::UserSettingBuilder::fromValue (false),
+		},
+		depth
+	    );
+	} catch (const std::exception& e) {
+	    sLog.error ("Cannot load child particle ", name, ": ", e.what ());
+	}
+
+	if (particle != nullptr && particle->material == nullptr) {
+	    sLog.error ("Child particle ", name, " has no material, skipping it");
+	    particle = nullptr;
+	}
+    }
+
     return ParticleChild {
     return ParticleChild {
 	.type = type,
 	.type = type,
 	.name = name,
 	.name = name,
-	.maxCount = it.optional ("maxcount", 20),
-	.controlPointStartIndex = it.optional ("controlpointstartindex", 0),
+	.maxCount = it.optional ("maxcount", 10),
 	.probability = it.optional ("probability", 1.0f),
 	.probability = it.optional ("probability", 1.0f),
-	.angles = parseVec3 ("angles", glm::vec3 (0.0f)),
-	.origin = parseVec3 ("origin", glm::vec3 (0.0f)),
-	.scale = parseVec3 ("scale", glm::vec3 (1.0f)),
-	.particleFile = particleFile,
+	.flags = it.optional ("flags", 0u),
+	.controlPointStartIndex = it.optional ("controlpointstartindex", 0),
+	.transform = flipY * weTransform * flipY,
+	.particle = std::move (particle),
     };
     };
 }
 }
 
 
@@ -946,6 +1567,7 @@ ParticleInstanceOverride ObjectParser::parseParticleInstanceOverride (const JSON
 	.count = it.user ("count", properties, 1.0f),
 	.count = it.user ("count", properties, 1.0f),
 	.color = it.user ("color", properties, glm::vec3 (1.0f)),
 	.color = it.user ("color", properties, glm::vec3 (1.0f)),
 	.colorn = it.user ("colorn", properties, glm::vec3 (1.0f)),
 	.colorn = it.user ("colorn", properties, glm::vec3 (1.0f)),
+	.hasColor = it.optional ("colorn").has_value () || it.optional ("color").has_value (),
     };
     };
 
 
     // WE converts the legacy 0-255 "color" into colorn on load (replacing any colorn) and only reads colorn after
     // WE converts the legacy 0-255 "color" into colorn on load (replacing any colorn) and only reads colorn after

+ 7 - 2
src/WallpaperEngine/Data/Parsers/ObjectParser.h

@@ -24,9 +24,11 @@ private:
     static uint32_t parseAlignment (const std::string& alignment);
     static uint32_t parseAlignment (const std::string& alignment);
     static ImageUniquePtr
     static ImageUniquePtr
     parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
     parseImage (const JSON& it, const Project& project, ObjectData base, const std::string& image);
-    static ParticleUniquePtr parseParticle (const JSON& it, const Project& project, ObjectData base);
+    static ParticleUniquePtr parseParticle (const JSON& it, const Project& project, ObjectData base, int depth = 0);
     static TextUniquePtr parseText (const JSON& it, const Project& project, ObjectData base);
     static TextUniquePtr parseText (const JSON& it, const Project& project, ObjectData base);
     static LightUniquePtr parseLight (const JSON& it, const Project& project, ObjectData base);
     static LightUniquePtr parseLight (const JSON& it, const Project& project, ObjectData base);
+    static MeshUniquePtr parseMesh (const JSON& it, ObjectData base);
+    static SceneCameraUniquePtr parseCamera (const JSON& it, const Project& project, ObjectData base);
     static std::vector<ImageEffectUniquePtr> parseEffects (const JSON& it, const Project& project);
     static std::vector<ImageEffectUniquePtr> parseEffects (const JSON& it, const Project& project);
     static ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static std::vector<ImageEffectPassOverrideUniquePtr>
     static std::vector<ImageEffectPassOverrideUniquePtr>
@@ -41,7 +43,10 @@ private:
     static ParticleOperatorUniquePtr parseParticleOperator (const JSON& it, const Properties& properties);
     static ParticleOperatorUniquePtr parseParticleOperator (const JSON& it, const Properties& properties);
     static ParticleRenderer parseParticleRenderer (const JSON& it);
     static ParticleRenderer parseParticleRenderer (const JSON& it);
     static ParticleControlPoint parseParticleControlPoint (const JSON& it);
     static ParticleControlPoint parseParticleControlPoint (const JSON& it);
-    static ParticleChild parseParticleChild (const JSON& it, const Project& project);
+    static ParticleEventInput parseParticleEventInput (const JSON& it, ParticleEventInput fallback);
+    static ParticleChild parseParticleChild (
+	const JSON& it, const JSON& owner, const Project& project, const ObjectData& ownerBase, size_t index, int depth
+    );
     static ParticleInstanceOverride parseParticleInstanceOverride (const JSON& it, const Properties& properties);
     static ParticleInstanceOverride parseParticleInstanceOverride (const JSON& it, const Properties& properties);
 };
 };
 } // namespace WallpaperEngine::Data::Parsers
 } // namespace WallpaperEngine::Data::Parsers

+ 10 - 0
src/WallpaperEngine/Data/Parsers/TextureParser.cpp

@@ -53,6 +53,11 @@ MipmapSharedPtr TextureParser::parseMipmap (const BinaryReader& file, const Text
     result->width = file.nextUInt32 ();
     result->width = file.nextUInt32 ();
     result->height = file.nextUInt32 ();
     result->height = file.nextUInt32 ();
 
 
+    // wallpaper64.exe sub_14015C8D0, volume textures (image filter LUTs)
+    if (header.flags & TextureFlags_Volume) {
+	result->depth = file.nextUInt32 ();
+    }
+
     if (header.containerVersion == ContainerVersion_TEXB0004 || header.containerVersion == ContainerVersion_TEXB0003
     if (header.containerVersion == ContainerVersion_TEXB0004 || header.containerVersion == ContainerVersion_TEXB0003
 	|| header.containerVersion == ContainerVersion_TEXB0002) {
 	|| header.containerVersion == ContainerVersion_TEXB0002) {
 	result->compression = file.nextUInt32 ();
 	result->compression = file.nextUInt32 ();
@@ -193,6 +198,11 @@ void TextureParser::parseTextureHeader (Texture& header, const BinaryReader& fil
     header.width = file.nextUInt32 ();
     header.width = file.nextUInt32 ();
     header.height = file.nextUInt32 ();
     header.height = file.nextUInt32 ();
 
 
+    // wallpaper64.exe sub_14015C760
+    if (header.flags & TextureFlags_Volume) {
+	header.depth = file.nextUInt32 ();
+    }
+
     // ignore some more bytes
     // ignore some more bytes
     std::ignore = file.nextUInt32 ();
     std::ignore = file.nextUInt32 ();
 }
 }

+ 161 - 16
src/WallpaperEngine/Data/Parsers/WallpaperParser.cpp

@@ -6,8 +6,123 @@
 #include "WallpaperEngine/FileSystem/Container.h"
 #include "WallpaperEngine/FileSystem/Container.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Logging/Log.h"
 
 
+#include <sstream>
+
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Parsers;
 
 
+namespace {
+// vectors in camera path files are "x y z" strings read with atof, missing numbers are 0
+glm::vec3 parsePathVector (const JSON& transform, const char* key) {
+    glm::vec3 result (0.0f);
+    const auto it = transform.find (key);
+
+    if (it == transform.end () || !it->is_string ()) {
+	return result;
+    }
+
+    std::istringstream stream (it->get<std::string> ());
+
+    for (int component = 0; component < 3 && stream >> result[component]; component++) {
+    }
+
+    return result;
+}
+
+float numberOr (const JSON& data, const char* key, const float fallback) {
+    const auto it = data.find (key);
+
+    return it != data.end () && it->is_number () ? it->get<float> () : fallback;
+}
+
+bool isDisabled (const JSON& data) {
+    const auto it = data.find ("disabled");
+
+    return it != data.end () && it->is_boolean () && it->get<bool> ();
+}
+
+// one file listed under "camera" "paths" (sub_140198E20), every enabled path goes into the list
+void parseCameraPathFile (const std::string& filename, const Project& project, std::vector<CameraPath>& paths) {
+    JSON file;
+
+    try {
+	file = JSON::parseAsset (project.assetLocator->readString (filename));
+    } catch (const std::exception& e) {
+	sLog.error ("Cannot load camera path file ", filename, ": ", e.what ());
+	return;
+    }
+
+    const auto list = file.find ("paths");
+
+    if (list == file.end () || !list->is_array ()) {
+	return;
+    }
+
+    for (const auto& path : *list) {
+	if (!path.is_object ()) {
+	    break;
+	}
+
+	const auto transforms = path.find ("transforms");
+
+	// WE stops at the first path without transforms
+	if (transforms == path.end () || !transforms->is_array () || transforms->empty ()) {
+	    break;
+	}
+
+	if (isDisabled (path)) {
+	    continue;
+	}
+
+	CameraPath result { .keys = {}, .duration = numberOr (path, "duration", 0.0f) };
+	const int count = static_cast<int> (transforms->size ());
+
+	for (int index = 0; index < count; index++) {
+	    const auto& transform = (*transforms)[index];
+
+	    if (!transform.is_object () || isDisabled (transform)) {
+		continue;
+	    }
+
+	    // without a timestamp the keys spread evenly over the duration
+	    float time = 0.0f;
+
+	    if (const auto stamp = transform.find ("timestamp"); stamp != transform.end () && stamp->is_number ()) {
+		time = stamp->get<float> ();
+	    } else if (index != 0) {
+		time = static_cast<float> (index) / static_cast<float> (count - 1) * result.duration;
+	    }
+
+	    result.keys.push_back (CameraPathKey {
+		.time = time,
+		.eye = parsePathVector (transform, "eye"),
+		.center = parsePathVector (transform, "center"),
+		.up = parsePathVector (transform, "up"),
+		.zoom = numberOr (transform, "zoom", 1.0f),
+	    });
+	}
+
+	paths.push_back (std::move (result));
+    }
+}
+
+std::vector<CameraPath> parseCameraPaths (const JSON& camera, const Project& project) {
+    std::vector<CameraPath> paths;
+    const auto list = camera.find ("paths");
+
+    if (list == camera.end () || !list->is_array ()) {
+	return paths;
+    }
+
+    for (const auto& file : *list) {
+	if (file.is_string ()) {
+	    parseCameraPathFile (file.get<std::string> (), project, paths);
+	}
+    }
+
+    return paths;
+}
+} // namespace
+
 WallpaperUniquePtr WallpaperParser::parse (const JSON& file, Project& project) {
 WallpaperUniquePtr WallpaperParser::parse (const JSON& file, Project& project) {
     switch (project.type) {
     switch (project.type) {
 	case Project::Type_Scene:
 	case Project::Type_Scene:
@@ -25,10 +140,15 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
     const auto scene = JSON::parseAsset (project.assetLocator->readString (file));
     const auto scene = JSON::parseAsset (project.assetLocator->readString (file));
     const auto camera = scene.require ("camera", "Scenes must have a camera section");
     const auto camera = scene.require ("camera", "Scenes must have a camera section");
     const auto general = scene.require ("general", "Scenes must have a general section");
     const auto general = scene.require ("general", "Scenes must have a general section");
-    const auto projection
-	= general.require ("orthogonalprojection", "General section must have orthogonal projection info");
+    // null (or missing) means a perspective camera, a 3D scene
+    const auto projectionIt = general.find ("orthogonalprojection");
+    const bool perspective = projectionIt == general.end () || projectionIt->is_null ();
+    const JSON projection = perspective ? JSON::object () : JSON (*projectionIt);
     const auto objects = scene.require ("objects", "Scenes must have an objects section");
     const auto objects = scene.require ("objects", "Scenes must have an objects section");
     const auto& properties = project.properties;
     const auto& properties = project.properties;
+    // particles read it while the objects get parsed below
+    project.sceneVersion = scene.optional ("version", 0);
+    // missing keys fall back to the scene constructor's defaults in wallpaper64.exe 2.8.42 (sub_140186C90)
 
 
     return std::make_unique <Scene> (
     return std::make_unique <Scene> (
         WallpaperData {
         WallpaperData {
@@ -40,38 +160,63 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
                 .skylight = general.user ("skylightcolor", properties, glm::vec3 (0.0f)),
                 .skylight = general.user ("skylightcolor", properties, glm::vec3 (0.0f)),
                 .clear = general.user ("clearcolor", properties, glm::vec3 (1.0f)),
                 .clear = general.user ("clearcolor", properties, glm::vec3 (1.0f)),
             },
             },
+            .fog = {
+                .distanceEnabled = general.user ("fogdistance", properties, false),
+                .distanceColor = general.user ("fogdistancecolor", properties, glm::vec3 (0.0f)),
+                .distanceStart = general.user ("fogdistancestart", properties, 1.0f),
+                .distanceEnd = general.user ("fogdistanceend", properties, 5.0f),
+                .distanceStartDensity = general.user ("fogdistancestartdensity", properties, 0.0f),
+                .distanceEndDensity = general.user ("fogdistanceenddensity", properties, 1.0f),
+                .heightEnabled = general.user ("fogheight", properties, false),
+                .heightColor = general.user ("fogheightcolor", properties, glm::vec3 (0.0f)),
+                .heightStart = general.user ("fogheightstart", properties, 1.0f),
+                .heightEnd = general.user ("fogheightend", properties, -3.0f),
+                .heightStartDensity = general.user ("fogheightstartdensity", properties, 0.0f),
+                .heightEndDensity = general.user ("fogheightenddensity", properties, 1.0f),
+            },
             .camera = {
             .camera = {
                 .fade = general.user ("camerafade", properties, false),
                 .fade = general.user ("camerafade", properties, false),
                 .preview = general.optional ("camerapreview", false),
                 .preview = general.optional ("camerapreview", false),
                 .bloom = {
                 .bloom = {
                     .enabled = general.user ("bloom", properties, false),
                     .enabled = general.user ("bloom", properties, false),
-                    .strength = general.user ("bloomstrength", properties, 0.0f),
-                    .threshold = general.user ("bloomthreshold", properties, 0.0f),
+                    .strength = general.user ("bloomstrength", properties, 2.0f),
+                    .threshold = general.user ("bloomthreshold", properties, 0.65f),
+                    .hdr = general.user ("hdr", properties, false),
+                    .hdrStrength = general.user ("bloomhdrstrength", properties, 2.0f),
+                    .hdrThreshold = general.user ("bloomhdrthreshold", properties, 1.0f),
+                    .hdrFeather = general.user ("bloomhdrfeather", properties, 0.1f),
+                    .hdrScatter = general.user ("bloomhdrscatter", properties, 1.619f),
+                    .hdrIterations = general.user ("bloomhdriterations", properties, 8),
+                    .tint = general.user ("bloomtint", properties, glm::vec3 (1.0f)),
                 },
                 },
                 .parallax = {
                 .parallax = {
                     .enabled = general.user ("cameraparallax", properties, false),
                     .enabled = general.user ("cameraparallax", properties, false),
-                    .amount = general.user ("cameraparallaxamount", properties, 1.0f),
-                    .delay = general.user ("cameraparallaxdelay", properties, 0.0f),
-                    .mouseInfluence = general.user ("cameraparallaxmouseinfluence", properties, 1.0f),
+                    .amount = general.user ("cameraparallaxamount", properties, 0.5f),
+                    .delay = general.user ("cameraparallaxdelay", properties, 0.1f),
+                    .mouseInfluence = general.user ("cameraparallaxmouseinfluence", properties, 0.5f),
                 },
                 },
                 .shake = {
                 .shake = {
                     .enabled = general.user ("camerashake", properties, false),
                     .enabled = general.user ("camerashake", properties, false),
-                    .amplitude = general.user ("camerashakeamplitude", properties, 0.0f),
-                    .roughness = general.user ("camerashakeroughness", properties, 0.0f),
-                    .speed = general.user ("camerashakespeed", properties, 0.0f),
+                    .amplitude = general.user ("camerashakeamplitude", properties, 0.5f),
+                    .roughness = general.user ("camerashakeroughness", properties, 1.0f),
+                    .speed = general.user ("camerashakespeed", properties, 3.0f),
                 },
                 },
                 .configuration = {
                 .configuration = {
                     .center = camera.require <glm::vec3> ("center", "Camera must have a center position"),
                     .center = camera.require <glm::vec3> ("center", "Camera must have a center position"),
                     .eye = camera.require <glm::vec3> ("eye", "Camera must have an eye position"),
                     .eye = camera.require <glm::vec3> ("eye", "Camera must have an eye position"),
                     .up = camera.require <glm::vec3> ("up", "Camera must have an up position"),
                     .up = camera.require <glm::vec3> ("up", "Camera must have an up position"),
                 },
                 },
+                .paths = parseCameraPaths (camera, project),
                 .projection = {
                 .projection = {
-                    .width  = projection.optional ("auto", false) ? 0 : projection.require <int> ("width",  "Projection must have a width"),
-                    .height = projection.optional ("auto", false) ? 0 : projection.require <int> ("height", "Projection must have a height"),
-                    .isAuto = projection.optional ("auto", false),
-                    .nearz = camera.user ("nearz", properties, 0.0f),
-                    .farz = camera.user ("farz", properties, 1000.0f),
-                    .fov = camera.user ("fov", properties, 50.0f)
+                    .width  = perspective || projection.optional ("auto", false) ? 0 : projection.require <int> ("width",  "Projection must have a width"),
+                    .height = perspective || projection.optional ("auto", false) ? 0 : projection.require <int> ("height", "Projection must have a height"),
+                    .isAuto = perspective || projection.optional ("auto", false),
+                    .isPerspective = perspective,
+                    .nearz = general.user ("nearz", properties, 0.1f),
+                    .farz = general.user ("farz", properties, 10000.0f),
+                    .fov = general.user ("fov", properties, 50.0f),
+                    .perspectiveOverrideFov = general.user ("perspectiveoverridefov", properties, 95.0f),
+                    .zoom = general.user ("zoom", properties, 1.0f),
                 }
                 }
             },
             },
             .objects = parseObjects (objects, project),
             .objects = parseObjects (objects, project),

+ 54 - 1
src/WallpaperEngine/Maths.cpp

@@ -1,5 +1,7 @@
 #include "Maths.h"
 #include "Maths.h"
 
 
+#include <cmath>
+
 using namespace WallpaperEngine::Maths;
 using namespace WallpaperEngine::Maths;
 
 
 float WallpaperEngine::Maths::randomFloat (std::mt19937& rng, float min, float max) {
 float WallpaperEngine::Maths::randomFloat (std::mt19937& rng, float min, float max) {
@@ -29,4 +31,55 @@ float WallpaperEngine::Maths::fadeValue (float life, float startTime, float endT
 	float t = (life - startTime) / (endTime - startTime);
 	float t = (life - startTime) / (endTime - startTime);
 	return lerp (t, startValue, endValue);
 	return lerp (t, startValue, endValue);
     }
     }
-}
+}
+
+/** sub_1401B8C70 */
+glm::vec3 WallpaperEngine::Maths::hsvToRgb (const glm::vec3& hsv) {
+    const float chroma = hsv.z * hsv.y;
+    const float sector = static_cast<float> (std::fmod (static_cast<double> (hsv.x * 6.0f), 6.0));
+    const float second = static_cast<float> (
+	(1.0 - std::fabs (std::fmod (static_cast<double> (sector), 2.0) - 1.0)) * static_cast<double> (chroma)
+    );
+    const float base = hsv.z - chroma;
+
+    glm::vec3 rgb (0.0f);
+    if (sector >= 0.0f && sector < 1.0f) {
+	rgb = { chroma, second, 0.0f };
+    } else if (sector >= 1.0f && sector < 2.0f) {
+	rgb = { second, chroma, 0.0f };
+    } else if (sector >= 2.0f && sector < 3.0f) {
+	rgb = { 0.0f, chroma, second };
+    } else if (sector >= 3.0f && sector < 4.0f) {
+	rgb = { 0.0f, second, chroma };
+    } else if (sector >= 4.0f && sector < 5.0f) {
+	rgb = { second, 0.0f, chroma };
+    } else if (sector >= 5.0f && sector < 6.0f) {
+	rgb = { chroma, 0.0f, second };
+    }
+    return rgb + base;
+}
+
+/** sub_1401B8B90 */
+glm::vec3 WallpaperEngine::Maths::rgbToHsv (const glm::vec3& rgb) {
+    const float max = std::fmax (std::fmax (rgb.r, rgb.g), rgb.b);
+    const float min = std::fmin (std::fmin (rgb.r, rgb.g), rgb.b);
+    const float range = max - min;
+    glm::vec3 hsv (0.0f, 0.0f, max);
+
+    if (range < 0.0000099999997f || max <= 0.0f) {
+	return hsv;
+    }
+
+    hsv.y = range / max;
+    float hue;
+    if (rgb.r < max) {
+	hue = rgb.g < max ? (rgb.r - rgb.g) / range + 4.0f : (rgb.b - rgb.r) / range + 2.0f;
+    } else {
+	hue = (rgb.g - rgb.b) / range;
+    }
+    hsv.x = hue / 6.0f;
+    if (hsv.x < 0.0f) {
+	hsv.x += 1.0f;
+    }
+    return hsv;
+}

+ 3 - 0
src/WallpaperEngine/Maths.h

@@ -8,4 +8,7 @@ float randomFloat (std::mt19937& rng, float min, float max);
 glm::vec3 randomVec3 (std::mt19937& rng, const glm::vec3& min, const glm::vec3& max);
 glm::vec3 randomVec3 (std::mt19937& rng, const glm::vec3& min, const glm::vec3& max);
 float lerp (float t, float a, float b);
 float lerp (float t, float a, float b);
 float fadeValue (float life, float startTime, float endTime, float startValue, float endValue);
 float fadeValue (float life, float startTime, float endTime, float startValue, float endValue);
+/** wallpaper64.exe's HSV conversions, all channels 0..1 */
+glm::vec3 hsvToRgb (const glm::vec3& hsv);
+glm::vec3 rgbToHsv (const glm::vec3& rgb);
 }
 }

+ 33 - 0
src/WallpaperEngine/Render/CFBO.cpp

@@ -96,10 +96,43 @@ CFBO::CFBO (
 }
 }
 
 
 CFBO::~CFBO () {
 CFBO::~CFBO () {
+    if (this->m_depthbuffer != GL_NONE) {
+	glDeleteRenderbuffers (1, &this->m_depthbuffer);
+    }
+
     glDeleteTextures (1, &this->m_texture);
     glDeleteTextures (1, &this->m_texture);
     glDeleteFramebuffers (1, &this->m_framebuffer);
     glDeleteFramebuffers (1, &this->m_framebuffer);
 }
 }
 
 
+void CFBO::attachDepthBuffer () {
+    if (this->m_depthbuffer != GL_NONE) {
+	return;
+    }
+
+    GLint previous = 0;
+    glGetIntegerv (GL_FRAMEBUFFER_BINDING, &previous);
+
+    glGenRenderbuffers (1, &this->m_depthbuffer);
+    glBindRenderbuffer (GL_RENDERBUFFER, this->m_depthbuffer);
+    glRenderbufferStorage (
+	GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, static_cast<GLsizei> (this->m_resolution.x),
+	static_cast<GLsizei> (this->m_resolution.y)
+    );
+    glBindRenderbuffer (GL_RENDERBUFFER, 0);
+
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_framebuffer);
+    glFramebufferRenderbuffer (GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, this->m_depthbuffer);
+
+    if (glCheckFramebufferStatus (GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
+	sLog.error ("FBO ", this->m_name, " can't take a depth buffer");
+	glFramebufferRenderbuffer (GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, 0);
+	glDeleteRenderbuffers (1, &this->m_depthbuffer);
+	this->m_depthbuffer = GL_NONE;
+    }
+
+    glBindFramebuffer (GL_FRAMEBUFFER, previous);
+}
+
 const std::string& CFBO::getName () const { return this->m_name; }
 const std::string& CFBO::getName () const { return this->m_name; }
 
 
 const float& CFBO::getScale () const { return this->m_scale; }
 const float& CFBO::getScale () const { return this->m_scale; }

+ 3 - 0
src/WallpaperEngine/Render/CFBO.h

@@ -29,6 +29,9 @@ public:
      */
      */
     void setBorderColor (const glm::vec4& color) const;
     void setBorderColor (const glm::vec4& color) const;
 
 
+    /** Adds a depth buffer, only 3D scenes draw into the scene buffer with depth testing */
+    void attachDepthBuffer ();
+
     [[nodiscard]] const std::string& getName () const;
     [[nodiscard]] const std::string& getName () const;
     [[nodiscard]] const float& getScale () const;
     [[nodiscard]] const float& getScale () const;
     [[nodiscard]] TextureFormat getFormat () const override;
     [[nodiscard]] TextureFormat getFormat () const override;

+ 286 - 15
src/WallpaperEngine/Render/CWallpaper.cpp

@@ -1,6 +1,13 @@
+#include <cmath>
+#include <tuple>
 #include <vector>
 #include <vector>
 
 
+#include <stb_image.h>
+
 #include "CWallpaper.h"
 #include "CWallpaper.h"
+#include "WallpaperEngine/Assets/AssetLoadException.h"
+#include "WallpaperEngine/Data/Parsers/TextureParser.h"
+#include "WallpaperEngine/Data/Utils/BinaryReader.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
 #include "WallpaperEngine/Render/Wallpapers/CScene.h"
 #include "WallpaperEngine/Render/Wallpapers/CSplat.h"
 #include "WallpaperEngine/Render/Wallpapers/CSplat.h"
@@ -11,6 +18,7 @@
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 
 
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render;
+using WallpaperEngine::Data::Utils::BinaryReader;
 
 
 CWallpaper::CWallpaper (
 CWallpaper::CWallpaper (
     const Wallpaper& wallpaperData, RenderContext& context, AudioContext& audioContext,
     const Wallpaper& wallpaperData, RenderContext& context, AudioContext& audioContext,
@@ -59,6 +67,10 @@ CWallpaper::~CWallpaper () {
 
 
     glDeleteProgram (this->m_shader);
     glDeleteProgram (this->m_shader);
 
 
+    if (this->m_lutTexture != GL_NONE) {
+	glDeleteTextures (1, &this->m_lutTexture);
+    }
+
     glDeleteBuffers (1, &this->m_texCoordBuffer);
     glDeleteBuffers (1, &this->m_texCoordBuffer);
     glDeleteBuffers (1, &this->m_positionBuffer);
     glDeleteBuffers (1, &this->m_positionBuffer);
     glDeleteVertexArrays (1, &this->m_vaoBuffer);
     glDeleteVertexArrays (1, &this->m_vaoBuffer);
@@ -105,13 +117,83 @@ void CWallpaper::setupShaders () {
 
 
     const GLuint fragmentShaderID = glCreateShader (GL_FRAGMENT_SHADER);
     const GLuint fragmentShaderID = glCreateShader (GL_FRAGMENT_SHADER);
 
 
+    // image filter and color options are assets/shaders/ccsimple.frag
     sourcePointer = "#version 330\n"
     sourcePointer = "#version 330\n"
 		    "precision highp float;\n"
 		    "precision highp float;\n"
 		    "uniform sampler2D g_Texture0;\n"
 		    "uniform sampler2D g_Texture0;\n"
+		    "uniform sampler3D g_Texture1;\n"
+		    "uniform vec4 g_Params;\n"
+		    "uniform float g_LutParams;\n"
+		    "uniform bool u_ColorEnabled;\n"
+		    "uniform bool u_LutEnabled;\n"
+		    "uniform bool u_InputLinear;\n"
+		    "uniform bool u_OutputPQ;\n"
 		    "in vec2 v_TexCoord;\n"
 		    "in vec2 v_TexCoord;\n"
 		    "out vec4 out_FragColor;\n"
 		    "out vec4 out_FragColor;\n"
+		    // BT.2408 reference white, what the PQ image description of HDR surfaces anchors SDR content to
+		    "const float referenceWhite = 203.0;\n"
+		    "const mat3 bt709to2020 = mat3 (0.6274, 0.0691, 0.0164, 0.3293, 0.9195, 0.0880, 0.0433, 0.0114, 0.8956);\n"
+		    "const mat3 bt2020to709 = mat3 (1.6605, -0.1246, -0.0182, -0.5876, 1.1329, -0.1006, -0.0728, -0.0083, "
+		    "1.1187);\n"
+		    "vec3 srgbToLinear (vec3 c) {\n"
+		    "return mix (c / 12.92, pow ((c + 0.055) / 1.055, vec3 (2.4)), step (0.04045, c));\n"
+		    "}\n"
+		    "vec3 linearToSrgb (vec3 c) {\n"
+		    "return mix (c * 12.92, 1.055 * pow (c, vec3 (1.0 / 2.4)) - 0.055, step (0.0031308, c));\n"
+		    "}\n"
+		    // mpv decodes SDR video with BT.1886, a pure 2.4 power, going back the same way keeps SDR videos
+		    // exactly as they look without --hdr
+		    "vec3 videoToLinear (vec3 c) { return pow (c, vec3 (2.4)); }\n"
+		    "vec3 linearToVideo (vec3 c) { return pow (c, vec3 (1.0 / 2.4)); }\n"
+		    "vec3 pqEncode (vec3 nits) {\n"
+		    "vec3 y = pow (clamp (nits / 10000.0, 0.0, 1.0), vec3 (0.1593017578125));\n"
+		    "return pow ((0.8359375 + 18.8515625 * y) / (1.0 + 18.6875 * y), vec3 (78.84375));\n"
+		    "}\n"
+		    // highlights of HDR video on an SDR output, a soft knee instead of a hard clip
+		    "vec3 softClip (vec3 x) {\n"
+		    "return mix (x, 0.8 + 0.2 * (1.0 - exp (-(x - 0.8) / 0.2)), step (0.8, x));\n"
+		    "}\n"
+		    "vec3 hsv2rgb (vec3 c) {\n"
+		    "vec4 K = vec4 (1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);\n"
+		    "vec3 p = abs (fract (c.xxx + K.xyz) * 6.0 - K.www);\n"
+		    "return c.z * mix (K.xxx, clamp (p - K.xxx, 0.0, 1.0), c.y);\n"
+		    "}\n"
+		    "vec3 rgb2hsv (vec3 RGB) {\n"
+		    "vec4 P = (RGB.g < RGB.b) ? vec4 (RGB.bg, -1.0, 2.0 / 3.0) : vec4 (RGB.gb, 0.0, -1.0 / 3.0);\n"
+		    "vec4 Q = (RGB.r < P.x) ? vec4 (P.xyw, RGB.r) : vec4 (RGB.r, P.yzx);\n"
+		    "float C = Q.x - min (Q.w, Q.y);\n"
+		    "float H = abs ((Q.w - Q.y) / (6.0 * C + 1e-10) + Q.z);\n"
+		    "return vec3 (H, C / (Q.x + 1e-10), Q.x);\n"
+		    "}\n"
 		    "void main () {\n"
 		    "void main () {\n"
-		    "out_FragColor = texture (g_Texture0, v_TexCoord);\n"
+		    "vec4 albedo = texture (g_Texture0, v_TexCoord);\n"
+		    "bool filtered = u_ColorEnabled || u_LutEnabled;\n"
+		    "vec3 overbright = vec3 (0.0);\n"
+		    // the filters work on SDR-encoded values like in Wallpaper Engine, what's past reference white is kept aside
+		    "if (u_InputLinear && filtered) {\n"
+		    "vec3 light = max (bt2020to709 * albedo.rgb, 0.0);\n"
+		    "overbright = max (light - 1.0, 0.0);\n"
+		    "albedo.rgb = linearToVideo (min (light, 1.0));\n"
+		    "}\n"
+		    "if (u_ColorEnabled) {\n"
+		    "albedo.rgb = mix (vec3 (0.5), albedo.rgb, g_Params.y);\n"
+		    "vec3 hsv = rgb2hsv (albedo.rgb);\n"
+		    "hsv.z *= g_Params.x;\n"
+		    "hsv.y *= g_Params.z;\n"
+		    "hsv.x += g_Params.w;\n"
+		    "albedo.rgb = hsv2rgb (hsv);\n"
+		    "}\n"
+		    "if (u_LutEnabled) {\n"
+		    "albedo.rgb = mix (albedo.rgb, texture (g_Texture1, albedo.rgb).rgb, g_LutParams);\n"
+		    "}\n"
+		    "if (u_InputLinear || u_OutputPQ) {\n"
+		    "vec3 encoded = clamp (albedo.rgb, 0.0, 1.0);\n"
+		    "vec3 linear2020 = !u_InputLinear ? bt709to2020 * srgbToLinear (encoded)\n"
+		    "    : filtered ? bt709to2020 * (videoToLinear (encoded) + overbright) : albedo.rgb;\n"
+		    "albedo.rgb = u_OutputPQ ? pqEncode (max (linear2020, 0.0) * referenceWhite)\n"
+		    "    : linearToVideo (min (softClip (max (bt2020to709 * linear2020, 0.0)), 1.0));\n"
+		    "}\n"
+		    "out_FragColor = albedo;\n"
 		    "}";
 		    "}";
 
 
     glShaderSource (fragmentShaderID, 1, &sourcePointer, nullptr);
     glShaderSource (fragmentShaderID, 1, &sourcePointer, nullptr);
@@ -159,6 +241,13 @@ void CWallpaper::setupShaders () {
     glDeleteShader (fragmentShaderID);
     glDeleteShader (fragmentShaderID);
 
 
     this->g_Texture0 = glGetUniformLocation (this->m_shader, "g_Texture0");
     this->g_Texture0 = glGetUniformLocation (this->m_shader, "g_Texture0");
+    this->g_Texture1 = glGetUniformLocation (this->m_shader, "g_Texture1");
+    this->g_Params = glGetUniformLocation (this->m_shader, "g_Params");
+    this->g_LutParams = glGetUniformLocation (this->m_shader, "g_LutParams");
+    this->u_ColorEnabled = glGetUniformLocation (this->m_shader, "u_ColorEnabled");
+    this->u_LutEnabled = glGetUniformLocation (this->m_shader, "u_LutEnabled");
+    this->u_InputLinear = glGetUniformLocation (this->m_shader, "u_InputLinear");
+    this->u_OutputPQ = glGetUniformLocation (this->m_shader, "u_OutputPQ");
     this->a_Position = glGetAttribLocation (this->m_shader, "a_Position");
     this->a_Position = glGetAttribLocation (this->m_shader, "a_Position");
     this->a_TexCoord = glGetAttribLocation (this->m_shader, "a_TexCoord");
     this->a_TexCoord = glGetAttribLocation (this->m_shader, "a_TexCoord");
 }
 }
@@ -216,8 +305,13 @@ void CWallpaper::render (
 
 
 	// this viewport's relative position within the bounding box [0..1]; logicalSize is in the
 	// this viewport's relative position within the bounding box [0..1]; logicalSize is in the
 	// same coordinate space as globalPosition and totalBounds
 	// same coordinate space as globalPosition and totalBounds
-	const float relLeft = (static_cast<float> (globalPosition.x) - spanX) / spanW;
-	const float relRight = (static_cast<float> (globalPosition.x + logicalSize.x) - spanX) / spanW;
+	float relLeft = (static_cast<float> (globalPosition.x) - spanX) / spanW;
+	float relRight = (static_cast<float> (globalPosition.x + logicalSize.x) - spanX) / spanW;
+
+	// a flipped span mirrors the whole group, the leftmost screen shows the right end of the image
+	if (this->m_flipHorizontal) {
+	    std::tie (relLeft, relRight) = std::make_pair (1.0f - relLeft, 1.0f - relRight);
+	}
 	const float relTop = (static_cast<float> (globalPosition.y) - spanY) / spanH;
 	const float relTop = (static_cast<float> (globalPosition.y) - spanY) / spanH;
 	const float relBottom = (static_cast<float> (globalPosition.y + logicalSize.y) - spanY) / spanH;
 	const float relBottom = (static_cast<float> (globalPosition.y + logicalSize.y) - spanY) / spanH;
 
 
@@ -244,6 +338,10 @@ void CWallpaper::render (
 	auto uvs = this->m_state.getTextureUVs ();
 	auto uvs = this->m_state.getTextureUVs ();
 	ustart = uvs.ustart;
 	ustart = uvs.ustart;
 	uend = uvs.uend;
 	uend = uvs.uend;
+
+	if (this->m_flipHorizontal) {
+	    std::swap (ustart, uend);
+	}
 	vstart = uvs.vstart;
 	vstart = uvs.vstart;
 	vend = uvs.vend;
 	vend = uvs.vend;
     }
     }
@@ -258,14 +356,6 @@ void CWallpaper::render (
 
 
     glBindVertexArray (this->m_vaoBuffer);
     glBindVertexArray (this->m_vaoBuffer);
 
 
-    glDisable (GL_BLEND);
-    glDisable (GL_DEPTH_TEST);
-    glDisable (GL_CULL_FACE);
-    glUseProgram (this->m_shader);
-    glActiveTexture (GL_TEXTURE0);
-    glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
-    glUniform1i (this->g_Texture0, 0);
-
     if (ustart != this->m_uploadedUstart || uend != this->m_uploadedUend || vstart != this->m_uploadedVstart
     if (ustart != this->m_uploadedUstart || uend != this->m_uploadedUend || vstart != this->m_uploadedVstart
 	|| vend != this->m_uploadedVend) {
 	|| vend != this->m_uploadedVend) {
 	glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
 	glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
@@ -276,7 +366,7 @@ void CWallpaper::render (
 	this->m_uploadedVend = vend;
 	this->m_uploadedVend = vend;
     }
     }
 
 
-    glDrawArrays (GL_TRIANGLES, 0, 6);
+    this->drawOutputQuad ();
 
 
     if (this->getContext ().getApp ().getContext ().settings.render.debug.brightnessLog) {
     if (this->getContext ().getApp ().getContext ().settings.render.debug.brightnessLog) {
 	static uint32_t brightnessFrameCounter = 0;
 	static uint32_t brightnessFrameCounter = 0;
@@ -299,20 +389,55 @@ void CWallpaper::render (
 #endif /* !NDEBUG */
 #endif /* !NDEBUG */
 }
 }
 
 
+void CWallpaper::drawOutputQuad () {
+    glDisable (GL_BLEND);
+    glDisable (GL_DEPTH_TEST);
+    glDisable (GL_CULL_FACE);
+    glUseProgram (this->m_shader);
+    glActiveTexture (GL_TEXTURE0);
+    glBindTexture (GL_TEXTURE_2D, this->getWallpaperTexture ());
+    glUniform1i (this->g_Texture0, 0);
+    // a sampler2D and a sampler3D on the same unit is an invalid draw even when the LUT is never read
+    glUniform1i (this->g_Texture1, 1);
+
+    const bool lutEnabled = this->m_lutTexture != GL_NONE && this->m_lutStrength > 0.0f;
+
+    glUniform1i (this->u_ColorEnabled, this->m_colorEnabled);
+    glUniform1i (this->u_LutEnabled, lutEnabled);
+    glUniform1i (this->u_InputLinear, this->m_linearInput);
+    glUniform1i (this->u_OutputPQ, this->m_outputHDR);
+    glUniform4fv (this->g_Params, 1, &this->m_colorParams.x);
+    glUniform1f (this->g_LutParams, this->m_lutStrength);
+
+    if (lutEnabled) {
+	glActiveTexture (GL_TEXTURE1);
+	glBindTexture (GL_TEXTURE_3D, this->m_lutTexture);
+	glActiveTexture (GL_TEXTURE0);
+    }
+
+    glDrawArrays (GL_TRIANGLES, 0, 6);
+}
+
 void CWallpaper::setPause (bool newState) { }
 void CWallpaper::setPause (bool newState) { }
 
 
 void CWallpaper::setAudioPolicy (bool muted, std::optional<int> ambientVolume) { }
 void CWallpaper::setAudioPolicy (bool muted, std::optional<int> ambientVolume) { }
 
 
-void CWallpaper::setupFramebuffers () {
+void CWallpaper::setupFramebuffers (const bool depth, const TextureFormat format) {
     const uint32_t width = this->getCanvasWidth ();
     const uint32_t width = this->getCanvasWidth ();
     const uint32_t height = this->getCanvasHeight ();
     const uint32_t height = this->getCanvasHeight ();
     const uint32_t clamp = this->m_state.getClampingMode ();
     const uint32_t clamp = this->m_state.getClampingMode ();
 
 
-    this->m_sceneFBO = this->create (
-	"_rt_FullFrameBuffer", TextureFormat_ARGB8888, clamp, 1.0, { width, height }, { width, height },
+    const auto sceneFBO = this->create (
+	"_rt_FullFrameBuffer", format, clamp, 1.0, { width, height }, { width, height },
 	this->m_cornerColor
 	this->m_cornerColor
     );
     );
 
 
+    if (depth) {
+	sceneFBO->attachDepthBuffer ();
+    }
+
+    this->m_sceneFBO = sceneFBO;
+
     this->alias ("_rt_MipMappedFrameBuffer", "_rt_FullFrameBuffer");
     this->alias ("_rt_MipMappedFrameBuffer", "_rt_FullFrameBuffer");
 }
 }
 
 
@@ -334,6 +459,152 @@ void CWallpaper::setCornerColor (const glm::vec4& color) {
     }
     }
 }
 }
 
 
+void CWallpaper::setImageAdjustments (const ImageAdjustments& adjustments) {
+    // wallpaper64.exe sub_140181F30: sliders are 0-100 around 50, contrast and saturation go through a square
+    // root and brightness through a square before reaching ccsimple
+    const float brightness = adjustments.brightness.value_or (50.0f) / 50.0f;
+    const float contrast = adjustments.contrast.value_or (50.0f) / 50.0f;
+    const float saturation = adjustments.saturation.value_or (50.0f) / 50.0f;
+    const float hue = adjustments.hue.value_or (50.0f) / 100.0f - 0.5f;
+
+    this->m_colorEnabled = adjustments.colorEnabled.value_or (false)
+	&& (contrast != 1.0f || brightness != 1.0f || saturation != 1.0f || hue != 0.0f);
+    this->m_colorParams = {
+	std::pow (brightness, 2.0f), std::pow (contrast, 0.5f), std::pow (saturation, 0.5f), hue
+    };
+    this->m_lutStrength = adjustments.filterStrength.value_or (100.0f) / 100.0f;
+    this->m_flipHorizontal = adjustments.flipHorizontal.value_or (false);
+
+    // only scenes and videos have these in Wallpaper Engine
+    if (this->is<Wallpapers::CWeb> ()) {
+	this->m_colorEnabled = false;
+	this->m_flipHorizontal = false;
+	this->loadLut ("");
+	return;
+    }
+
+    this->loadLut (adjustments.filter.value_or (""));
+}
+
+bool CWallpaper::hasImageAdjustments () const {
+    return this->m_colorEnabled || this->m_flipHorizontal
+	|| (this->m_lutTexture != GL_NONE && this->m_lutStrength > 0.0f);
+}
+
+GLuint CWallpaper::renderAdjustedFramebuffer () {
+    // a linear (HDR video) framebuffer needs encoding to sRGB even without adjustments
+    if (!this->hasImageAdjustments () && !this->m_linearInput) {
+	return this->getWallpaperFramebuffer ();
+    }
+
+    const auto width = static_cast<uint32_t> (this->getCanvasWidth ());
+    const auto height = static_cast<uint32_t> (this->getCanvasHeight ());
+
+    if (this->m_adjustedFBO == nullptr || this->m_adjustedFBO->getRealWidth () != width
+	|| this->m_adjustedFBO->getRealHeight () != height) {
+	this->m_adjustedFBO = std::make_unique<CFBO> (
+	    "_rt_ImageAdjustments", TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0f, width, height, width, height
+	);
+    }
+
+    // the output quad puts v=0 at the top, the framebuffer keeps row 0 at the bottom
+    const float ustart = this->m_flipHorizontal ? 1.0f : 0.0f;
+    const float uend = 1.0f - ustart;
+    const GLfloat texCoords[] = { ustart, 1.0f, uend, 1.0f, ustart, 0.0f, ustart, 0.0f, uend, 1.0f, uend, 0.0f };
+
+    // render() uploads its own UVs again next frame
+    this->m_uploadedUstart = std::numeric_limits<float>::quiet_NaN ();
+
+    const GLuint previousDestination = this->m_destFramebuffer;
+    const bool outputHDR = this->m_outputHDR;
+    this->m_destFramebuffer = this->m_adjustedFBO->getFramebuffer ();
+    // screenshots are always SDR
+    this->m_outputHDR = false;
+
+    glViewport (0, 0, static_cast<GLsizei> (width), static_cast<GLsizei> (height));
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_destFramebuffer);
+    glBindVertexArray (this->m_vaoBuffer);
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
+    glBufferSubData (GL_ARRAY_BUFFER, 0, sizeof (texCoords), texCoords);
+    this->drawOutputQuad ();
+
+    this->m_destFramebuffer = previousDestination;
+    this->m_outputHDR = outputHDR;
+
+    return this->m_adjustedFBO->getFramebuffer ();
+}
+
+void CWallpaper::loadLut (const std::string& name) {
+    if (name == this->m_lutName) {
+	return;
+    }
+
+    this->m_lutName = name;
+
+    if (this->m_lutTexture != GL_NONE) {
+	glDeleteTextures (1, &this->m_lutTexture);
+	this->m_lutTexture = GL_NONE;
+    }
+
+    if (name.empty ()) {
+	return;
+    }
+
+    try {
+	const auto stream = this->getAssetLocator ().texture ("lut/" + name);
+	const auto texture = WallpaperEngine::Data::Parsers::TextureParser::parse (BinaryReader (stream));
+	const auto& mipmap = texture->images.at (0).at (0);
+
+	if (!(texture->flags & TextureFlags_Volume)) {
+	    sLog.error ("Image filter ", name, " is not a volume texture");
+	    return;
+	}
+
+	stbi_uc* decoded = nullptr;
+	const void* pixels = mipmap->uncompressedData.get ();
+	const size_t expected = static_cast<size_t> (mipmap->width) * mipmap->height * mipmap->depth * 4;
+
+	// the z slices are stacked vertically in the image, the order glTexImage3D wants
+	if (texture->freeImageFormat != FIF_UNKNOWN) {
+	    int width, height, channels;
+
+	    decoded = stbi_load_from_memory (
+		reinterpret_cast<const stbi_uc*> (mipmap->uncompressedData.get ()), mipmap->uncompressedSize, &width,
+		&height, &channels, 4
+	    );
+
+	    if (decoded == nullptr || static_cast<size_t> (width) * height * 4 != expected) {
+		stbi_image_free (decoded);
+		sLog.error ("Image filter ", name, " has unexpected image data");
+		return;
+	    }
+
+	    pixels = decoded;
+	} else if (texture->format != TextureFormat_ARGB8888 || static_cast<size_t> (mipmap->uncompressedSize) < expected) {
+	    sLog.error ("Image filter ", name, " has an unsupported format");
+	    return;
+	}
+
+	glGenTextures (1, &this->m_lutTexture);
+	glBindTexture (GL_TEXTURE_3D, this->m_lutTexture);
+	glTexParameteri (GL_TEXTURE_3D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+	glTexParameteri (GL_TEXTURE_3D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+	glTexParameteri (GL_TEXTURE_3D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+	glTexParameteri (GL_TEXTURE_3D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+	glTexParameteri (GL_TEXTURE_3D, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
+	glPixelStorei (GL_UNPACK_ALIGNMENT, 4);
+	glTexImage3D (
+	    GL_TEXTURE_3D, 0, GL_RGBA8, mipmap->width, mipmap->height, mipmap->depth, 0, GL_RGBA, GL_UNSIGNED_BYTE,
+	    pixels
+	);
+	glBindTexture (GL_TEXTURE_3D, 0);
+
+	stbi_image_free (decoded);
+    } catch (const std::exception& e) {
+	sLog.error ("Cannot load image filter ", name, ": ", e.what ());
+    }
+}
+
 std::shared_ptr<const CFBO> CWallpaper::findFBO (const std::string& name) const {
 std::shared_ptr<const CFBO> CWallpaper::findFBO (const std::string& name) const {
     const auto fbo = this->find (name);
     const auto fbo = this->find (name);
 
 

+ 39 - 1
src/WallpaperEngine/Render/CWallpaper.h

@@ -12,6 +12,7 @@
 #include "WallpaperEngine/Render/Helpers/ContextAware.h"
 #include "WallpaperEngine/Render/Helpers/ContextAware.h"
 #include "WallpaperEngine/Render/RenderContext.h"
 #include "WallpaperEngine/Render/RenderContext.h"
 
 
+#include "WallpaperEngine/Data/Model/ImageAdjustments.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Media/MediaSource.h"
 #include "WallpaperEngine/Media/MediaSource.h"
 
 
@@ -80,6 +81,21 @@ public:
      */
      */
     void setCornerColor (const glm::vec4& color);
     void setCornerColor (const glm::vec4& color);
 
 
+    /**
+     * Wallpaper Engine's image filter, color options and flip, applied live on the way to the screen like its
+     * materials/util/ccsimple.json pass. Unset fields take Wallpaper Engine's defaults
+     */
+    void setImageAdjustments (const ImageAdjustments& adjustments);
+    [[nodiscard]] bool isFlippedHorizontally () const { return this->m_flipHorizontal; }
+    [[nodiscard]] bool hasImageAdjustments () const;
+    /** The viewport about to be drawn is a PQ surface (--hdr on an output in HDR mode) */
+    void setOutputHDR (bool hdr) { this->m_outputHDR = hdr; }
+    /**
+     * The last frame with the image adjustments applied, same size and orientation as the wallpaper's own
+     * framebuffer (what screenshots read). Just the wallpaper's framebuffer when nothing is adjusted
+     */
+    [[nodiscard]] GLuint renderAdjustedFramebuffer ();
+
     [[nodiscard]] virtual GLuint getWallpaperFramebuffer () const;
     [[nodiscard]] virtual GLuint getWallpaperFramebuffer () const;
     [[nodiscard]] virtual GLuint getWallpaperTexture () const;
     [[nodiscard]] virtual GLuint getWallpaperTexture () const;
     [[nodiscard]] std::shared_ptr<const CFBO> findFBO (const std::string& name) const;
     [[nodiscard]] std::shared_ptr<const CFBO> findFBO (const std::string& name) const;
@@ -115,7 +131,9 @@ protected:
 
 
     virtual void renderFrame (const glm::ivec4& viewport) = 0;
     virtual void renderFrame (const glm::ivec4& viewport) = 0;
 
 
-    void setupFramebuffers ();
+    void setupFramebuffers (bool depth = false, TextureFormat format = TextureFormat_ARGB8888);
+    /** The framebuffer holds linear light with BT.2020 primaries, 1.0 being reference white (HDR video) */
+    void setLinearInput (bool linear) { this->m_linearInput = linear; }
 
 
     const Wallpaper& m_wallpaperData;
     const Wallpaper& m_wallpaperData;
 
 
@@ -130,6 +148,11 @@ private:
     GLuint m_positionBuffer = GL_NONE;
     GLuint m_positionBuffer = GL_NONE;
     GLuint m_shader = GL_NONE;
     GLuint m_shader = GL_NONE;
     GLint g_Texture0 = GL_NONE;
     GLint g_Texture0 = GL_NONE;
+    GLint g_Texture1 = GL_NONE;
+    GLint g_Params = GL_NONE;
+    GLint g_LutParams = GL_NONE;
+    GLint u_ColorEnabled = GL_NONE;
+    GLint u_LutEnabled = GL_NONE;
     GLint a_Position = GL_NONE;
     GLint a_Position = GL_NONE;
     GLint a_TexCoord = GL_NONE;
     GLint a_TexCoord = GL_NONE;
     GLuint m_destFramebuffer = GL_NONE;
     GLuint m_destFramebuffer = GL_NONE;
@@ -144,6 +167,21 @@ private:
     AudioContext& m_audioContext;
     AudioContext& m_audioContext;
     WallpaperState m_state;
     WallpaperState m_state;
     glm::vec4 m_cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f };
     glm::vec4 m_cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f };
+    /** ccsimple's g_Params: brightness, contrast and saturation multipliers and the hue shift */
+    glm::vec4 m_colorParams = { 1.0f, 1.0f, 1.0f, 0.0f };
+    bool m_colorEnabled = false;
+    float m_lutStrength = 0.0f;
+    std::string m_lutName;
+    GLuint m_lutTexture = GL_NONE;
+    std::unique_ptr<CFBO> m_adjustedFBO;
+    bool m_flipHorizontal = false;
+    bool m_outputHDR = false;
+    bool m_linearInput = false;
+    GLint u_InputLinear = GL_NONE;
+    GLint u_OutputPQ = GL_NONE;
+    void loadLut (const std::string& name);
+    /** Draws the wallpaper's texture with the image adjustments over the bound framebuffer, UVs already uploaded */
+    void drawOutputQuad ();
     std::optional<SpanInfo> m_spanInfo = std::nullopt;
     std::optional<SpanInfo> m_spanInfo = std::nullopt;
     // Avoids redundant renderFrame calls when the same wallpaper is shared across viewports (span mode)
     // Avoids redundant renderFrame calls when the same wallpaper is shared across viewports (span mode)
     uint32_t m_lastRenderedFrame = UINT32_MAX;
     uint32_t m_lastRenderedFrame = UINT32_MAX;

+ 123 - 10
src/WallpaperEngine/Render/Camera.cpp

@@ -1,3 +1,6 @@
+#include <algorithm>
+#include <cmath>
+
 #include <glm/glm.hpp>
 #include <glm/glm.hpp>
 #include <glm/gtc/matrix_transform.hpp>
 #include <glm/gtc/matrix_transform.hpp>
 
 
@@ -7,7 +10,7 @@ using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
 using namespace WallpaperEngine::Render;
 
 
 Camera::Camera (Wallpapers::CScene& scene, const SceneData::Camera& camera) :
 Camera::Camera (Wallpapers::CScene& scene, const SceneData::Camera& camera) :
-    m_width (0), m_height (0), m_camera (camera), m_scene (scene) {
+    m_width (0), m_height (0), m_eye (camera.configuration.eye), m_camera (camera), m_scene (scene) {
     // WE builds this view matrix for orthographic cameras too (sub_140159080)
     // WE builds this view matrix for orthographic cameras too (sub_140159080)
     this->m_lookat = glm::lookAt (this->getEye (), this->getCenter (), this->getUp ());
     this->m_lookat = glm::lookAt (this->getEye (), this->getCenter (), this->getUp ());
 }
 }
@@ -16,7 +19,9 @@ Camera::~Camera () = default;
 
 
 const glm::vec3& Camera::getCenter () const { return this->m_camera.configuration.center; }
 const glm::vec3& Camera::getCenter () const { return this->m_camera.configuration.center; }
 
 
-const glm::vec3& Camera::getEye () const { return this->m_camera.configuration.eye; }
+const glm::vec3& Camera::getEye () const { return this->m_eye; }
+
+const glm::vec3& Camera::getConfiguredEye () const { return this->m_camera.configuration.eye; }
 
 
 const glm::vec3& Camera::getUp () const { return this->m_camera.configuration.up; }
 const glm::vec3& Camera::getUp () const { return this->m_camera.configuration.up; }
 
 
@@ -24,8 +29,18 @@ const glm::mat4& Camera::getProjection () const { return this->m_projection; }
 
 
 const glm::mat4& Camera::getLookAt () const { return this->m_lookat; }
 const glm::mat4& Camera::getLookAt () const { return this->m_lookat; }
 
 
+const glm::mat4& Camera::getWorldView () const { return this->m_worldView; }
+
+const glm::mat4& Camera::getPerspectiveLayerViewProjection () const { return this->m_perspectiveLayer; }
+
 bool Camera::isOrthogonal () const { return this->m_isOrthogonal; }
 bool Camera::isOrthogonal () const { return this->m_isOrthogonal; }
 
 
+bool Camera::isPerspective () const { return this->m_isPerspective; }
+
+const glm::mat4& Camera::getPerspective () const { return this->m_perspective; }
+
+const glm::mat4& Camera::getView () const { return this->m_view; }
+
 Wallpapers::CScene& Camera::getScene () const { return this->m_scene; }
 Wallpapers::CScene& Camera::getScene () const { return this->m_scene; }
 
 
 float Camera::getWidth () const { return this->m_width; }
 float Camera::getWidth () const { return this->m_width; }
@@ -50,13 +65,111 @@ void Camera::setOrthogonalProjection (
     this->m_canvasWidth = canvasWidth > 0.0f ? canvasWidth : width;
     this->m_canvasWidth = canvasWidth > 0.0f ? canvasWidth : width;
     this->m_canvasHeight = canvasHeight > 0.0f ? canvasHeight : height;
     this->m_canvasHeight = canvasHeight > 0.0f ? canvasHeight : height;
 
 
-    float nearz = this->m_camera.projection.nearz->value->getFloat ();
-    float farz = this->m_camera.projection.farz->value->getFloat ();
-
-    this->m_projection = glm::ortho<float> (
-	-this->m_canvasWidth / 2.0, this->m_canvasWidth / 2.0, -this->m_canvasHeight / 2.0, this->m_canvasHeight / 2.0,
-	nearz, farz
+    // WE's 2D projection keeps -2000..2000 in depth (sub_140183A70), rotated layers and particles poke out of z = 0
+    this->m_orthogonal = glm::ortho<float> (
+	-this->m_canvasWidth / 2.0f, this->m_canvasWidth / 2.0f, -this->m_canvasHeight / 2.0f,
+	this->m_canvasHeight / 2.0f, -2000.0f, 2000.0f
     );
     );
-    this->m_projection = glm::translate (this->m_projection, this->getEye ());
     this->m_isOrthogonal = true;
     this->m_isOrthogonal = true;
-}
+    this->setZoom (1.0f);
+    this->setWorldView (glm::mat4 (1.0f));
+    this->updatePerspectiveLayers ({ 0.0f, 1.0f, 0.0f, 1.0f }, this->m_canvasWidth / this->m_canvasHeight);
+}
+
+void Camera::setPerspectiveProjection (const float width, const float height) {
+    this->m_width = width;
+    this->m_height = height;
+    this->m_canvasWidth = width;
+    this->m_canvasHeight = height;
+
+    this->m_projection = glm::ortho<float> (-width / 2.0f, width / 2.0f, -height / 2.0f, height / 2.0f, 0.0f, 1000.0f);
+    this->m_view = this->m_lookat;
+    this->m_lookat = glm::mat4 (1.0f);
+
+    // XMMatrixPerspectiveFovRH with the vertical fov in degrees and the render target's aspect (sub_140146660)
+    const float fov = glm::clamp (this->getFov (), 0.1f, 179.9f);
+    this->m_perspective = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f))
+	* glm::perspective (glm::radians (fov), width / height, this->getNearZ (), this->getFarZ ());
+    this->m_isPerspective = true;
+}
+namespace {
+// the y flip between WE's world (y up) and the y down space 2D objects are laid out in here
+const glm::mat4 kFlipY = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f));
+} // namespace
+
+void Camera::setWorldView (const glm::mat4& view) {
+    this->m_worldView = view;
+
+    if (!this->m_isOrthogonal) {
+	return;
+    }
+
+    // WE's 2D world has its origin at the bottom left corner, the layout here is centered
+    const glm::vec3 center (this->m_width / 2.0f, this->m_height / 2.0f, 0.0f);
+    this->m_lookat = kFlipY * glm::translate (glm::mat4 (1.0f), -center) * view
+	* glm::translate (glm::mat4 (1.0f), center) * kFlipY;
+}
+
+void Camera::setZoom (const float zoom) {
+    this->m_zoom = zoom;
+    this->m_projection = this->m_orthogonal * glm::scale (glm::mat4 (1.0f), glm::vec3 (zoom, zoom, 1.0f));
+}
+
+void Camera::setPerspectiveView (const glm::mat4& view, const float fov) {
+    this->m_worldView = view;
+    this->m_eye = glm::vec3 (glm::inverse (view)[3]);
+    this->m_view = view;
+    this->m_perspective = glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f))
+	* glm::perspective (glm::radians (glm::clamp (fov, 0.1f, 179.9f)), this->m_width / this->m_height,
+			    this->getNearZ (), this->getFarZ ());
+}
+
+void Camera::updatePerspectiveLayers (const glm::vec4& uvs, const float viewportAspect) {
+    if (!this->m_isOrthogonal) {
+	return;
+    }
+
+    const float width = this->m_width;
+    const float height = this->m_height;
+    const float canvasWidth = this->m_canvasWidth;
+    const float canvasHeight = this->m_canvasHeight;
+
+    // the visible window of the scene in WE's world units: what WE's own ortho projection covers on screen
+    float visibleWidth = std::abs (uvs.y - uvs.x) * canvasWidth;
+    float visibleHeight = std::abs (uvs.w - uvs.z) * canvasHeight;
+
+    if (visibleWidth <= 0.0f || visibleHeight <= 0.0f) {
+	visibleWidth = canvasWidth;
+	visibleHeight = canvasHeight;
+    }
+
+    const float centerX = width / 2.0f + ((uvs.x + uvs.y) / 2.0f - 0.5f) * canvasWidth;
+    const float centerY = height / 2.0f - ((uvs.z + uvs.w) / 2.0f - 0.5f) * canvasHeight;
+
+    // sub_1401E5B60: the camera backs off until the visible height fills the fov, near 5, far max (15000, d + 1000)
+    const float fov = glm::radians (glm::clamp (this->m_camera.projection.perspectiveOverrideFov->value->getFloat (), 0.1f, 179.9f));
+    // the zoom is part of the ortho projection WE derives the distance from
+    const float distance = visibleHeight / (2.0f * this->m_zoom * std::tan (fov / 2.0f));
+    const float nearZ = 5.0f;
+    const float farZ = std::max (15000.0f, distance + 1000.0f);
+
+    glm::mat4 view = this->m_worldView;
+    view[3][0] -= centerX;
+    view[3][1] -= centerY;
+    view[3][2] = -distance;
+
+    // WE's frustum covers the output only, with the output's aspect; this one covers the whole scene buffer the
+    // output window is cut from later, scaled so the visible part lands where WE puts it
+    const float aspectScale = viewportAspect * visibleHeight / visibleWidth;
+    const float scale = nearZ / (distance * this->m_zoom);
+    const glm::mat4 projection = glm::frustum (
+	(width / 2.0f - canvasWidth / 2.0f - centerX) * aspectScale * scale,
+	(width / 2.0f + canvasWidth / 2.0f - centerX) * aspectScale * scale,
+	(height / 2.0f - canvasHeight / 2.0f - centerY) * scale, (height / 2.0f + canvasHeight / 2.0f - centerY) * scale,
+	nearZ, farZ
+    );
+
+    const glm::vec3 center (width / 2.0f, height / 2.0f, 0.0f);
+    this->m_perspectiveLayer
+	= kFlipY * projection * view * glm::translate (glm::mat4 (1.0f), center) * kFlipY;
+}

+ 38 - 0
src/WallpaperEngine/Render/Camera.h

@@ -26,14 +26,25 @@ public:
      * @param canvasHeight Same as canvasWidth, for the height
      * @param canvasHeight Same as canvasWidth, for the height
      */
      */
     void setOrthogonalProjection (float width, float height, float canvasWidth = 0.0f, float canvasHeight = 0.0f);
     void setOrthogonalProjection (float width, float height, float canvasWidth = 0.0f, float canvasHeight = 0.0f);
+    /**
+     * 3D scenes: the scene camera becomes a perspective one (getPerspective/getView), getProjection and
+     * getLookAt turn into a plain screen space camera for the 2D layers (fullscreen post processing, bloom)
+     */
+    void setPerspectiveProjection (float width, float height);
 
 
     [[nodiscard]] const glm::vec3& getCenter () const;
     [[nodiscard]] const glm::vec3& getCenter () const;
     [[nodiscard]] const glm::vec3& getEye () const;
     [[nodiscard]] const glm::vec3& getEye () const;
+    /** The scene.json eye, getEye () follows camera objects in 3D scenes */
+    [[nodiscard]] const glm::vec3& getConfiguredEye () const;
     [[nodiscard]] const glm::vec3& getUp () const;
     [[nodiscard]] const glm::vec3& getUp () const;
     [[nodiscard]] const glm::mat4& getProjection () const;
     [[nodiscard]] const glm::mat4& getProjection () const;
     [[nodiscard]] const glm::mat4& getLookAt () const;
     [[nodiscard]] const glm::mat4& getLookAt () const;
     [[nodiscard]] Wallpapers::CScene& getScene () const;
     [[nodiscard]] Wallpapers::CScene& getScene () const;
     [[nodiscard]] bool isOrthogonal () const;
     [[nodiscard]] bool isOrthogonal () const;
+    [[nodiscard]] bool isPerspective () const;
+    /** Perspective projection of a 3D scene, flipped vertically like everything else drawn into the scene buffer */
+    [[nodiscard]] const glm::mat4& getPerspective () const;
+    [[nodiscard]] const glm::mat4& getView () const;
     [[nodiscard]] float getWidth () const;
     [[nodiscard]] float getWidth () const;
     [[nodiscard]] float getHeight () const;
     [[nodiscard]] float getHeight () const;
     [[nodiscard]] float getCanvasWidth () const;
     [[nodiscard]] float getCanvasWidth () const;
@@ -42,14 +53,41 @@ public:
     [[nodiscard]] float getNearZ () const;
     [[nodiscard]] float getNearZ () const;
     [[nodiscard]] float getFarZ () const;
     [[nodiscard]] float getFarZ () const;
 
 
+    /**
+     * Per frame view from a camera object, in WE's world space (y up, origin at the bottom left of the scene).
+     * 2D scenes ignore the scene.json eye (WE resets it), so without a camera object this is the identity
+     */
+    void setWorldView (const glm::mat4& view);
+    /** 2D scenes: general zoom times the camera object's zoom, scales around the scene center (sub_14017FA70) */
+    void setZoom (float zoom);
+    /** 3D scenes: a camera object replaces the scene.json eye and fov */
+    void setPerspectiveView (const glm::mat4& view, float fov);
+    [[nodiscard]] const glm::mat4& getWorldView () const;
+    /**
+     * Rebuilds the camera of layers with "perspective" set (sub_1401E5B60) for the part of the scene that ends up
+     * on screen: uvs are the output's texture window over the scene buffer (ustart, uend, vstart, vend)
+     */
+    void updatePerspectiveLayers (const glm::vec4& uvs, float viewportAspect);
+    /** View projection of "perspective" layers, same space as getProjection () * getLookAt () */
+    [[nodiscard]] const glm::mat4& getPerspectiveLayerViewProjection () const;
+
 private:
 private:
     float m_width;
     float m_width;
     float m_height;
     float m_height;
+    /** 3D scenes: where the view puts the eye, the scene.json one or a camera object's */
+    glm::vec3 m_eye;
     float m_canvasWidth = 0.0f;
     float m_canvasWidth = 0.0f;
     float m_canvasHeight = 0.0f;
     float m_canvasHeight = 0.0f;
     bool m_isOrthogonal = false;
     bool m_isOrthogonal = false;
+    bool m_isPerspective = false;
+    glm::mat4 m_perspective = {};
+    glm::mat4 m_view = {};
     glm::mat4 m_projection = {};
     glm::mat4 m_projection = {};
     glm::mat4 m_lookat = {};
     glm::mat4 m_lookat = {};
+    glm::mat4 m_worldView = glm::mat4 (1.0f);
+    glm::mat4 m_orthogonal = {};
+    float m_zoom = 1.0f;
+    glm::mat4 m_perspectiveLayer = {};
     const SceneData::Camera& m_camera;
     const SceneData::Camera& m_camera;
     Wallpapers::CScene& m_scene;
     Wallpapers::CScene& m_scene;
 };
 };

+ 198 - 0
src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.cpp

@@ -0,0 +1,198 @@
+#include "HeadlessOpenGLDriver.h"
+#include "VideoFactories.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/Drivers/Output/GLFWWindowOutput.h"
+
+#include <EGL/eglext.h>
+#include <cstdlib>
+#include <algorithm>
+#include <cstring>
+#include <filesystem>
+#include <fstream>
+#include <string>
+#include <vector>
+
+using namespace WallpaperEngine::Render::Drivers;
+
+namespace {
+EGLDisplay openDisplay () {
+    const auto queryDevices = reinterpret_cast<PFNEGLQUERYDEVICESEXTPROC> (eglGetProcAddress ("eglQueryDevicesEXT"));
+    const auto queryDeviceString
+	= reinterpret_cast<PFNEGLQUERYDEVICESTRINGEXTPROC> (eglGetProcAddress ("eglQueryDeviceStringEXT"));
+    const auto getPlatformDisplay
+	= reinterpret_cast<PFNEGLGETPLATFORMDISPLAYEXTPROC> (eglGetProcAddress ("eglGetPlatformDisplayEXT"));
+
+    if (queryDevices == nullptr || queryDeviceString == nullptr || getPlatformDisplay == nullptr) {
+	sLog.exception ("EGL device enumeration is not available, cannot render headless");
+    }
+
+    EGLint count = 0;
+    queryDevices (0, nullptr, &count);
+    std::vector<EGLDeviceEXT> devices (count);
+    queryDevices (count, devices.data (), &count);
+
+    const char* wanted = std::getenv ("LWE_HEADLESS_DEVICE");
+    std::vector<std::pair<EGLDeviceEXT, std::string>> candidates;
+
+    for (const auto device : devices) {
+	// devices without a render node are software renderers (llvmpipe), the whole point is to avoid those
+	const char* node = queryDeviceString (device, EGL_DRM_RENDER_NODE_FILE_EXT);
+
+	if (node != nullptr && (wanted == nullptr || std::strcmp (node, wanted) == 0)) {
+	    candidates.emplace_back (device, node);
+	}
+    }
+
+    // with an iGPU and a dedicated card, EGL often lists the iGPU first; the card driving the boot
+    // display is the better guess
+    std::ranges::stable_partition (candidates, [] (const auto& candidate) {
+	const auto name = std::filesystem::path (candidate.second).filename ();
+	std::ifstream bootVga ("/sys/class/drm" / name / "device/boot_vga");
+	char value = '0';
+	bootVga >> value;
+	return value == '1';
+    });
+
+    for (const auto& [device, node] : candidates) {
+	const EGLDisplay display = getPlatformDisplay (EGL_PLATFORM_DEVICE_EXT, device, nullptr);
+
+	if (display != EGL_NO_DISPLAY && eglInitialize (display, nullptr, nullptr)) {
+	    sLog.out ("Headless rendering on ", node);
+	    return display;
+	}
+    }
+
+    sLog.exception ("No usable GPU found for headless rendering", wanted ? " (LWE_HEADLESS_DEVICE=" : "",
+		    wanted ? wanted : "", wanted ? ")" : "");
+    return EGL_NO_DISPLAY;
+}
+} // namespace
+
+HeadlessOpenGLDriver::HeadlessOpenGLDriver (ApplicationContext& context, WallpaperApplication& app) :
+    VideoDriver (app, m_mouseInput), m_context (context), m_mouseInput (*this) {
+    this->m_display = openDisplay ();
+
+    const EGLint configAttributes[] = {
+	EGL_SURFACE_TYPE, EGL_PBUFFER_BIT, EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT,
+	EGL_RED_SIZE, 8, EGL_GREEN_SIZE, 8, EGL_BLUE_SIZE, 8, EGL_ALPHA_SIZE, 8,
+	EGL_DEPTH_SIZE, 24, EGL_STENCIL_SIZE, 8, EGL_NONE
+    };
+    EGLint configs = 0;
+
+    if (!eglChooseConfig (this->m_display, configAttributes, &this->m_config, 1, &configs) || configs == 0) {
+	sLog.exception ("No pbuffer capable EGL config for headless rendering");
+    }
+
+    eglBindAPI (EGL_OPENGL_API);
+
+    const EGLint contextAttributes[] = {
+	EGL_CONTEXT_MAJOR_VERSION, 3, EGL_CONTEXT_MINOR_VERSION, 3,
+	EGL_CONTEXT_OPENGL_PROFILE_MASK, EGL_CONTEXT_OPENGL_CORE_PROFILE_BIT,
+	EGL_CONTEXT_OPENGL_DEBUG, EGL_TRUE, EGL_NONE
+    };
+    this->m_eglContext = eglCreateContext (this->m_display, this->m_config, EGL_NO_CONTEXT, contextAttributes);
+
+    if (this->m_eglContext == EGL_NO_CONTEXT) {
+	sLog.exception ("Cannot create headless OpenGL context: ", eglGetError ());
+    }
+
+    if (context.settings.render.mode == ApplicationContext::EXPLICIT_WINDOW) {
+	this->m_size = { context.settings.render.window.geometry.z, context.settings.render.window.geometry.w };
+    }
+
+    this->createSurface ();
+
+    glewExperimental = GL_TRUE;
+
+    // GLEW built with GLX support fails its GLX part without an X display, the GL functions are loaded by then
+    if (const GLenum result = glewInit (); result != GLEW_OK && result != GLEW_ERROR_NO_GLX_DISPLAY) {
+	sLog.error ("Failed to initialize GLEW: ", glewGetErrorString (result));
+    }
+
+    this->m_output = new Output::GLFWWindowOutput (context, *this);
+}
+
+HeadlessOpenGLDriver::~HeadlessOpenGLDriver () {
+    eglMakeCurrent (this->m_display, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+    eglDestroySurface (this->m_display, this->m_surface);
+    eglDestroyContext (this->m_display, this->m_eglContext);
+    eglTerminate (this->m_display);
+}
+
+void HeadlessOpenGLDriver::createSurface () {
+    const EGLint attributes[] = { EGL_WIDTH, this->m_size.x, EGL_HEIGHT, this->m_size.y, EGL_NONE };
+    const EGLSurface surface = eglCreatePbufferSurface (this->m_display, this->m_config, attributes);
+
+    if (surface == EGL_NO_SURFACE) {
+	sLog.exception ("Cannot create a ", this->m_size.x, "x", this->m_size.y, " pbuffer: ", eglGetError ());
+    }
+
+    eglMakeCurrent (this->m_display, surface, surface, this->m_eglContext);
+
+    if (this->m_surface != EGL_NO_SURFACE) {
+	eglDestroySurface (this->m_display, this->m_surface);
+    }
+
+    this->m_surface = surface;
+}
+
+Output::Output& HeadlessOpenGLDriver::getOutput () { return *this->m_output; }
+
+float HeadlessOpenGLDriver::getRenderTime () const {
+    return std::chrono::duration<float> (std::chrono::steady_clock::now () - this->m_started).count ();
+}
+
+bool HeadlessOpenGLDriver::closeRequested () { return false; }
+
+void HeadlessOpenGLDriver::resizeWindow (glm::ivec2 size) {
+    if (size == this->m_size || size.x <= 0 || size.y <= 0) {
+	return;
+    }
+
+    this->m_size = size;
+    this->createSurface ();
+}
+
+void HeadlessOpenGLDriver::resizeWindow (glm::ivec4 sizeandpos) { this->resizeWindow (glm::ivec2 (sizeandpos.z, sizeandpos.w)); }
+
+void HeadlessOpenGLDriver::showWindow () { }
+
+void HeadlessOpenGLDriver::hideWindow () { }
+
+glm::ivec2 HeadlessOpenGLDriver::getFramebufferSize () const { return this->m_size; }
+
+uint32_t HeadlessOpenGLDriver::getFrameCounter () const { return this->m_frameCounter; }
+
+void HeadlessOpenGLDriver::dispatchEventQueue () {
+    glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+
+    for (const auto& [screen, viewport] : this->m_output->getViewports ()) {
+	this->getApp ().update (viewport);
+    }
+
+    this->m_output->updateRender ();
+
+    // there's no swap to throttle on, so keep at most one frame queued on the GPU instead of letting
+    // --fps 1000 pile up work faster than it gets done
+    if (this->m_lastFrame != nullptr) {
+	glClientWaitSync (this->m_lastFrame, GL_SYNC_FLUSH_COMMANDS_BIT, GL_TIMEOUT_IGNORED);
+	glDeleteSync (this->m_lastFrame);
+    }
+
+    this->m_lastFrame = glFenceSync (GL_SYNC_GPU_COMMANDS_COMPLETE, 0);
+    glFlush ();
+    this->m_frameCounter++;
+}
+
+void* HeadlessOpenGLDriver::getProcAddress (const char* name) const {
+    return reinterpret_cast<void*> (eglGetProcAddress (name));
+}
+
+__attribute__ ((constructor)) void registerHeadlessOpenGLDriver () {
+    const auto create = [] (ApplicationContext& context, WallpaperApplication& application) -> std::unique_ptr<VideoDriver> {
+	return std::make_unique<HeadlessOpenGLDriver> (context, application);
+    };
+
+    sVideoFactories.registerDriver (ApplicationContext::EXPLICIT_WINDOW, "headless", create);
+    sVideoFactories.registerDriver (ApplicationContext::NORMAL_WINDOW, "headless", create);
+}

+ 67 - 0
src/WallpaperEngine/Render/Drivers/HeadlessOpenGLDriver.h

@@ -0,0 +1,67 @@
+#pragma once
+
+#include "WallpaperEngine/Application/ApplicationContext.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
+#include "WallpaperEngine/Input/MouseInput.h"
+#include "WallpaperEngine/Render/Drivers/VideoDriver.h"
+
+#include <EGL/egl.h>
+#include <GL/glew.h>
+#include <chrono>
+
+namespace WallpaperEngine::Render::Drivers {
+using namespace WallpaperEngine::Application;
+
+/**
+ * Renders into an offscreen EGL pbuffer on a GPU render node, no window system involved. Picked with
+ * XDG_SESSION_TYPE=headless together with --window, meant for screenshots and regression runs on
+ * machines without a display (Xvfb only offers software rendering). LWE_HEADLESS_DEVICE=/dev/dri/renderDN
+ * chooses the GPU, otherwise the first one EGL lists is used.
+ */
+class HeadlessOpenGLDriver final : public VideoDriver {
+public:
+    explicit HeadlessOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
+    ~HeadlessOpenGLDriver () override;
+
+    [[nodiscard]] Output::Output& getOutput () override;
+    [[nodiscard]] float getRenderTime () const override;
+    bool closeRequested () override;
+    void resizeWindow (glm::ivec2 size) override;
+    void resizeWindow (glm::ivec4 sizeandpos) override;
+    void showWindow () override;
+    void hideWindow () override;
+    [[nodiscard]] glm::ivec2 getFramebufferSize () const override;
+    [[nodiscard]] uint32_t getFrameCounter () const override;
+    void dispatchEventQueue () override;
+    [[nodiscard]] void* getProcAddress (const char* name) const override;
+
+private:
+    class FixedMouse final : public Input::MouseInput {
+    public:
+	explicit FixedMouse (const HeadlessOpenGLDriver& driver) : m_driver (driver) { }
+
+	void update () override { }
+	// the middle of the output, where the pointer of a fresh Xvfb display sits too
+	[[nodiscard]] glm::dvec2 position () const override { return glm::dvec2 (m_driver.m_size) / 2.0; }
+	[[nodiscard]] Input::MouseClickStatus leftClick () const override { return Input::Released; }
+	[[nodiscard]] Input::MouseClickStatus rightClick () const override { return Input::Released; }
+
+    private:
+	const HeadlessOpenGLDriver& m_driver;
+    };
+
+    void createSurface ();
+
+    ApplicationContext& m_context;
+    FixedMouse m_mouseInput;
+    Output::Output* m_output = nullptr;
+    EGLDisplay m_display = EGL_NO_DISPLAY;
+    EGLConfig m_config = nullptr;
+    EGLContext m_eglContext = EGL_NO_CONTEXT;
+    EGLSurface m_surface = EGL_NO_SURFACE;
+    GLsync m_lastFrame = nullptr;
+    glm::ivec2 m_size = { 640, 480 };
+    std::chrono::steady_clock::time_point m_started = std::chrono::steady_clock::now ();
+    uint32_t m_frameCounter = 0;
+};
+} // namespace WallpaperEngine::Render::Drivers

+ 2 - 0
src/WallpaperEngine/Render/Drivers/Output/OutputViewport.h

@@ -22,5 +22,7 @@ public:
 
 
     virtual void makeCurrent () = 0;
     virtual void makeCurrent () = 0;
     virtual void swapOutput () = 0;
     virtual void swapOutput () = 0;
+    /** The surface is tagged as PQ with BT.2020 primaries, what gets drawn has to be encoded that way */
+    [[nodiscard]] virtual bool isHDR () const { return false; }
 };
 };
 } // namespace WallpaperEngine::Render::Drivers::Output
 } // namespace WallpaperEngine::Render::Drivers::Output

+ 130 - 0
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp

@@ -1,10 +1,13 @@
 #include "WaylandOutputViewport.h"
 #include "WaylandOutputViewport.h"
 #include "WallpaperEngine/Logging/Log.h"
 #include "WallpaperEngine/Logging/Log.h"
 
 
+#include <unistd.h>
+
 #define class _class
 #define class _class
 #define namespace _namespace
 #define namespace _namespace
 #define static
 #define static
 extern "C" {
 extern "C" {
+#include "color-management-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 #include "xdg-shell-protocol.h"
@@ -161,6 +164,127 @@ void WaylandOutputViewport::setupXdgOutput (zxdg_output_manager_v1* manager) {
     zxdg_output_v1_add_listener (this->xdgOutput, &xdgOutputListener, this);
     zxdg_output_v1_add_listener (this->xdgOutput, &xdgOutputListener, this);
 }
 }
 
 
+static void descriptionInfoDone (void* data, wp_image_description_info_v1* info) {
+    auto* viewport = static_cast<WaylandOutputViewport*> (data);
+    const auto& description = viewport->pendingDescription;
+    const uint32_t peak = description.targetMaxLuminance ? description.targetMaxLuminance : description.maxLuminance;
+    // KWin describes an HDR output as PQ; a plain SDR output peaks at about its reference white
+    const bool hdr = description.tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ
+	|| (description.referenceLuminance > 0 && peak * 2 > description.referenceLuminance * 3);
+
+    wp_image_description_info_v1_destroy (info);
+
+    if (hdr != viewport->outputHDR) {
+	viewport->outputHDR = hdr;
+	sLog.out ("Output ", viewport->name, hdr ? " switched to HDR, drawing PQ" : " switched to SDR");
+	viewport->applyImageDescription ();
+    }
+}
+
+static void descriptionInfoIcc (void*, wp_image_description_info_v1*, int32_t fd, uint32_t) { close (fd); }
+
+static void descriptionInfoPrimaries (
+    void*, wp_image_description_info_v1*, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t, int32_t
+) { }
+
+static void descriptionInfoPrimariesNamed (void*, wp_image_description_info_v1*, uint32_t) { }
+
+static void descriptionInfoTransferPower (void*, wp_image_description_info_v1*, uint32_t) { }
+
+static void descriptionInfoTransferNamed (void* data, wp_image_description_info_v1*, uint32_t tf) {
+    static_cast<WaylandOutputViewport*> (data)->pendingDescription.tf = tf;
+}
+
+static void descriptionInfoLuminances (void* data, wp_image_description_info_v1*, uint32_t, uint32_t max, uint32_t reference) {
+    auto& description = static_cast<WaylandOutputViewport*> (data)->pendingDescription;
+
+    description.maxLuminance = max;
+    description.referenceLuminance = reference;
+}
+
+static void descriptionInfoTargetLuminance (void* data, wp_image_description_info_v1*, uint32_t, uint32_t max) {
+    static_cast<WaylandOutputViewport*> (data)->pendingDescription.targetMaxLuminance = max;
+}
+
+static void descriptionInfoTargetMaxCll (void*, wp_image_description_info_v1*, uint32_t) { }
+
+static void descriptionInfoTargetMaxFall (void*, wp_image_description_info_v1*, uint32_t) { }
+
+constexpr wp_image_description_info_v1_listener descriptionInfoListener = {
+    .done = descriptionInfoDone,
+    .icc_file = descriptionInfoIcc,
+    .primaries = descriptionInfoPrimaries,
+    .primaries_named = descriptionInfoPrimariesNamed,
+    .tf_power = descriptionInfoTransferPower,
+    .tf_named = descriptionInfoTransferNamed,
+    .luminances = descriptionInfoLuminances,
+    .target_primaries = descriptionInfoPrimaries,
+    .target_luminance = descriptionInfoTargetLuminance,
+    .target_max_cll = descriptionInfoTargetMaxCll,
+    .target_max_fall = descriptionInfoTargetMaxFall,
+};
+
+static void outputDescriptionFailed (void* data, wp_image_description_v1* description, uint32_t, const char* message) {
+    sLog.error ("Cannot tell whether ", static_cast<WaylandOutputViewport*> (data)->name, " runs in HDR: ", message);
+    wp_image_description_v1_destroy (description);
+}
+
+static void outputDescriptionReady (void* data, wp_image_description_v1* description, uint32_t) {
+    auto* viewport = static_cast<WaylandOutputViewport*> (data);
+
+    viewport->pendingDescription = {};
+    wp_image_description_info_v1_add_listener (
+	wp_image_description_v1_get_information (description), &descriptionInfoListener, viewport
+    );
+    wp_image_description_v1_destroy (description);
+}
+
+constexpr wp_image_description_v1_listener outputDescriptionListener = {
+    .failed = outputDescriptionFailed,
+    .ready = outputDescriptionReady,
+};
+
+static void outputDescriptionChanged (void* data, wp_color_management_output_v1*) {
+    static_cast<WaylandOutputViewport*> (data)->queryOutputDescription ();
+}
+
+constexpr wp_color_management_output_v1_listener colorOutputListener = {
+    .image_description_changed = outputDescriptionChanged,
+};
+
+void WaylandOutputViewport::setupColorManagement () {
+    if (this->colorOutput != nullptr || !m_driver->isHDRAvailable ()) {
+	return;
+    }
+
+    this->colorOutput = wp_color_manager_v1_get_output (m_driver->getWaylandContext ()->colorManager, this->output);
+    wp_color_management_output_v1_add_listener (this->colorOutput, &colorOutputListener, this);
+    this->queryOutputDescription ();
+}
+
+void WaylandOutputViewport::queryOutputDescription () {
+    wp_image_description_v1_add_listener (
+	wp_color_management_output_v1_get_image_description (this->colorOutput), &outputDescriptionListener, this
+    );
+}
+
+void WaylandOutputViewport::applyImageDescription () {
+    if (this->colorSurface == nullptr) {
+	return;
+    }
+
+    // takes effect with the next frame's commit
+    if (this->outputHDR) {
+	wp_color_management_surface_v1_set_image_description (
+	    this->colorSurface, m_driver->getHDRDescription (), WP_COLOR_MANAGER_V1_RENDER_INTENT_PERCEPTUAL
+	);
+    } else {
+	wp_color_management_surface_v1_unset_image_description (this->colorSurface);
+    }
+}
+
+bool WaylandOutputViewport::isHDR () const { return this->colorSurface != nullptr && this->outputHDR; }
+
 void WaylandOutputViewport::setupLS () {
 void WaylandOutputViewport::setupLS () {
     surface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
     surface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
 
 
@@ -225,6 +349,12 @@ void WaylandOutputViewport::setupLS () {
     }
     }
 #endif
 #endif
 
 
+    if (m_driver->isHDRAvailable ()) {
+	this->setupColorManagement ();
+	this->colorSurface = wp_color_manager_v1_get_surface (m_driver->getWaylandContext ()->colorManager, surface);
+	this->applyImageDescription ();
+    }
+
     wl_surface_commit (surface);
     wl_surface_commit (surface);
     wl_display_roundtrip (m_driver->getWaylandContext ()->display);
     wl_display_roundtrip (m_driver->getWaylandContext ()->display);
 
 

+ 21 - 0
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h

@@ -18,6 +18,8 @@ struct zwlr_layer_shell_v1;
 struct zwlr_layer_surface_v1;
 struct zwlr_layer_surface_v1;
 struct zxdg_output_v1;
 struct zxdg_output_v1;
 struct zxdg_output_manager_v1;
 struct zxdg_output_manager_v1;
+struct wp_color_management_output_v1;
+struct wp_color_management_surface_v1;
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 struct org_kde_plasma_surface;
 struct org_kde_plasma_surface;
 #endif
 #endif
@@ -62,6 +64,25 @@ namespace Output {
 
 
 	void setupLS ();
 	void setupLS ();
 	void setupXdgOutput (zxdg_output_manager_v1* manager);
 	void setupXdgOutput (zxdg_output_manager_v1* manager);
+	/** Starts following whether the output runs in HDR (only with --hdr and a compositor that can take PQ) */
+	void setupColorManagement ();
+	/** Asks for the output's current image description again, its info events decide outputHDR */
+	void queryOutputDescription ();
+	/** Tags the surface as PQ while the output runs in HDR, plain sRGB otherwise */
+	void applyImageDescription ();
+	[[nodiscard]] bool isOutputHDR () const { return this->outputHDR; }
+	[[nodiscard]] bool isHDR () const override;
+
+	wp_color_management_output_v1* colorOutput = nullptr;
+	wp_color_management_surface_v1* colorSurface = nullptr;
+	bool outputHDR = false;
+	/** Filled by the image description info events of the query in flight */
+	struct {
+	    uint32_t tf = 0;
+	    uint32_t maxLuminance = 0;
+	    uint32_t referenceLuminance = 0;
+	    uint32_t targetMaxLuminance = 0;
+	} pendingDescription;
 
 
 	void makeCurrent () override;
 	void makeCurrent () override;
 	void swapOutput () override;
 	void swapOutput () override;

+ 5 - 0
src/WallpaperEngine/Render/Drivers/VideoDriver.h

@@ -40,6 +40,11 @@ public:
     [[nodiscard]] virtual void* getWaylandDisplay () const { return nullptr; }
     [[nodiscard]] virtual void* getWaylandDisplay () const { return nullptr; }
     /** The X11 Display, if rendering goes through X11 (mpv needs it for zero-copy hwdec) */
     /** The X11 Display, if rendering goes through X11 (mpv needs it for zero-copy hwdec) */
     [[nodiscard]] virtual void* getX11Display () const { return nullptr; }
     [[nodiscard]] virtual void* getX11Display () const { return nullptr; }
+    /**
+     * --hdr was asked for and the compositor can take PQ content: videos then decode to linear BT.2020 and
+     * HDR outputs get PQ, see CWallpaper::setOutputHDR
+     */
+    [[nodiscard]] virtual bool isHDRAvailable () const { return false; }
     virtual void dispatchEventQueue () = 0;
     virtual void dispatchEventQueue () = 0;
     [[nodiscard]] WallpaperApplication& getApp () const;
     [[nodiscard]] WallpaperApplication& getApp () const;
     [[nodiscard]] Input::InputContext& getInputContext ();
     [[nodiscard]] Input::InputContext& getInputContext ();

+ 7 - 5
src/WallpaperEngine/Render/Drivers/VideoFactories.cpp

@@ -64,11 +64,13 @@ std::unique_ptr<VideoDriver> VideoFactories::createVideoDriver (
 	sLog.exception ("Cannot find a driver for window mode ", mode, " and XDG_SESSION_TYPE ", xdgSessionType);
 	sLog.exception ("Cannot find a driver for window mode ", mode, " and XDG_SESSION_TYPE ", xdgSessionType);
     }
     }
 
 
-    // windowed modes only have one handler and the map isn't built to store that, so just
-    // hijack session-type detection with a fixed key for those
-    const auto factory = mode != Application::ApplicationContext::DESKTOP_BACKGROUND
-	? sessionTypeToFactory->second.find (DEFAULT_WINDOW_NAME)
-	: sessionTypeToFactory->second.find (xdgSessionType);
+    // windowed modes have one handler for every session type, stored under a fixed key; the only
+    // session-specific one is the headless driver (XDG_SESSION_TYPE=headless)
+    auto factory = sessionTypeToFactory->second.find (xdgSessionType);
+
+    if (mode != Application::ApplicationContext::DESKTOP_BACKGROUND && xdgSessionType != "headless") {
+	factory = sessionTypeToFactory->second.find (DEFAULT_WINDOW_NAME);
+    }
 
 
     if (factory == sessionTypeToFactory->second.end ()) {
     if (factory == sessionTypeToFactory->second.end ()) {
 	sLog.exception ("Cannot find a driver for window mode ", mode, " and XDG_SESSION_TYPE ", xdgSessionType);
 	sLog.exception ("Cannot find a driver for window mode ", mode, " and XDG_SESSION_TYPE ", xdgSessionType);

+ 135 - 1
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.cpp

@@ -7,6 +7,7 @@
 #define namespace _namespace
 #define namespace _namespace
 #define static
 #define static
 extern "C" {
 extern "C" {
+#include "color-management-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-output-unstable-v1-protocol.h"
 #include "xdg-shell-protocol.h"
 #include "xdg-shell-protocol.h"
@@ -107,6 +108,49 @@ static void handleCapabilities (void* data, wl_seat* wl_seat, uint32_t capabilit
 
 
 constexpr struct wl_seat_listener seatListener = { .capabilities = handleCapabilities };
 constexpr struct wl_seat_listener seatListener = { .capabilities = handleCapabilities };
 
 
+static void colorManagerSupportedIntent (void*, wp_color_manager_v1*, uint32_t) { }
+
+static void colorManagerSupportedFeature (void* data, wp_color_manager_v1*, uint32_t feature) {
+    if (feature == WP_COLOR_MANAGER_V1_FEATURE_PARAMETRIC) {
+	static_cast<WaylandOpenGLDriver*> (data)->colorSupport.parametric = true;
+    }
+}
+
+static void colorManagerSupportedTransferFunction (void* data, wp_color_manager_v1*, uint32_t tf) {
+    if (tf == WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ) {
+	static_cast<WaylandOpenGLDriver*> (data)->colorSupport.pq = true;
+    }
+}
+
+static void colorManagerSupportedPrimaries (void* data, wp_color_manager_v1*, uint32_t primaries) {
+    if (primaries == WP_COLOR_MANAGER_V1_PRIMARIES_BT2020) {
+	static_cast<WaylandOpenGLDriver*> (data)->colorSupport.bt2020 = true;
+    }
+}
+
+static void colorManagerDone (void*, wp_color_manager_v1*) { }
+
+constexpr struct wp_color_manager_v1_listener colorManagerListener = {
+    .supported_intent = colorManagerSupportedIntent,
+    .supported_feature = colorManagerSupportedFeature,
+    .supported_tf_named = colorManagerSupportedTransferFunction,
+    .supported_primaries_named = colorManagerSupportedPrimaries,
+    .done = colorManagerDone,
+};
+
+static void hdrDescriptionFailed (void*, wp_image_description_v1*, uint32_t cause, const char* message) {
+    sLog.error ("--hdr: the compositor refused a PQ image description (", cause, "): ", message);
+}
+
+static void hdrDescriptionReady (void* data, wp_image_description_v1*, uint32_t) {
+    static_cast<WaylandOpenGLDriver*> (data)->hdrDescriptionReady = true;
+}
+
+constexpr struct wp_image_description_v1_listener hdrDescriptionListener = {
+    .failed = hdrDescriptionFailed,
+    .ready = hdrDescriptionReady,
+};
+
 static void
 static void
 handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const char* interface, uint32_t version) {
 handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const char* interface, uint32_t version) {
     const auto driver = static_cast<WaylandOpenGLDriver*> (data);
     const auto driver = static_cast<WaylandOpenGLDriver*> (data);
@@ -132,6 +176,10 @@ handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const cha
 	driver->getWaylandContext ()->xdgOutputManager = static_cast<zxdg_output_manager_v1*> (
 	driver->getWaylandContext ()->xdgOutputManager = static_cast<zxdg_output_manager_v1*> (
 	    wl_registry_bind (registry, name, &zxdg_output_manager_v1_interface, std::min (version, 3u))
 	    wl_registry_bind (registry, name, &zxdg_output_manager_v1_interface, std::min (version, 3u))
 	);
 	);
+    } else if (strcmp (interface, wp_color_manager_v1_interface.name) == 0 && driver->getApp ().getContext ().settings.render.hdr) {
+	driver->getWaylandContext ()->colorManager
+	    = static_cast<wp_color_manager_v1*> (wl_registry_bind (registry, name, &wp_color_manager_v1_interface, 1));
+	wp_color_manager_v1_add_listener (driver->getWaylandContext ()->colorManager, &colorManagerListener, driver);
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
     } else if (strcmp (interface, org_kde_plasma_shell_interface.name) == 0) {
     } else if (strcmp (interface, org_kde_plasma_shell_interface.name) == 0) {
 	driver->getWaylandContext ()->plasmaShell = static_cast<org_kde_plasma_shell*> (
 	driver->getWaylandContext ()->plasmaShell = static_cast<org_kde_plasma_shell*> (
@@ -211,7 +259,35 @@ void WaylandOpenGLDriver::initEGL () {
 	EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_NONE,
 	EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_NONE,
     };
     };
 
 
-    if (!eglChooseConfig (m_eglContext.display, CONFIG_ATTRIBUTES, &m_eglContext.config, 1, &matchedConfigs)) {
+    // PQ in 8 bits bands visibly, so HDR outputs want a 10 bit buffer (fine for the SDR ones too)
+    if (this->isHDRAvailable ()) {
+	const EGLint HDR_CONFIG_ATTRIBUTES[] = {
+	    EGL_SURFACE_TYPE, EGL_WINDOW_BIT, EGL_RED_SIZE,	    10, EGL_GREEN_SIZE, 10, EGL_BLUE_SIZE, 10,
+	    EGL_SAMPLES,      4,	      EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT, EGL_NONE,
+	};
+	EGLConfig configs[64];
+	EGLint count = 0;
+
+	if (eglChooseConfig (m_eglContext.display, HDR_CONFIG_ATTRIBUTES, configs, 64, &count)) {
+	    for (EGLint i = 0; i < count && matchedConfigs == 0; i++) {
+		EGLint red = 0;
+
+		eglGetConfigAttrib (m_eglContext.display, configs[i], EGL_RED_SIZE, &red);
+
+		if (red == 10) {
+		    m_eglContext.config = configs[i];
+		    matchedConfigs = 1;
+		}
+	    }
+	}
+
+	if (matchedConfigs == 0) {
+	    sLog.error ("--hdr: no 10 bit EGL config, HDR output will band");
+	}
+    }
+
+    if (matchedConfigs == 0
+	&& !eglChooseConfig (m_eglContext.display, CONFIG_ATTRIBUTES, &m_eglContext.config, 1, &matchedConfigs)) {
 	this->finishEGL ();
 	this->finishEGL ();
 	sLog.exception ("eglChooseConfig failed!");
 	sLog.exception ("eglChooseConfig failed!");
     }
     }
@@ -269,6 +345,16 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 	wl_egl_window_destroy (viewport->eglWindow);
 	wl_egl_window_destroy (viewport->eglWindow);
     }
     }
 
 
+    if (viewport->colorSurface) {
+	wp_color_management_surface_v1_destroy (viewport->colorSurface);
+	viewport->colorSurface = nullptr;
+    }
+
+    if (viewport->colorOutput) {
+	wp_color_management_output_v1_destroy (viewport->colorOutput);
+	viewport->colorOutput = nullptr;
+    }
+
     if (viewport->layerSurface) {
     if (viewport->layerSurface) {
 	zwlr_layer_surface_v1_destroy (viewport->layerSurface);
 	zwlr_layer_surface_v1_destroy (viewport->layerSurface);
     }
     }
@@ -310,6 +396,7 @@ WaylandOpenGLDriver::WaylandOpenGLDriver (ApplicationContext& context, Wallpaper
     VideoDriver (app, m_mouseInput), m_output (context, *this), m_requestedExit (false), m_frameCounter (0),
     VideoDriver (app, m_mouseInput), m_output (context, *this), m_requestedExit (false), m_frameCounter (0),
     m_context (context), m_mouseInput (*this) {
     m_context (context), m_mouseInput (*this) {
     initWaylandRegistry ();
     initWaylandRegistry ();
+    initColorManagement ();
     initEGL ();
     initEGL ();
     setupOutputLayerSurfaces ();
     setupOutputLayerSurfaces ();
     initGLEW ();
     initGLEW ();
@@ -343,6 +430,53 @@ void WaylandOpenGLDriver::initWaylandRegistry () {
     }
     }
 }
 }
 
 
+void WaylandOpenGLDriver::initColorManagement () {
+    if (!m_context.settings.render.hdr) {
+	return;
+    }
+
+    if (!m_waylandContext.colorManager) {
+	sLog.error ("--hdr: the compositor has no color management protocol (wp_color_manager_v1), staying SDR");
+	return;
+    }
+
+    // the supported_* events and done follow the bind
+    wl_display_roundtrip (m_waylandContext.display);
+
+    if (!colorSupport.parametric || !colorSupport.pq || !colorSupport.bt2020) {
+	sLog.error ("--hdr: the compositor can't take PQ with BT.2020 primaries, staying SDR");
+	return;
+    }
+
+    auto* creator = wp_color_manager_v1_create_parametric_creator (m_waylandContext.colorManager);
+    wp_image_description_creator_params_v1_set_tf_named (creator, WP_COLOR_MANAGER_V1_TRANSFER_FUNCTION_ST2084_PQ);
+    wp_image_description_creator_params_v1_set_primaries_named (creator, WP_COLOR_MANAGER_V1_PRIMARIES_BT2020);
+    this->m_hdrDescription = wp_image_description_creator_params_v1_create (creator);
+    wp_image_description_v1_add_listener (this->m_hdrDescription, &hdrDescriptionListener, this);
+    wl_display_roundtrip (m_waylandContext.display);
+
+    if (!this->hdrDescriptionReady) {
+	return;
+    }
+
+    // every output reports whether it runs in HDR, and again whenever that changes
+    for (const auto& o : this->m_screens) {
+	o->setupColorManagement ();
+    }
+
+    // image description ready -> get_information -> its events
+    wl_display_roundtrip (m_waylandContext.display);
+    wl_display_roundtrip (m_waylandContext.display);
+
+    for (const auto& o : this->m_screens) {
+	sLog.out ("Output ", o->name, o->isOutputHDR () ? " runs in HDR, drawing PQ" : " runs in SDR");
+    }
+}
+
+bool WaylandOpenGLDriver::isHDRAvailable () const { return this->hdrDescriptionReady; }
+
+wp_image_description_v1* WaylandOpenGLDriver::getHDRDescription () const { return this->m_hdrDescription; }
+
 void WaylandOpenGLDriver::setupOutputLayerSurfaces () {
 void WaylandOpenGLDriver::setupOutputLayerSurfaces () {
     bool any = false;
     bool any = false;
 
 

+ 20 - 0
src/WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h

@@ -25,6 +25,8 @@ class WallpaperApplication;
 struct zwlr_layer_shell_v1;
 struct zwlr_layer_shell_v1;
 struct zwlr_layer_surface_v1;
 struct zwlr_layer_surface_v1;
 struct zxdg_output_manager_v1;
 struct zxdg_output_manager_v1;
+struct wp_color_manager_v1;
+struct wp_image_description_v1;
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 struct org_kde_plasma_shell;
 struct org_kde_plasma_shell;
 #endif
 #endif
@@ -59,11 +61,20 @@ public:
 	zwlr_layer_shell_v1* layerShell = nullptr;
 	zwlr_layer_shell_v1* layerShell = nullptr;
 	wl_seat* seat = nullptr;
 	wl_seat* seat = nullptr;
 	zxdg_output_manager_v1* xdgOutputManager = nullptr;
 	zxdg_output_manager_v1* xdgOutputManager = nullptr;
+	/** Only bound with --hdr */
+	wp_color_manager_v1* colorManager = nullptr;
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 	org_kde_plasma_shell* plasmaShell = nullptr;
 	org_kde_plasma_shell* plasmaShell = nullptr;
 #endif
 #endif
     };
     };
 
 
+    /** What wp_color_manager_v1 advertised, only these three matter for PQ output */
+    struct ColorSupport {
+	bool parametric = false;
+	bool pq = false;
+	bool bt2020 = false;
+    };
+
     explicit WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
     explicit WaylandOpenGLDriver (ApplicationContext& context, WallpaperApplication& app);
     ~WaylandOpenGLDriver ();
     ~WaylandOpenGLDriver ();
 
 
@@ -79,6 +90,9 @@ public:
     void dispatchEventQueue () override;
     void dispatchEventQueue () override;
     [[nodiscard]] void* getProcAddress (const char* name) const override;
     [[nodiscard]] void* getProcAddress (const char* name) const override;
     [[nodiscard]] void* getWaylandDisplay () const override;
     [[nodiscard]] void* getWaylandDisplay () const override;
+    [[nodiscard]] bool isHDRAvailable () const override;
+    /** PQ with BT.2020 primaries (and the PQ default luminances, 203 nits reference white), shared by every HDR surface */
+    [[nodiscard]] wp_image_description_v1* getHDRDescription () const;
 
 
     void onLayerClose (Output::WaylandOutputViewport*);
     void onLayerClose (Output::WaylandOutputViewport*);
     Output::WaylandOutputViewport* surfaceToViewport (const wl_surface*) const;
     Output::WaylandOutputViewport* surfaceToViewport (const wl_surface*) const;
@@ -90,6 +104,9 @@ public:
 
 
     std::vector<Output::WaylandOutputViewport*> m_screens = {};
     std::vector<Output::WaylandOutputViewport*> m_screens = {};
 
 
+    ColorSupport colorSupport = {};
+    bool hdrDescriptionReady = false;
+
 private:
 private:
     Output::WaylandOutput m_output;
     Output::WaylandOutput m_output;
     SEGLContext m_eglContext = {};
     SEGLContext m_eglContext = {};
@@ -101,6 +118,9 @@ private:
     void initEGL ();
     void initEGL ();
     void initGLEW ();
     void initGLEW ();
     void finishEGL () const;
     void finishEGL () const;
+    void initColorManagement ();
+
+    wp_image_description_v1* m_hdrDescription = nullptr;
 
 
     uint32_t m_frameCounter = 0;
     uint32_t m_frameCounter = 0;
     ApplicationContext& m_context;
     ApplicationContext& m_context;

+ 155 - 0
src/WallpaperEngine/Render/Objects/CCamera.cpp

@@ -0,0 +1,155 @@
+#include "CCamera.h"
+
+#include <cmath>
+
+#include <glm/gtc/matrix_transform.hpp>
+
+#include "WallpaperEngine/Data/Model/Object.h"
+#include "WallpaperEngine/Scripting/AnimationSystem.h"
+
+using namespace WallpaperEngine::Render::Objects;
+
+CCamera::CCamera (Wallpapers::CScene& scene, const SceneCamera& camera) :
+    CObject (scene, camera), ScriptableObject (scene, camera), m_camera (camera),
+    m_random (static_cast<unsigned> (camera.id)) {
+    this->registerProperty ("fov", *camera.fov->value);
+    this->registerProperty ("zoom", *camera.zoom->value);
+}
+
+const SceneCamera& CCamera::getCamera () const { return this->m_camera; }
+
+int CCamera::nextTimeline () {
+    const auto& timelines = this->m_camera.timelines;
+    const int count = static_cast<int> (timelines.size ());
+
+    if (this->m_camera.queueMode == CameraQueueMode::Sequential) {
+	for (int tries = 0; tries < count; tries++) {
+	    this->m_cursor = (this->m_cursor + 1) % count;
+
+	    if (timelines[this->m_cursor].visible) {
+		return this->m_cursor;
+	    }
+	}
+
+	return -1;
+    }
+
+    // random: a bag of the visible entries, refilled once empty, never the same entry twice in a row
+    if (this->m_bag.empty ()) {
+	for (int index = 0; index < count; index++) {
+	    if (timelines[index].visible) {
+		this->m_bag.push_back (index);
+	    }
+	}
+    }
+
+    if (this->m_bag.empty ()) {
+	return -1;
+    }
+
+    const int size = static_cast<int> (this->m_bag.size ());
+    int pick = std::uniform_int_distribution<int> (0, size - 1) (this->m_random);
+
+    for (int tries = 0; tries < size && this->m_last == this->m_bag[pick]; tries++) {
+	pick = (pick + 1) % size;
+    }
+
+    const int chosen = this->m_bag[pick];
+    this->m_bag.erase (this->m_bag.begin () + pick);
+    this->m_last = chosen;
+
+    return chosen;
+}
+
+void CCamera::advanceClock (const CameraTimeline& timeline, const float dt) {
+    // sub_1401A9F60: single stops at the end, loop wraps, mirror bounces
+    const float length = timeline.length / timeline.fps;
+
+    if (timeline.startPaused || this->m_ended || length <= 0.0f) {
+	return;
+    }
+
+    float time = this->m_time + (this->m_reverse ? -dt : dt);
+
+    switch (timeline.mode) {
+	case PropertyAnimation::Mode::Single:
+	    if (time >= length) {
+		time = length;
+		this->m_ended = true;
+	    }
+	    break;
+	case PropertyAnimation::Mode::Loop:
+	    if (time < 0.0f) {
+		time += length;
+	    }
+	    if (time >= length) {
+		time = std::fmod (time, length);
+	    }
+	    break;
+	case PropertyAnimation::Mode::Mirror:
+	    if (this->m_reverse && time <= 0.0f) {
+		time = -time;
+		this->m_reverse = false;
+	    } else if (!this->m_reverse && time >= length) {
+		time = length - (time - length);
+		this->m_reverse = true;
+	    }
+	    break;
+    }
+
+    this->m_time = time;
+}
+
+std::optional<CCamera::Pose> CCamera::updateTimeline (const float dt, const glm::mat4& world, const glm::mat4& parent) {
+    const auto& timelines = this->m_camera.timelines;
+
+    if (timelines.empty ()) {
+	return std::nullopt;
+    }
+
+    // a finished entry starts over and hands over to the next one in the queue
+    if (this->m_ended) {
+	this->m_ended = false;
+	this->m_time = 0.0f;
+	this->m_active = -1;
+    }
+
+    if (this->m_active < 0) {
+	this->m_active = this->nextTimeline ();
+
+	if (this->m_active < 0) {
+	    return std::nullopt;
+	}
+
+	this->m_time = 0.0f;
+	this->m_reverse = false;
+    }
+
+    const auto& timeline = timelines[this->m_active];
+    this->advanceClock (timeline, dt);
+
+    const int frames = static_cast<int> (timeline.length);
+    const float frame = this->m_time * timeline.fps;
+    const auto sample = [&] (const std::vector<AnimationKeyframe>& keys, float& target) {
+	if (!keys.empty ()) {
+	    target = Scripting::evaluateAnimationCurve (keys, frame, timeline.fps, frames);
+	}
+    };
+
+    // components without a track keep the camera's own transform, the center 5 units down its view axis
+    glm::vec3 eye (world[3]);
+    glm::vec3 center = eye - glm::normalize (glm::vec3 (world[2])) * 5.0f;
+    glm::vec3 up = glm::normalize (glm::vec3 (world[1]));
+
+    for (int component = 0; component < 3; component++) {
+	sample (timeline.center[component], center[component]);
+	sample (timeline.eye[component], eye[component]);
+	sample (timeline.up[component], up[component]);
+    }
+
+    Pose pose { .world = parent * glm::inverse (glm::lookAt (eye, center, up)), .zoom = 1.0f, .fov = 50.0f };
+    sample (timeline.zoom, pose.zoom);
+    sample (timeline.fov, pose.fov);
+
+    return pose;
+}

+ 53 - 0
src/WallpaperEngine/Render/Objects/CCamera.h

@@ -0,0 +1,53 @@
+#pragma once
+
+#include <optional>
+#include <random>
+#include <vector>
+
+#include <glm/mat4x4.hpp>
+
+#include "WallpaperEngine/Scripting/ScriptableObject.h"
+
+namespace WallpaperEngine::Render::Objects {
+using namespace WallpaperEngine::Data::Model;
+
+/**
+ * A "camera" object draws nothing, the last visible one in the scene sets the view: its position is the eye, it looks
+ * down its own -z axis with its y axis as up (wallpaper64.exe 2.8.42 sub_1401891A0). With a "path" file it plays
+ * those timelines one after another instead of holding still (sub_1401F2AD0)
+ */
+class CCamera final : public Scripting::ScriptableObject {
+public:
+    struct Pose {
+	/** Replaces the camera's world matrix */
+	glm::mat4 world;
+	/** zoom in 2D scenes, fov in 3D ones, from the timeline */
+	float zoom;
+	float fov;
+    };
+
+    CCamera (Wallpapers::CScene& scene, const SceneCamera& camera);
+
+    [[nodiscard]] const SceneCamera& getCamera () const;
+    /**
+     * Advances the running timeline by dt and samples it. world is the camera's own world matrix, parent its
+     * parent's; nothing comes back when the camera has no path
+     */
+    std::optional<Pose> updateTimeline (float dt, const glm::mat4& world, const glm::mat4& parent);
+
+private:
+    [[nodiscard]] int nextTimeline ();
+    void advanceClock (const CameraTimeline& timeline, float dt);
+
+    const SceneCamera& m_camera;
+    int m_active = -1;
+    /** Last entry the sequential queue handed out */
+    int m_cursor = -1;
+    float m_time = 0.0f;
+    bool m_reverse = false;
+    bool m_ended = false;
+    std::vector<int> m_bag;
+    std::optional<int> m_last;
+    std::minstd_rand m_random;
+};
+} // namespace WallpaperEngine::Render::Objects

+ 77 - 18
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -813,6 +813,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     this->registerProperty ("scale", *image.scale->value);
     this->registerProperty ("scale", *image.scale->value);
     this->registerProperty ("angles", *image.angles->value);
     this->registerProperty ("angles", *image.angles->value);
     this->registerProperty ("visible", *image.visible->value);
     this->registerProperty ("visible", *image.visible->value);
+    this->registerProperty ("copybackground", *image.copyBackground->value);
     this->registerProperty ("alpha", *image.alpha->value);
     this->registerProperty ("alpha", *image.alpha->value);
     this->registerProperty ("color", *image.color->value);
     this->registerProperty ("color", *image.color->value);
     this->registerProperty ("parallaxDepth", *image.parallaxDepth->value);
     this->registerProperty ("parallaxDepth", *image.parallaxDepth->value);
@@ -901,11 +902,13 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 	? (this->m_texture->getFlags () | TextureFlags_ClampUVs)
 	? (this->m_texture->getFlags () | TextureFlags_ClampUVs)
 	: this->m_texture->getFlags ();
 	: this->m_texture->getFlags ();
 
 
+    // layer buffers are 16 bit float in HDR scene rendering (sub_1401E7170)
+    const TextureFormat layerFormat = this->getScene ().isHDR () ? TextureFormat_RGBA16161616f : TextureFormat_ARGB8888;
     this->m_currentMainFBO = this->m_mainFBO = scene.create (
     this->m_currentMainFBO = this->m_mainFBO = scene.create (
-	nameA.str (), TextureFormat_ARGB8888, compositeFlags, 1, { bufferSize.x, bufferSize.y }, { bufferSize.x, bufferSize.y }
+	nameA.str (), layerFormat, compositeFlags, 1, { bufferSize.x, bufferSize.y }, { bufferSize.x, bufferSize.y }
     );
     );
     this->m_currentSubFBO = this->m_subFBO = scene.create (
     this->m_currentSubFBO = this->m_subFBO = scene.create (
-	nameB.str (), TextureFormat_ARGB8888, compositeFlags, 1, { bufferSize.x, bufferSize.y }, { bufferSize.x, bufferSize.y }
+	nameB.str (), layerFormat, compositeFlags, 1, { bufferSize.x, bufferSize.y }, { bufferSize.x, bufferSize.y }
     );
     );
 
 
     GLfloat sceneSpacePosition[] = { this->m_pos.x, this->m_pos.y, 0.0f, this->m_pos.x, this->m_pos.w, 0.0f,
     GLfloat sceneSpacePosition[] = { this->m_pos.x, this->m_pos.y, 0.0f, this->m_pos.x, this->m_pos.w, 0.0f,
@@ -1005,8 +1008,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     this->m_sceneCenter
     this->m_sceneCenter
 	= glm::vec3 ((this->m_pos.x + this->m_pos.z) / 2.0f, (this->m_pos.y + this->m_pos.w) / 2.0f, 0.0f);
 	= glm::vec3 ((this->m_pos.x + this->m_pos.z) / 2.0f, (this->m_pos.y + this->m_pos.w) / 2.0f, 0.0f);
 
 
-    this->m_modelViewProjectionScreen
-	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt ();
+    this->m_modelViewProjectionScreen = this->getViewProjection ();
     // must match m_modelViewProjectionScreen - updateScreenSpacePosition() may skip recomputing it
     // must match m_modelViewProjectionScreen - updateScreenSpacePosition() may skip recomputing it
     this->m_modelViewProjectionScreenInverse = glm::inverse (this->m_modelViewProjectionScreen);
     this->m_modelViewProjectionScreenInverse = glm::inverse (this->m_modelViewProjectionScreen);
     this->updateEffectTextureProjection ();
     this->updateEffectTextureProjection ();
@@ -1841,12 +1843,29 @@ void CImage::setup () {
     // drawing the last effect pass straight to the screen would leave _a one pass behind (or empty)
     // drawing the last effect pass straight to the screen would leave _a one pass behind (or empty)
     const bool copyForReaders = readByOtherLayer && this->getImage ().visible->value->getBool ();
     const bool copyForReaders = readByOtherLayer && this->getImage ().visible->value->getBool ();
 
 
-    if (!debug.baseOnly && (colorBlendMode > 0 || copyForReaders)) {
-	this->m_materials.colorBlending.material
-	    = MaterialParser::load (this->getScene ().getScene ().project, "materials/util/effectpassthrough.json");
+    // fog sends every layer through its buffer and a FOG_COMPUTED composite (sub_1401E6F50, sub_1401EBBC0)
+    const bool fog = this->getScene ().hasDistanceFog () || this->getScene ().hasHeightFog ();
+    this->m_fogPass = nullptr;
+
+    if (!debug.baseOnly && (colorBlendMode > 0 || copyForReaders || fog)) {
+	// scenes from version 3 on composite with genericimage4, the one with fog (sub_1401EBBC0)
+	const auto& project = this->getScene ().getScene ().project;
+	this->m_materials.colorBlending.material = MaterialParser::load (
+	    project,
+	    project.sceneVersion >= 3 ? "materials/util/effectpassthrough_4.json" : "materials/util/effectpassthrough.json"
+	);
+	ComboMap combos;
+
+	if (colorBlendMode > 0) {
+	    combos.emplace ("BLENDMODE", colorBlendMode);
+	}
+	if (fog) {
+	    combos.emplace ("FOG_COMPUTED", 1);
+	}
+
 	this->m_materials.colorBlending.override = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
 	this->m_materials.colorBlending.override = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
 	    .id = -1,
 	    .id = -1,
-	    .combos = colorBlendMode > 0 ? ComboMap { { "BLENDMODE", colorBlendMode } } : ComboMap {},
+	    .combos = combos,
 	    .constants = {},
 	    .constants = {},
 	    .textures = {},
 	    .textures = {},
 	});
 	});
@@ -1855,6 +1874,10 @@ void CImage::setup () {
 	    *this, std::make_shared<FBOProvider> (this), **this->m_materials.colorBlending.material->passes.begin (),
 	    *this, std::make_shared<FBOProvider> (this), **this->m_materials.colorBlending.material->passes.begin (),
 	    *this->m_materials.colorBlending.override, std::nullopt, std::nullopt
 	    *this->m_materials.colorBlending.override, std::nullopt, std::nullopt
 	));
 	));
+
+	if (fog) {
+	    this->m_fogPass = this->m_passes.back ();
+	}
     }
     }
 
 
     if (this->m_hasPuppetMesh && !this->m_passes.empty ()) {
     if (this->m_hasPuppetMesh && !this->m_passes.empty ()) {
@@ -2026,6 +2049,12 @@ void CImage::setupPasses () {
 	    &this->m_lightingNormal, &this->m_lightingViewProjection
 	    &this->m_lightingNormal, &this->m_lightingViewProjection
 	);
 	);
 
 
+	// the fog composite measures in WE's world, the lighting model already maps the layer there
+	if (pass == this->m_fogPass && projection == &this->m_modelViewProjectionScreen) {
+	    pass->setModelMatrix (&this->m_lightingSceneModel);
+	    pass->setFogWorld (true);
+	}
+
 	pass->setDestination (drawTo);
 	pass->setDestination (drawTo);
 	pass->setInput (asInput);
 	pass->setInput (asInput);
 	pass->setPreviousInput (inTargetEffectSequence ? effectInput : nullptr);
 	pass->setPreviousInput (inTargetEffectSequence ? effectInput : nullptr);
@@ -2150,6 +2179,13 @@ void CImage::render () {
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
 #endif /* DEBUG */
 #endif /* DEBUG */
 
 
+    // WE (sub_140207B50): a passthrough layer without copybackground clears its buffer to 0,0,0,0 instead of
+    // drawing the scene behind it, so its effects start from a transparent image
+    const Effects::CPass* skippedBasePass = this->m_image.model->passthrough
+	    && !this->m_image.copyBackground->value->getBool () && !this->m_allPasses.empty ()
+	? this->m_allPasses.front ()
+	: nullptr;
+
     auto cur = this->m_passes.begin ();
     auto cur = this->m_passes.begin ();
     const auto end = this->m_passes.end ();
     const auto end = this->m_passes.end ();
 
 
@@ -2158,6 +2194,11 @@ void CImage::render () {
 	    glColorMask (true, true, true, false);
 	    glColorMask (true, true, true, false);
 	}
 	}
 
 
+	if (*cur == skippedBasePass && std::next (cur) != end) {
+	    (*cur)->clearDestination ();
+	    continue;
+	}
+
 	(*cur)->render ();
 	(*cur)->render ();
 
 
     }
     }
@@ -2181,11 +2222,16 @@ const float& CImage::getAlpha () const {
     return m_alphaCache;
     return m_alphaCache;
 }
 }
 
 
-const glm::vec3& CImage::getColor () const { return this->m_image.color->value->getVec3 (); }
+const glm::vec3& CImage::getColor () const {
+    // the object's brightness only scales its color in HDR scene rendering (sub_140207740)
+    m_colorCache = this->m_image.color->value->getVec3 ()
+	* (this->getScene ().isHDR () ? this->m_image.brightness->value->getFloat () : 1.0f);
+    return m_colorCache;
+}
 
 
 const glm::vec4& CImage::getColor4 () const {
 const glm::vec4& CImage::getColor4 () const {
     // "version" 2 materials take color and alpha together through g_Color4
     // "version" 2 materials take color and alpha together through g_Color4
-    m_color4Cache = glm::vec4 (this->m_image.color->value->getVec3 (), this->getAlpha ());
+    m_color4Cache = glm::vec4 (this->getColor (), this->getAlpha ());
     return m_color4Cache;
     return m_color4Cache;
 }
 }
 
 
@@ -2358,13 +2404,17 @@ float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExte
 void CImage::updateScreenSpacePosition () {
 void CImage::updateScreenSpacePosition () {
     const ResolvedTransform transform = this->updateGeometryBuffers ();
     const ResolvedTransform transform = this->updateGeometryBuffers ();
 
 
-    // angles are already in radians from scene.json; negated to account for the Y-flipped coordinate
-    // system (see CParticle.cpp)
+    // angles are already in radians from scene.json. WE's Rz * Ry * Rx seen through the y flip of this space is
+    // Rz (-z) * Ry (y) * Rx (-x) (see CParticle.cpp). Only the object's own x/y angles, the parent chain folds z only
     const float angle = transform.angle;
     const float angle = transform.angle;
-    glm::mat4 rotModel = glm::mat4 (1.0f);
-    if (angle != 0.0f) {
+    const glm::vec3 ownAngles = this->getImage ().angles->value->getVec3 ();
+    // origin z only shows through a perspective camera, the ortho one keeps -2000..2000 like WE
+    glm::mat4 rotModel = glm::translate (glm::mat4 (1.0f), glm::vec3 (0.0f, 0.0f, transform.origin.z));
+    if (angle != 0.0f || ownAngles.x != 0.0f || ownAngles.y != 0.0f) {
 	rotModel = glm::translate (rotModel, this->m_sceneCenter);
 	rotModel = glm::translate (rotModel, this->m_sceneCenter);
 	rotModel = glm::rotate (rotModel, -angle, glm::vec3 (0.0f, 0.0f, 1.0f));
 	rotModel = glm::rotate (rotModel, -angle, glm::vec3 (0.0f, 0.0f, 1.0f));
+	rotModel = glm::rotate (rotModel, ownAngles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
+	rotModel = glm::rotate (rotModel, -ownAngles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
 	rotModel = glm::translate (rotModel, -this->m_sceneCenter);
 	rotModel = glm::translate (rotModel, -this->m_sceneCenter);
     }
     }
 
 
@@ -2391,8 +2441,7 @@ void CImage::updateScreenSpacePosition () {
 	}
 	}
     }
     }
 
 
-    glm::mat4 mvp
-	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt () * rotModel;
+    glm::mat4 mvp = this->getViewProjection () * rotModel;
 
 
     // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
     // CScene::renderFrame() already folds disableparallax into getParallaxDisplacement()
     if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
     if (this->getScene ().getScene ().camera.parallax.enabled->value->getBool ()) {
@@ -2432,6 +2481,17 @@ void CImage::updateScreenSpacePosition () {
     }
     }
 }
 }
 
 
+glm::mat4 CImage::getViewProjection () const {
+    const auto& camera = this->getScene ().getCamera ();
+
+    // "perspective" layers get their own camera in 2D scenes (sub_1401E8AA0 -> sub_1401E5B60)
+    if (camera.isOrthogonal () && this->getImage ().perspective->value->getBool ()) {
+	return camera.getPerspectiveLayerViewProjection ();
+    }
+
+    return camera.getProjection () * camera.getLookAt ();
+}
+
 void CImage::updateLightingTransform (const glm::mat4& sceneTransform) {
 void CImage::updateLightingTransform (const glm::mat4& sceneTransform) {
     const auto width = static_cast<float> (this->getScene ().getWidth ());
     const auto width = static_cast<float> (this->getScene ().getWidth ());
     const auto height = static_cast<float> (this->getScene ().getHeight ());
     const auto height = static_cast<float> (this->getScene ().getHeight ());
@@ -2442,8 +2502,7 @@ void CImage::updateLightingTransform (const glm::mat4& sceneTransform) {
 
 
     this->m_lightingSceneModel = toWorld * sceneTransform;
     this->m_lightingSceneModel = toWorld * sceneTransform;
     this->m_lightingNormal = flip * glm::mat3 (sceneTransform) * flip;
     this->m_lightingNormal = flip * glm::mat3 (sceneTransform) * flip;
-    this->m_lightingViewProjection
-	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt () * glm::inverse (toWorld);
+    this->m_lightingViewProjection = this->getViewProjection () * glm::inverse (toWorld);
 
 
     // the first pass draws into the layer's own buffer, (0, 0) there is the image's top left corner in the scene
     // the first pass draws into the layer's own buffer, (0, 0) there is the image's top left corner in the scene
     if (this->getImage ().model->passthrough) {
     if (this->getImage ().model->passthrough) {

+ 9 - 0
src/WallpaperEngine/Render/Objects/CImage.h

@@ -141,6 +141,14 @@ protected:
     };
     };
 
 
     [[nodiscard]] ResolvedTransform resolveTransform (const WallpaperEngine::Data::Model::Object& object) const;
     [[nodiscard]] ResolvedTransform resolveTransform (const WallpaperEngine::Data::Model::Object& object) const;
+    /** Scene camera of this layer, the perspective layer camera when "perspective" is set */
+    [[nodiscard]] glm::mat4 getViewProjection () const;
+
+private:
+    /** Composite pass that applies the scene's fog, when there is fog */
+    Effects::CPass* m_fogPass = nullptr;
+
+public:
 
 
     /**
     /**
      * Computes the object's own transform (origin/scale/angle) without walking the
      * Computes the object's own transform (origin/scale/angle) without walking the
@@ -228,6 +236,7 @@ private:
     std::shared_ptr<const CFBO> m_currentSubFBO = nullptr;
     std::shared_ptr<const CFBO> m_currentSubFBO = nullptr;
 
 
     const Image& m_image;
     const Image& m_image;
+    mutable glm::vec3 m_colorCache {};
     mutable glm::vec4 m_color4Cache {};
     mutable glm::vec4 m_color4Cache {};
     mutable float m_alphaCache = 1.0f;
     mutable float m_alphaCache = 1.0f;
 
 

+ 482 - 0
src/WallpaperEngine/Render/Objects/CMesh.cpp

@@ -0,0 +1,482 @@
+#include "CMesh.h"
+
+#include <cstring>
+#include <glm/gtc/matrix_transform.hpp>
+
+#include "WallpaperEngine/Data/Model/Object.h"
+#include "WallpaperEngine/Data/Parsers/MaterialParser.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/Objects/CRenderable.h"
+#include "WallpaperEngine/Render/Objects/Effects/CPass.h"
+
+using namespace WallpaperEngine::Render::Objects;
+using namespace WallpaperEngine::Data::Parsers;
+
+namespace {
+// vertex attributes in the order they're packed, bit in the mesh's format and size in bytes (sub_1401D9860)
+struct VertexComponent {
+    uint32_t bit;
+    uint32_t size;
+};
+
+constexpr VertexComponent VERTEX_COMPONENTS[] = {
+    { 0x1, 12 },	 { 0x10000, 16 }, { 0x2, 12 },	  { 0x4, 16 },	  { 0x800000, 16 }, { 0x1000000, 16 }, { 0x8, 8 },
+    { 0x10, 12 },	 { 0x20, 16 },	  { 0x40, 8 },	  { 0x80, 12 },	  { 0x100, 16 },    { 0x200, 8 },      { 0x400, 12 },
+    { 0x800, 16 },	 { 0x1000, 8 },	  { 0x2000, 12 }, { 0x4000, 16 }, { 0x20000, 8 },   { 0x40000, 12 },   { 0x80000, 16 },
+    { 0x100000, 8 }, { 0x200000, 12 }, { 0x400000, 16 }, { 0x8000, 16 },
+};
+
+constexpr uint32_t FORMAT_POSITION = 0x1;
+constexpr uint32_t FORMAT_POSITION4 = 0x10000;
+constexpr uint32_t FORMAT_NORMAL = 0x2;
+constexpr uint32_t FORMAT_TANGENT = 0x4;
+constexpr uint32_t FORMAT_TEXCOORD2 = 0x8;
+constexpr uint32_t FORMAT_TEXCOORD3 = 0x10;
+constexpr uint32_t FORMAT_TEXCOORD4 = 0x20;
+constexpr uint32_t FORMAT_BLENDINDICES = 0x800000;
+constexpr uint32_t FORMAT_BLENDWEIGHTS = 0x1000000;
+
+constexpr uint32_t MESH_FLAG_WIDE_INDICES = 0x1;
+
+struct MdlMesh {
+    std::string material;
+    uint32_t flags = 0;
+    uint32_t format = 0;
+    uint32_t stride = 0;
+    std::vector<char> vertices;
+    std::vector<char> indices;
+};
+
+class MdlReader {
+public:
+    explicit MdlReader (const std::vector<char>& data) : m_data (data) { }
+
+    [[nodiscard]] bool has (size_t bytes) const { return this->m_offset + bytes <= this->m_data.size (); }
+
+    uint32_t u32 () {
+	uint32_t value = 0;
+
+	if (this->has (sizeof (value))) {
+	    std::memcpy (&value, this->m_data.data () + this->m_offset, sizeof (value));
+	}
+
+	this->m_offset += sizeof (value);
+	return value;
+    }
+
+    uint8_t u8 () {
+	const uint8_t value = this->has (1) ? static_cast<uint8_t> (this->m_data[this->m_offset]) : 0;
+	this->m_offset++;
+	return value;
+    }
+
+    std::string string () {
+	std::string value;
+
+	while (this->has (1) && this->m_data[this->m_offset] != '\0') {
+	    value += this->m_data[this->m_offset++];
+	}
+
+	this->m_offset++;
+	return value;
+    }
+
+    std::vector<char> bytes (size_t count) {
+	if (!this->has (count)) {
+	    throw std::runtime_error ("truncated model");
+	}
+
+	std::vector<char> value (this->m_data.begin () + this->m_offset, this->m_data.begin () + this->m_offset + count);
+	this->m_offset += count;
+	return value;
+    }
+
+    void skip (size_t count) { this->m_offset += count; }
+
+private:
+    const std::vector<char>& m_data;
+    size_t m_offset = 0;
+};
+
+std::vector<MdlMesh> readMdlMeshes (const std::vector<char>& data) {
+    MdlReader reader (data);
+    const std::string magic = reader.string ();
+
+    if (!magic.starts_with ("MDLV") || magic.size () < 5) {
+	throw std::runtime_error ("not a MDLV model");
+    }
+
+    const int version = std::stoi (magic.substr (4));
+    const uint32_t defaultFormat = reader.u32 ();
+    const uint32_t materialsPerMesh = reader.u32 ();
+    const uint32_t meshCount = reader.u32 ();
+    std::vector<MdlMesh> meshes;
+
+    for (uint32_t index = 0; index < meshCount; index++) {
+	MdlMesh mesh;
+
+	for (uint32_t material = 0; material < materialsPerMesh; material++) {
+	    const std::string name = reader.string ();
+
+	    if (material == 0) {
+		mesh.material = name;
+	    }
+	}
+
+	mesh.format = defaultFormat;
+
+	if (version >= 4) {
+	    mesh.flags = reader.u32 ();
+
+	    if (mesh.flags & 2) {
+		reader.u32 ();
+	    }
+
+	    // bounding box
+	    if (version >= 17) {
+		reader.skip (sizeof (float) * 6);
+	    }
+
+	    if (version >= 15) {
+		mesh.format = reader.u32 ();
+	    }
+	}
+
+	for (const auto& component : VERTEX_COMPONENTS) {
+	    if (mesh.format & component.bit) {
+		mesh.stride += component.size;
+	    }
+	}
+
+	mesh.vertices = reader.bytes (reader.u32 ());
+	mesh.indices = reader.bytes (reader.u32 ());
+
+	const bool usable = mesh.stride != 0 && mesh.vertices.size () % mesh.stride == 0;
+
+	if (!usable) {
+	    sLog.error ("Skipping mesh ", index, " with vertex format ", mesh.format, ", its stride doesn't match its data");
+	}
+
+	// version 23 files (the 2.4 engine only reads up to 19) close every mesh with a small block: two flag bytes, an
+	// optional sized blob and another sized blob. Static meshes write six zero bytes, puppets fill it
+	if (version >= 23) {
+	    const uint8_t first = reader.u8 ();
+	    const uint8_t second = reader.u8 ();
+
+	    if (first != 0) {
+		if (usable) {
+		    meshes.push_back (std::move (mesh));
+		}
+
+		if (index + 1 < meshCount) {
+		    sLog.error ("Unknown mesh trailer in model, only the first ", index + 1, " meshes are read");
+		}
+
+		break;
+	    }
+
+	    if (second != 0) {
+		reader.skip (reader.u32 ());
+	    }
+
+	    reader.skip (reader.u32 ());
+	}
+
+	if (usable) {
+	    meshes.push_back (std::move (mesh));
+	}
+    }
+
+    return meshes;
+}
+} // namespace
+
+class CMesh::Part final : public CRenderable {
+public:
+    Part (CMesh& owner, const Material& material, MdlMesh mesh) :
+	CObject (owner.getScene (), owner.getObject ()),
+	CRenderable (owner.getScene (), owner.getObject (), material), m_owner (owner), m_mesh (std::move (mesh)) { }
+
+    ~Part () override {
+	for (const auto* pass : this->m_passes) {
+	    delete pass;
+	}
+
+	for (const auto vao : this->m_vaos) {
+	    glDeleteVertexArrays (1, &vao);
+	}
+
+	glDeleteBuffers (1, &this->m_vbo);
+	glDeleteBuffers (1, &this->m_ebo);
+    }
+
+    void setup () override {
+	this->detectTexture ();
+
+	// a model textured by a missing file still has a shape worth drawing
+	if (this->m_texture == nullptr) {
+	    this->m_texture = this->getContext ().resolveTexture ("util/white", this->getScene ().getScene ().project);
+	}
+
+	CRenderable::setup ();
+
+	glGenBuffers (1, &this->m_vbo);
+	glBindBuffer (GL_ARRAY_BUFFER, this->m_vbo);
+	glBufferData (
+	    GL_ARRAY_BUFFER, static_cast<GLsizeiptr> (this->m_mesh.vertices.size ()), this->m_mesh.vertices.data (),
+	    GL_STATIC_DRAW
+	);
+
+	GLint previousVAO = 0;
+	glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &previousVAO);
+
+	glGenBuffers (1, &this->m_ebo);
+
+	const bool wide = (this->m_mesh.flags & MESH_FLAG_WIDE_INDICES) != 0;
+	this->m_indexType = wide ? GL_UNSIGNED_INT : GL_UNSIGNED_SHORT;
+	this->m_indexCount
+	    = static_cast<GLsizei> (this->m_mesh.indices.size () / (wide ? sizeof (uint32_t) : sizeof (uint16_t)));
+
+	this->m_fboProvider = std::make_shared<FBOProvider> (this);
+
+	for (const auto& materialPass : this->m_material.passes) {
+	    auto* pass = new Effects::CPass (*this, this->m_fboProvider, *materialPass, std::nullopt, std::nullopt, std::nullopt);
+
+	    pass->setDestination (this->getScene ().getFBO ());
+	    pass->setInput (this->getTexture ());
+	    pass->setModelMatrix (&this->m_owner.getModelMatrix ());
+	    pass->setViewProjectionMatrix (&this->m_owner.getViewProjectionMatrix ());
+	    pass->setModelViewProjectionMatrix (&this->m_owner.getModelViewProjectionMatrix ());
+	    pass->setModelViewProjectionMatrixInverse (&this->m_owner.getModelViewProjectionMatrixInverse ());
+	    pass->addUniform ("g_NormalModelMatrix", &this->m_owner.getNormalMatrix ());
+	    pass->addUniform ("g_EyePosition", &this->m_owner.getEyePosition ());
+
+	    // attribute locations differ between programs, so every pass gets its own vertex array
+	    GLuint vao = GL_NONE;
+	    glGenVertexArrays (1, &vao);
+	    glBindVertexArray (vao);
+	    glBindBuffer (GL_ARRAY_BUFFER, this->m_vbo);
+	    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_ebo);
+	    this->bindAttributes (pass->getProgramID ());
+	    this->m_vaos.push_back (vao);
+
+	    pass->setGeometryCallback (
+		[this, vao] () {
+		    glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &this->m_previousVAO);
+		    glBindVertexArray (vao);
+		    // WE is D3D: front faces wind clockwise on screen, and the scene buffer is drawn upside down here
+		    glFrontFace (GL_CW);
+		},
+		[this] () { glDrawElements (GL_TRIANGLES, this->m_indexCount, this->m_indexType, nullptr); },
+		[this] () {
+		    glFrontFace (GL_CCW);
+		    glBindVertexArray (this->m_previousVAO);
+		}
+	    );
+
+	    this->m_passes.push_back (pass);
+	}
+
+	// the element buffer is part of the vertex array state, fill it once one is bound
+	if (!this->m_vaos.empty ()) {
+	    glBindVertexArray (this->m_vaos.front ());
+	    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_ebo);
+	    glBufferData (
+		GL_ELEMENT_ARRAY_BUFFER, static_cast<GLsizeiptr> (this->m_mesh.indices.size ()),
+		this->m_mesh.indices.data (), GL_STATIC_DRAW
+	    );
+	}
+
+	glBindVertexArray (previousVAO);
+
+	// the GPU has them now
+	this->m_mesh.vertices = {};
+	this->m_mesh.indices = {};
+    }
+
+    void render () override {
+	for (auto* pass : this->m_passes) {
+	    pass->render ();
+	}
+    }
+
+    [[nodiscard]] const float& getBrightness () const override { return this->m_one; }
+    [[nodiscard]] const float& getUserAlpha () const override { return this->m_one; }
+    [[nodiscard]] const float& getAlpha () const override { return this->m_one; }
+    [[nodiscard]] const glm::vec3& getColor () const override { return this->m_white; }
+    [[nodiscard]] const glm::vec4& getColor4 () const override { return this->m_white4; }
+    [[nodiscard]] const glm::vec3& getCompositeColor () const override { return this->m_white; }
+
+private:
+    void bindAttributes (const GLuint program) const {
+	const auto stride = static_cast<GLsizei> (this->m_mesh.stride);
+	uint32_t offset = 0;
+
+	for (const auto& component : VERTEX_COMPONENTS) {
+	    if ((this->m_mesh.format & component.bit) == 0) {
+		continue;
+	    }
+
+	    const char* name = nullptr;
+	    GLint elements = static_cast<GLint> (component.size / sizeof (float));
+
+	    switch (component.bit) {
+		case FORMAT_POSITION:
+		case FORMAT_POSITION4: name = "a_Position"; break;
+		case FORMAT_NORMAL: name = "a_Normal"; break;
+		case FORMAT_TANGENT: name = "a_Tangent4"; break;
+		case FORMAT_BLENDINDICES: name = "a_BlendIndices"; break;
+		case FORMAT_BLENDWEIGHTS: name = "a_BlendWeights"; break;
+		case FORMAT_TEXCOORD2:
+		case FORMAT_TEXCOORD3:
+		case FORMAT_TEXCOORD4: name = "a_TexCoord"; break;
+		default: break;
+	    }
+
+	    const GLint location = name != nullptr ? glGetAttribLocation (program, name) : -1;
+	    const auto pointer = reinterpret_cast<const void*> (static_cast<uintptr_t> (offset));
+	    offset += component.size;
+
+	    if (location < 0) {
+		continue;
+	    }
+
+	    glEnableVertexAttribArray (location);
+
+	    if (component.bit == FORMAT_BLENDINDICES) {
+		glVertexAttribIPointer (location, elements, GL_UNSIGNED_INT, stride, pointer);
+	    } else {
+		glVertexAttribPointer (location, elements, GL_FLOAT, GL_FALSE, stride, pointer);
+	    }
+	}
+    }
+
+    CMesh& m_owner;
+    MdlMesh m_mesh;
+    std::shared_ptr<FBOProvider> m_fboProvider = nullptr;
+    std::vector<Effects::CPass*> m_passes = {};
+    std::vector<GLuint> m_vaos = {};
+    GLuint m_vbo = GL_NONE;
+    GLuint m_ebo = GL_NONE;
+    GLint m_previousVAO = 0;
+    GLsizei m_indexCount = 0;
+    GLenum m_indexType = GL_UNSIGNED_SHORT;
+    float m_one = 1.0f;
+    glm::vec3 m_white = glm::vec3 (1.0f);
+    glm::vec4 m_white4 = glm::vec4 (1.0f);
+};
+
+CMesh::CMesh (Wallpapers::CScene& scene, const Mesh& mesh) :
+    CObject (scene, mesh), ScriptableObject (scene, mesh), m_mesh (mesh) { }
+
+CMesh::~CMesh () = default;
+
+void CMesh::setup () {
+    const auto& project = this->getScene ().getScene ().project;
+    const auto stream = project.assetLocator->read (this->m_mesh.model);
+    const std::vector<char> data { std::istreambuf_iterator<char> (*stream), std::istreambuf_iterator<char> () };
+
+    for (auto& mesh : readMdlMeshes (data)) {
+	auto material = MaterialParser::load (project, mesh.material);
+
+	if (material == nullptr || material->passes.empty ()) {
+	    sLog.error ("Skipping a mesh of ", this->m_mesh.model, " without a usable material ", mesh.material);
+	    continue;
+	}
+
+	// a model that doesn't say otherwise is solid, the church in 3777414605 relies on it
+	for (const auto& pass : material->passes) {
+	    if (pass->depthtest == DepthtestMode_Unknown) {
+		pass->depthtest = DepthtestMode_Enabled;
+	    }
+
+	    if (pass->depthwrite == DepthwriteMode_Unknown) {
+		pass->depthwrite = DepthwriteMode_Enabled;
+	    }
+
+	    if (pass->cullmode == CullingMode_Unknown) {
+		pass->cullmode = CullingMode_Normal;
+	    }
+	}
+
+	auto part = std::make_unique<Part> (*this, *material, std::move (mesh));
+	this->m_materials.push_back (std::move (material));
+
+	try {
+	    part->setup ();
+	} catch (const std::exception& e) {
+	    sLog.error ("Cannot set up a mesh of ", this->m_mesh.model, ": ", e.what ());
+	    continue;
+	}
+
+	this->m_parts.push_back (std::move (part));
+    }
+
+    if (this->m_parts.empty ()) {
+	throw std::runtime_error ("no drawable meshes in " + this->m_mesh.model);
+    }
+
+    this->updateMatrices ();
+}
+
+void CMesh::render () {
+    if (!this->m_mesh.groupVisible->value->getBool ()) {
+	return;
+    }
+
+    this->updateMatrices ();
+
+    for (const auto& part : this->m_parts) {
+	part->render ();
+    }
+}
+
+glm::mat4 CMesh::localTransform (const Object& object) const {
+    const glm::vec3 angles = object.groupAngles->value->getVec3 ();
+
+    // T * Rz * Ry * Rx * S, WE's row vector version of it is sub_140148A20 + sub_14016B7C0
+    glm::mat4 transform = glm::translate (glm::mat4 (1.0f), object.origin->value->getVec3 ());
+    transform = glm::rotate (transform, angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
+    transform = glm::rotate (transform, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
+    transform = glm::rotate (transform, angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
+    return glm::scale (transform, object.groupScale->value->getVec3 ());
+}
+
+void CMesh::updateMatrices () {
+    glm::mat4 model = this->localTransform (this->m_mesh);
+    const Object* current = &this->m_mesh;
+
+    for (int depth = 0; current->parent.has_value () && depth < 64; depth++) {
+	const auto* parent = this->getScene ().getObject (current->parent.value ());
+
+	if (parent == nullptr) {
+	    break;
+	}
+
+	current = &parent->getObject ();
+	model = this->localTransform (*current) * model;
+    }
+
+    const auto& camera = this->getScene ().getCamera ();
+
+    this->m_modelMatrix = model;
+    this->m_normalMatrix = glm::mat3 (model);
+    this->m_viewProjection = camera.getPerspective () * camera.getView ();
+    this->m_modelViewProjection = this->m_viewProjection * model;
+    this->m_modelViewProjectionInverse = glm::inverse (this->m_modelViewProjection);
+    this->m_eyePosition = camera.getEye ();
+}
+
+const Mesh& CMesh::getMesh () const { return this->m_mesh; }
+
+const glm::mat4& CMesh::getModelMatrix () const { return this->m_modelMatrix; }
+
+const glm::mat3& CMesh::getNormalMatrix () const { return this->m_normalMatrix; }
+
+const glm::mat4& CMesh::getViewProjectionMatrix () const { return this->m_viewProjection; }
+
+const glm::mat4& CMesh::getModelViewProjectionMatrix () const { return this->m_modelViewProjection; }
+
+const glm::mat4& CMesh::getModelViewProjectionMatrixInverse () const { return this->m_modelViewProjectionInverse; }
+
+const glm::vec3& CMesh::getEyePosition () const { return this->m_eyePosition; }

+ 48 - 0
src/WallpaperEngine/Render/Objects/CMesh.h

@@ -0,0 +1,48 @@
+#pragma once
+
+#include "WallpaperEngine/Scripting/ScriptableObject.h"
+
+#include <glm/mat3x3.hpp>
+#include <glm/mat4x4.hpp>
+
+namespace WallpaperEngine::Render::Objects {
+using namespace WallpaperEngine::Data::Model;
+
+/**
+ * A static 3D model ("model" objects) in a perspective scene. Every mesh in the .mdl has its own material and is
+ * drawn straight into the scene buffer with depth testing, the bind pose only (no skinning or animation yet)
+ */
+class CMesh final : public Scripting::ScriptableObject {
+public:
+    CMesh (Wallpapers::CScene& scene, const Mesh& mesh);
+    ~CMesh () override;
+
+    void setup () override;
+    void render () override;
+
+    [[nodiscard]] const Mesh& getMesh () const;
+    [[nodiscard]] const glm::mat4& getModelMatrix () const;
+    [[nodiscard]] const glm::mat3& getNormalMatrix () const;
+    [[nodiscard]] const glm::mat4& getViewProjectionMatrix () const;
+    [[nodiscard]] const glm::mat4& getModelViewProjectionMatrix () const;
+    [[nodiscard]] const glm::mat4& getModelViewProjectionMatrixInverse () const;
+    [[nodiscard]] const glm::vec3& getEyePosition () const;
+
+    class Part;
+
+private:
+    void updateMatrices ();
+    [[nodiscard]] glm::mat4 localTransform (const Object& object) const;
+
+    const Mesh& m_mesh;
+    std::vector<MaterialUniquePtr> m_materials;
+    std::vector<std::unique_ptr<Part>> m_parts;
+
+    glm::mat4 m_modelMatrix = glm::mat4 (1.0f);
+    glm::mat3 m_normalMatrix = glm::mat3 (1.0f);
+    glm::mat4 m_viewProjection = glm::mat4 (1.0f);
+    glm::mat4 m_modelViewProjection = glm::mat4 (1.0f);
+    glm::mat4 m_modelViewProjectionInverse = glm::mat4 (1.0f);
+    glm::vec3 m_eyePosition = glm::vec3 (0.0f);
+};
+} // namespace WallpaperEngine::Render::Objects

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 775 - 176
src/WallpaperEngine/Render/Objects/CParticle.cpp


+ 181 - 2
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -11,6 +11,7 @@
 #include <glm/vec3.hpp>
 #include <glm/vec3.hpp>
 #include <glm/vec4.hpp>
 #include <glm/vec4.hpp>
 #include <memory>
 #include <memory>
+#include <optional>
 #include <random>
 #include <random>
 #include <vector>
 #include <vector>
 
 
@@ -24,6 +25,8 @@ constexpr uint32_t DEFAULT_MAX_PARTICLES = 1000;
 
 
 struct ParticleInstance {
 struct ParticleInstance {
     glm::vec3 position { 0.0f };
     glm::vec3 position { 0.0f };
+    /** Where the particle was when this frame's operators started, collisionquad tests the step in between */
+    glm::vec3 previousPosition { 0.0f };
     glm::vec3 velocity { 0.0f };
     glm::vec3 velocity { 0.0f };
     glm::vec3 acceleration { 0.0f };
     glm::vec3 acceleration { 0.0f };
 
 
@@ -67,18 +70,39 @@ struct ParticleInstance {
     /** Random 0..1 picked at spawn, WE's turbulence operator uses it for the particle's phase and speed */
     /** Random 0..1 picked at spawn, WE's turbulence operator uses it for the particle's phase and speed */
     float seed { 0.0f };
     float seed { 0.0f };
 
 
+    /** Unique per system, eventfollow children find the particle they follow by it (indices shift on compaction) */
+    uint32_t id { 0 };
+    /** WE's pool slot: the lowest one free at spawn, what orders particles handed to child control points */
+    uint32_t slot { 0 };
+
     bool alive { false };
     bool alive { false };
 
 
     float getLifetimePos () const { return lifetime > 0.0f ? (age / lifetime) : 1.0f; }
     float getLifetimePos () const { return lifetime > 0.0f ? (age / lifetime) : 1.0f; }
 
 
-    bool isAlive () const { return alive && age < lifetime; }
+    // sub_140236CD0 kills a particle once lifetime < age, so it is still drawn on the frame it reaches its lifetime
+    bool isAlive () const { return alive && lifetime != 0.0f && !(lifetime < age); }
 };
 };
 
 
 struct ControlPointData {
 struct ControlPointData {
     glm::vec3 position { 0.0f };
     glm::vec3 position { 0.0f };
+    /** Rotation and scale of the control point's matrix, emitters orient their shape and velocities with it */
+    glm::mat3 orientation { 1.0f };
     glm::vec3 offset { 0.0f };
     glm::vec3 offset { 0.0f };
     bool linkMouse { false };
     bool linkMouse { false };
     bool worldSpace { false };
     bool worldSpace { false };
+    /** Follows the parent system's control point parentIndex (child systems only) */
+    bool followParent { false };
+    /** Flag 8: takes the parent's control point as it is, without moving it into this system's space */
+    bool copyUntransformed { false };
+    /** Written by a remap component, the engine leaves it alone (WE's loader flag 0x10000) */
+    bool remapOutput { false };
+    int parentIndex { 0 };
+    /** Movement per second over the last update, for inheritcontrolpointvelocity */
+    glm::vec3 velocity { 0.0f };
+    /** How far it moved since the last update (wallpaper64.exe keeps last frame's matrix at +64) */
+    glm::vec3 movement { 0.0f };
+    glm::vec3 previousPosition { 0.0f };
+    bool hasPreviousPosition { false };
 };
 };
 
 
 using EmitterFunc = std::function<void (std::vector<ParticleInstance>&, uint32_t&, float)>;
 using EmitterFunc = std::function<void (std::vector<ParticleInstance>&, uint32_t&, float)>;
@@ -116,6 +140,11 @@ protected:
 
 
     EmitterFunc createBoxEmitter (const ParticleEmitter& emitter);
     EmitterFunc createBoxEmitter (const ParticleEmitter& emitter);
     EmitterFunc createSphereEmitter (const ParticleEmitter& emitter);
     EmitterFunc createSphereEmitter (const ParticleEmitter& emitter);
+    /** Spawn position from the emitter's shape offset and control point, sets m_emitOrientation */
+    void placeSpawn (
+	ParticleInstance& p, const ControlPointData* cp, int controlPointIndex, const glm::vec3& origin,
+	glm::vec3& offset
+    );
 
 
     [[nodiscard]] float sampleAudio (
     [[nodiscard]] float sampleAudio (
 	int mode, const glm::vec2& bounds, float exponent, int frequencyStart, int frequencyEnd
 	int mode, const glm::vec2& bounds, float exponent, int frequencyStart, int frequencyEnd
@@ -131,6 +160,14 @@ protected:
     InitializerFunc createTurbulentVelocityRandomInitializer (const TurbulentVelocityRandomInitializer& init);
     InitializerFunc createTurbulentVelocityRandomInitializer (const TurbulentVelocityRandomInitializer& init);
     InitializerFunc
     InitializerFunc
     createMapSequenceAroundControlPointInitializer (const MapSequenceAroundControlPointInitializer& init);
     createMapSequenceAroundControlPointInitializer (const MapSequenceAroundControlPointInitializer& init);
+    InitializerFunc createInheritInitialValueFromEventInitializer (const InheritInitialValueFromEventInitializer& init);
+    InitializerFunc createInheritControlPointVelocityInitializer (const InheritControlPointVelocityInitializer& init);
+    InitializerFunc createHsvColorRandomInitializer (const HsvColorRandomInitializer& init);
+    InitializerFunc createColorListInitializer (const ColorListInitializer& init);
+    InitializerFunc createPositionOffsetRandomInitializer (const PositionOffsetRandomInitializer& init);
+    InitializerFunc
+    createMapSequenceBetweenControlPointsInitializer (const MapSequenceBetweenControlPointsInitializer& init);
+    InitializerFunc createRemapInitialValueInitializer (const RemapInitialValueInitializer& init);
 
 
     OperatorFunc createMovementOperator (const MovementOperator& op);
     OperatorFunc createMovementOperator (const MovementOperator& op);
     OperatorFunc createAngularMovementOperator (const AngularMovementOperator& op);
     OperatorFunc createAngularMovementOperator (const AngularMovementOperator& op);
@@ -144,18 +181,73 @@ protected:
     OperatorFunc createOscillateAlphaOperator (const OscillateAlphaOperator& op);
     OperatorFunc createOscillateAlphaOperator (const OscillateAlphaOperator& op);
     OperatorFunc createOscillateSizeOperator (const OscillateSizeOperator& op);
     OperatorFunc createOscillateSizeOperator (const OscillateSizeOperator& op);
     OperatorFunc createOscillatePositionOperator (const OscillatePositionOperator& op);
     OperatorFunc createOscillatePositionOperator (const OscillatePositionOperator& op);
+    OperatorFunc createInheritValueFromEventOperator (const InheritValueFromEventOperator& op);
+    OperatorFunc createCapVelocityOperator (const CapVelocityOperator& op);
+    OperatorFunc createBoidsOperator (const BoidsOperator& op);
+    OperatorFunc createRemapValueOperator (const RemapValueOperator& op);
+    OperatorFunc createMaintainDistanceToControlPointOperator (const MaintainDistanceToControlPointOperator& op);
+    OperatorFunc
+    createMaintainDistanceBetweenControlPointsOperator (const MaintainDistanceBetweenControlPointsOperator& op);
+    OperatorFunc createReduceMovementNearControlPointOperator (const ReduceMovementNearControlPointOperator& op);
+    OperatorFunc createCollisionOperator (const CollisionOperator& op);
+
+    /** A remap input as wallpaper64.exe reads it during the simulation (sub_14023FBC0 case 19), in its y-up space */
+    [[nodiscard]] glm::vec3 remapOperatorInput (const ParticleRemap& remap, const ParticleInstance& p) const;
+    /** The same for remapinitialvalue (sub_14023B340 case 15), which reads the particle's base values */
+    [[nodiscard]] glm::vec3 remapInitialInput (const ParticleRemap& remap, ParticleInstance& p);
+    /** Layer transform translation in wallpaper64.exe's scene coordinates (remap input layerorigin) */
+    [[nodiscard]] glm::vec3 remapLayerOrigin () const;
+    /** Control point position in wallpaper64.exe's y-up particle space */
+    [[nodiscard]] glm::vec3 controlPointWE (int index) const;
+    /** Operators scale some forces by how long frames take (sub_140236CD0): dt * min(1, 0.025 / frame time)^0.7 */
+    [[nodiscard]] float frameScaledDelta (float dt) const;
+    /** wallpaper64.exe's time since the layer became visible (+1904), the remap input layertime */
+    [[nodiscard]] float layerTime () const;
 
 
     void renderSprites ();
     void renderSprites ();
     void renderRope ();
     void renderRope ();
+    void buildRopeTrail (uint32_t& vertexIndex, uint32_t& indexOffset);
+    [[nodiscard]] float ropeUVScale () const;
     void setupPass ();
     void setupPass ();
     void setupGeometryCallbacks ();
     void setupGeometryCallbacks ();
     void setupParticleUniforms ();
     void setupParticleUniforms ();
     void updateMatrices ();
     void updateMatrices ();
+    /** The system's origin in the space it's drawn in (centered and y-down for 2D scenes, world for 3D ones) */
+    [[nodiscard]] glm::vec3 sceneOrigin () const;
     void syncTransformedOrigin ();
     void syncTransformedOrigin ();
-    void applyParallaxToModelMatrix ();
     void updateParticleViewProjection ();
     void updateParticleViewProjection ();
     void updateParticleRenderVars ();
     void updateParticleRenderVars ();
 
 
+    void prewarm (double now);
+    /** base is the matrix the parent leaves for its children, identity for top level systems */
+    void draw (const glm::mat4& base);
+
+    /** The object's transform (origin, parallax, angles, scale), what WE keeps at +928 for top level systems */
+    [[nodiscard]] glm::mat4 objectMatrix () const;
+    /** The matrix stack top WE simulates this system with (sub_140229760 / sub_140229810) */
+    void updateFrame ();
+    /** wallpaper64.exe sub_14022E3E0 */
+    void updateControlPoints ();
+    [[nodiscard]] DynamicValue* emitterCountOverride () const;
+    /** wallpaper64.exe sub_14022BD40 / sub_14022F890: whether the instanceoverride color applies, and how */
+    void refreshColorOverride ();
+    /** wallpaper64.exe sub_14022A360: where an event child sits for one of this system's particles */
+    [[nodiscard]] glm::mat4 eventPlacement (const ParticleChild& child, const glm::vec3& position) const;
+
+    /** A child system: owned and driven by its parent, placed in the parent's particle space */
+    CParticle (CParticle& parent, const ParticleChild& child);
+    void setupChildren ();
+    void updateChildren (float dt);
+    void spawnEventChildren (ParticleChildType type, const ParticleInstance& particle, bool alive);
+    void clearEventChildren ();
+    void placeChild (const glm::mat4& placement);
+    [[nodiscard]] const ParticleInstance* findParticle (uint32_t id) const;
+    void passControlPoints (CParticle& child, const ParticleChild& definition) const;
+    /** Starts over as a freshly spawned system, for pooled event children */
+    void restart ();
+    [[nodiscard]] bool isFinished () const;
+    [[nodiscard]] bool emittersExhausted () const;
+
 private:
 private:
     const Particle& m_particle;
     const Particle& m_particle;
 
 
@@ -234,6 +326,16 @@ private:
     bool m_ropeUVScrolling { false };
     bool m_ropeUVScrolling { false };
     bool m_ropeUVSmoothing { true }; // rope only
     bool m_ropeUVSmoothing { true }; // rope only
     bool m_uniformLifetimes { false }; // true when lifetime min==max (enables UV smoothing)
     bool m_uniformLifetimes { false }; // true when lifetime min==max (enables UV smoothing)
+    bool m_trailFadeAlpha { false };
+    bool m_trailFadeSize { false };
+    /** ropetrail: m_ropeSegments past positions per particle slot, newest first, compacted with the particles */
+    std::vector<glm::vec3> m_trailHistory;
+    /** History entries filled so far and pushes since spawn (uvscrolling) per particle slot */
+    std::vector<uint16_t> m_trailCount;
+    std::vector<uint16_t> m_trailScroll;
+    /** Counts down to the next history push, WE starts it at 0 so the first update pushes */
+    float m_trailTimer { 0.0f };
+    float m_trailInterval { 1.0f };
 
 
     static constexpr int SPRITE_FLOATS_PER_VERTEX = 17;
     static constexpr int SPRITE_FLOATS_PER_VERTEX = 17;
     static constexpr int ROPE_FLOATS_PER_VERTEX = 26;
     static constexpr int ROPE_FLOATS_PER_VERTEX = 26;
@@ -251,5 +353,82 @@ private:
 
 
     bool m_initialized { false };
     bool m_initialized { false };
     Playback m_lastPlayback { Playback::Playing };
     Playback m_lastPlayback { Playback::Playing };
+
+    struct EventChildSlot {
+	const ParticleChild* child;
+	std::vector<std::unique_ptr<CParticle>> active;
+	std::vector<std::unique_ptr<CParticle>> pool;
+    };
+
+    CParticle* m_parent { nullptr };
+    const ParticleChild* m_childDefinition { nullptr };
+    /**
+     * WE's +928 matrix. Top level: the object's transform. Static children: the child transform. Event children:
+     * the spawning particle's position and the child transform, in the parent's space or the world (eventPlacement)
+     */
+    glm::mat4 m_placement { 1.0f };
+    /** The full transform particles are simulated in, the world for world space systems */
+    glm::mat4 m_frame { 1.0f };
+    /** Particle flags bit 0: particles are kept in world space, moving the system leaves them behind */
+    bool m_worldSpace { false };
+    /** Orientation of the control point the current emitter spawns from, the velocity initializers apply it */
+    glm::mat3 m_emitOrientation { 1.0f };
+    /** Event children: the parent's particle that spawned it, while it lives (eventfollow ones move with it) */
+    std::optional<uint32_t> m_eventId;
+    /** That particle as last seen, what the inherit components read */
+    ParticleInstance m_eventParticle;
+    bool m_hasEventParticle { false };
+    bool m_following { false };
+    bool m_emissionStopped { false };
+    /** Emission time since (re)start, for telling when every emitter is done */
+    float m_emitterTime { 0.0f };
+
+    std::vector<std::unique_ptr<CParticle>> m_staticChildren;
+    std::vector<EventChildSlot> m_eventChildren;
+    bool m_hasBirthEvents { false };
+    bool m_hasDeathEvents { false };
+    /** No events while prewarming, like WE */
+    bool m_prewarming { false };
+    uint32_t m_nextParticleId { 0 };
+    /** Pool slots taken by live particles, see ParticleInstance::slot */
+    std::vector<uint8_t> m_slotUsed;
+    std::vector<uint32_t> m_births;
+    std::vector<ParticleInstance> m_deaths;
+
+    /** Slots handed out since the last restart, wallpaper64.exe's particle count (+832), the boids sample stride */
+    uint32_t m_slotExtent { 0 };
+    /**
+     * wallpaper64.exe never clears a dead pool slot: movement keeps integrating it and boids still reads it as a
+     * neighbor (see createBoidsOperator). Kept per slot for systems with boids, the only operator that reads others
+     */
+    std::vector<ParticleInstance> m_ghosts;
+    std::vector<uint8_t> m_ghostUsed;
+    bool m_hasBoids { false };
+    /** wallpaper64.exe +1008: simulated time since the system started, the remapinitialvalue particlesystemtime */
+    float m_systemTime { 0.0f };
+    /** Top level systems only, see layerTime () */
+    float m_layerTime { 0.0f };
+    /** Real time between the last two rendered frames and the frame count, for frameScaledDelta and boids */
+    float m_frameDelta { 0.0f };
+    float m_lastRealTime { 0.0f };
+    uint32_t m_frameCounter { 0 };
+    struct ColorOverride {
+	/** override color given and further than 0.9/255 from the particle file's reference color */
+	bool active = false;
+	/** spawn color every particle starts from (sub_1401D15A0), the override color itself only in tint mode */
+	glm::vec3 tint = glm::vec3 (1.0f);
+	glm::vec3 hsv = glm::vec3 (0.0f);
+	/** override HSV minus the reference HSV, what colorrandom and colorchange shift their colors by */
+	glm::vec3 shift = glm::vec3 (0.0f);
+    };
+    ColorOverride m_colorOverride {};
+    /** System flag 2 in wallpaper64.exe: angularvelocityrandom or angularmovement, angular speed means something */
+    bool m_hasAngularVelocity { false };
+    /** sub_14023FBC0 restores these from the spawn values before the operators when a remapvalue writes them */
+    bool m_resetSizeFromBase { false };
+    bool m_resetAlphaFromBase { false };
+    bool m_resetColorFromBase { false };
+    /** collisionquad needs where particles were before this frame's operators */
+    bool m_tracksPreviousPosition { false };
 };
 };
 } // namespace WallpaperEngine::Render::Objects
 } // namespace WallpaperEngine::Render::Objects

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 357 - 481
src/WallpaperEngine/Render/Objects/CText.cpp


+ 65 - 48
src/WallpaperEngine/Render/Objects/CText.h

@@ -9,15 +9,10 @@
 
 
 #include "WallpaperEngine/Render/Objects/CRenderable.h"
 #include "WallpaperEngine/Render/Objects/CRenderable.h"
 #include "WallpaperEngine/Render/Objects/Effects/CPass.h"
 #include "WallpaperEngine/Render/Objects/Effects/CPass.h"
+#include "WallpaperEngine/Render/Objects/TextLayout.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 #include "WallpaperEngine/Scripting/ScriptableObject.h"
 #include "WallpaperEngine/Scripting/ScriptableObject.h"
 
 
-// Forward-declare FreeType types to avoid leaking the header into users.
-struct FT_LibraryRec_;
-struct FT_FaceRec_;
-typedef struct FT_LibraryRec_* FT_Library;
-typedef struct FT_FaceRec_* FT_Face;
-
 namespace WallpaperEngine::Render {
 namespace WallpaperEngine::Render {
 class TextureProvider;
 class TextureProvider;
 }
 }
@@ -30,15 +25,10 @@ namespace WallpaperEngine::Render::Objects {
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::Data::Model;
 
 
 /**
 /**
- * Renders text objects as a single FreeType-rasterized coverage texture, tinted by
- * the real Wallpaper Engine "font" shader and drawn through the same CRenderable/CPass
- * pipeline CImage uses - so per-object effects (edgedetection, waterripple, ...) apply
- * to text the same way they do to images.
- *
- * Supports scripted/dynamic text (via ScriptableObject's layer scripts) and places the
- * glyph quad within the object's size/padding box according to horizontalalign/verticalalign.
- * Still single-line only - no wrapping. Effects only see the text's own rendered pixels,
- * not the scene behind it (copybackground-style effects are not supported).
+ * Text objects the way wallpaper64.exe 2.8.42 draws them: HarfBuzz shaped glyphs from an atlas (plain coverage, or
+ * MSDF when msdf/outline/blur/drop shadow is on) drawn with WE's own materials/fonts materials, an optional opaque
+ * background quad, and when the text has effects everything goes through a buffer the size of the text box plus
+ * padding first, the way images run their effects.
  */
  */
 class CText final : public CRenderable, public Scripting::ScriptableObject {
 class CText final : public CRenderable, public Scripting::ScriptableObject {
     friend CObject;
     friend CObject;
@@ -58,38 +48,63 @@ public:
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
 
 
 private:
 private:
-    void rebuildTextureFrom (const std::string& text);
-    void uploadQuadVertices ();
+    /** The parts of the text's settings that change which passes exist */
+    struct PassLayout {
+	bool msdf = false;
+	bool outline = false;
+	bool blur = false;
+	bool dropShadow = false;
+	bool background = false;
+	/** effects or a blend mode: the text goes through a buffer of its box plus padding first */
+	bool buffered = false;
+	int blendMode = 0;
+	glm::ivec2 bufferSize = { 0, 0 };
+
+	bool operator== (const PassLayout&) const = default;
+    };
+
+    [[nodiscard]] TextLayoutParams currentParams () const;
+    [[nodiscard]] PassLayout currentPassLayout () const;
+    [[nodiscard]] glm::vec2 currentPadding () const;
+    [[nodiscard]] glm::vec2 screenAnchorOffset () const;
+    void relayout (const std::string& text);
+    void uploadGeometry ();
+    void updateRenderVars ();
     void buildPasses ();
     void buildPasses ();
     void destroyPasses ();
     void destroyPasses ();
+    Effects::CPass* createFontPass (const std::shared_ptr<const CFBO>& destination, const glm::mat4* mvp);
 
 
-    bool initFreeType ();
-    bool loadEmbeddedFont ();
-    bool loadSystemFont ();
-    void reloadFont ();
-    unsigned int computeEffectivePixelSize () const;
+    bool loadFont ();
     void initScriptLayer ();
     void initScriptLayer ();
 
 
     const Text& m_text;
     const Text& m_text;
     std::string m_lastRenderedText;
     std::string m_lastRenderedText;
-    unsigned int m_lastPixelSize = 0;
+    TextLayoutParams m_params;
+    TextLayout m_layout;
+    TextLayoutResult m_result;
+    PassLayout m_passLayout;
     Scripting::ScriptLayerHandle m_layerHandle = Scripting::kInvalidLayerHandle;
     Scripting::ScriptLayerHandle m_layerHandle = Scripting::kInvalidLayerHandle;
 
 
-    FT_Library m_ftLibrary = nullptr;
-    FT_Face m_ftFace = nullptr;
-    std::vector<uint8_t> m_fontData;
     /** Backs the scripts' layer.font */
     /** Backs the scripts' layer.font */
     DynamicValue m_font;
     DynamicValue m_font;
     std::string m_loadedFont;
     std::string m_loadedFont;
 
 
-    std::shared_ptr<TextureProvider> m_glyphTexture;
-
-    // Effect passes run FBO -> FBO at a fixed size (m_quadSize) with an identity matrix like CImage,
-    // only the final composite pass needs its own scene-positioned quad/matrix.
-    GLuint m_copySpacePosition = 0;
+    std::shared_ptr<TextureProvider> m_atlas;
+    MaterialUniquePtr m_fontMaterial;
+    MaterialUniquePtr m_backgroundMaterial;
+    MaterialUniquePtr m_clearAlphaMaterial;
+    MaterialUniquePtr m_passthroughMaterial;
+    ImageEffectPassOverride m_passthroughOverride = { .id = -1 };
+    ImageEffectPassOverride m_fontOverride = { .id = -1 };
+
+    GLuint m_glyphPositions = 0;
+    GLuint m_glyphTexcoords = 0;
+    GLsizei m_glyphVertexCount = 0;
+    GLuint m_backgroundPositions = 0;
+    // effect passes run FBO -> FBO with an identity matrix like CImage, the composite quad is the buffer's size
     GLuint m_passSpacePosition = 0;
     GLuint m_passSpacePosition = 0;
-    GLuint m_sceneSpacePosition = 0;
-    GLuint m_texcoordCopy = 0;
+    GLuint m_compositePosition = 0;
+    GLuint m_quadTexcoords = 0;
 
 
     std::vector<Effects::CPass*> m_passes = {};
     std::vector<Effects::CPass*> m_passes = {};
 
 
@@ -98,26 +113,28 @@ private:
     std::shared_ptr<const CFBO> m_currentMainFBO = nullptr;
     std::shared_ptr<const CFBO> m_currentMainFBO = nullptr;
     std::shared_ptr<const CFBO> m_currentSubFBO = nullptr;
     std::shared_ptr<const CFBO> m_currentSubFBO = nullptr;
 
 
+    glm::mat4 m_glyphSceneMatrix = glm::mat4 (1.0f);
+    glm::mat4 m_glyphSceneMatrixInverse = glm::mat4 (1.0f);
+    glm::mat4 m_glyphBufferMatrix = glm::mat4 (1.0f);
+    glm::mat4 m_glyphBufferMatrixInverse = glm::mat4 (1.0f);
+    glm::mat4 m_compositeMatrix = glm::mat4 (1.0f);
+    glm::mat4 m_compositeMatrixInverse = glm::mat4 (1.0f);
     glm::mat4 m_modelViewProjectionPass = glm::mat4 (1.0f);
     glm::mat4 m_modelViewProjectionPass = glm::mat4 (1.0f);
-    glm::mat4 m_modelViewProjectionCopy = {};
-    glm::mat4 m_modelViewProjectionCopyInverse = {};
-    glm::mat4 m_modelViewProjectionScreen = {};
-    glm::mat4 m_modelViewProjectionScreenInverse = {};
-    glm::mat4 m_modelMatrix = {};
-    glm::mat4 m_viewProjectionMatrix = {};
+    glm::mat4 m_modelMatrix = glm::mat4 (1.0f);
+    /** Composite quad -> scene, what m_compositeMatrix projects */
+    glm::mat4 m_compositeModel = glm::mat4 (1.0f);
+    glm::mat4 m_viewProjectionMatrix = glm::mat4 (1.0f);
+
+    glm::vec4 m_renderVars[4] = {};
+    glm::vec3 m_backgroundColor = { 0.0f, 0.0f, 0.0f };
+    float m_backgroundAlpha = 1.0f;
+    glm::vec4 m_backgroundColor4 = { 0.0f, 0.0f, 0.0f, 1.0f };
+    const glm::vec4 m_white = { 1.0f, 1.0f, 1.0f, 1.0f };
 
 
     bool m_textFromProperty = false;
     bool m_textFromProperty = false;
+    mutable glm::vec3 m_colorCache {};
     mutable glm::vec4 m_color4Cache = { 1.0f, 1.0f, 1.0f, 1.0f };
     mutable glm::vec4 m_color4Cache = { 1.0f, 1.0f, 1.0f, 1.0f };
 
 
-    glm::ivec2 m_textureSize = { 0, 0 };
-    glm::vec2 m_quadSize = { 0.0f, 0.0f };
-    int m_descender = 0;
-    /** the layout box (what alignment uses); the texture can be larger when glyphs overhang it */
-    glm::vec2 m_boxSize = { 0.0f, 0.0f };
-    /** layout box center minus texture center, in texture pixels (y down) */
-    glm::vec2 m_boxShift = { 0.0f, 0.0f };
-
-    bool m_valid = false;
     bool m_initialized = false;
     bool m_initialized = false;
 };
 };
 } // namespace WallpaperEngine::Render::Objects
 } // namespace WallpaperEngine::Render::Objects

+ 212 - 41
src/WallpaperEngine/Render/Objects/Effects/CPass.cpp

@@ -154,12 +154,16 @@ CPass::~CPass () {
     }
     }
 
 
     // text layers rebuild their passes on every glyph texture resize, so this can't be left to leak
     // text layers rebuild their passes on every glyph texture resize, so this can't be left to leak
-    delete this->m_shader;
     this->m_shader = nullptr;
     this->m_shader = nullptr;
+    this->m_compiled = nullptr;
 
 
     glDeleteVertexArrays (1, &m_vao);
     glDeleteVertexArrays (1, &m_vao);
     this->m_vao = GL_NONE;
     this->m_vao = GL_NONE;
 
 
+    if (this->releaseSharedProgram ()) {
+	return;
+    }
+
     if (!glIsProgram (this->m_programID)) {
     if (!glIsProgram (this->m_programID)) {
 	return; // program already invalid or deleted
 	return; // program already invalid or deleted
     }
     }
@@ -266,10 +270,14 @@ void CPass::setupRenderFramebuffer () const {
     // Private per-object FBOs are never cleared elsewhere, so a blending pass would otherwise
     // Private per-object FBOs are never cleared elsewhere, so a blending pass would otherwise
     // accumulate stale alpha across frames. The shared scene FBO must not be cleared here though,
     // accumulate stale alpha across frames. The shared scene FBO must not be cleared here though,
     // since it accumulates every object drawn this frame.
     // since it accumulates every object drawn this frame.
-    if (this->m_drawTo != this->m_renderable.getScene ().getFBO ()) {
+    if (this->m_drawTo != this->m_renderable.getScene ().getFBO () && !this->m_keepDestination) {
 	GLfloat previousClearColor[4] = {};
 	GLfloat previousClearColor[4] = {};
 	glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
 	glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
-	glClearColor (0.0f, 0.0f, 0.0f, 0.0f);
+	if (this->m_clearColor != nullptr) {
+	    glClearColor (this->m_clearColor->r, this->m_clearColor->g, this->m_clearColor->b, this->m_clearColor->a);
+	} else {
+	    glClearColor (0.0f, 0.0f, 0.0f, 0.0f);
+	}
 	glClear (GL_COLOR_BUFFER_BIT);
 	glClear (GL_COLOR_BUFFER_BIT);
 	glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
 	glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
     }
     }
@@ -681,6 +689,20 @@ void CPass::render () {
     this->cleanupRenderSetup ();
     this->cleanupRenderSetup ();
 }
 }
 
 
+void CPass::clearDestination () const {
+    if (this->m_drawTo == nullptr) {
+	return;
+    }
+
+    GLfloat previousClearColor[4] = {};
+    glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_drawTo->getFramebuffer ());
+    glViewport (0, 0, this->m_drawTo->getRealWidth (), this->m_drawTo->getRealHeight ());
+    glClearColor (0.0f, 0.0f, 0.0f, 0.0f);
+    glClear (GL_COLOR_BUFFER_BIT);
+    glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
+}
+
 std::shared_ptr<const FBOProvider> CPass::getFBOProvider () const { return this->m_fboProvider; }
 std::shared_ptr<const FBOProvider> CPass::getFBOProvider () const { return this->m_fboProvider; }
 
 
 const CRenderable& CPass::getRenderable () const { return this->m_renderable; }
 const CRenderable& CPass::getRenderable () const { return this->m_renderable; }
@@ -703,6 +725,16 @@ void CPass::setModelViewProjectionMatrixInverse (const glm::mat4* projection) {
 
 
 void CPass::setModelMatrix (const glm::mat4* model) { this->m_modelMatrix = model; }
 void CPass::setModelMatrix (const glm::mat4* model) { this->m_modelMatrix = model; }
 
 
+void CPass::setFogWorld (const bool world) {
+    this->m_fogWorld = world;
+    const auto& fog = this->m_renderable.getScene ().getFog ();
+    this->addUniform ("g_FogHeightParams", world ? &fog.heightParamsWorld : &fog.heightParamsLocal);
+
+    if (world) {
+	this->addUniform ("g_EyePosition", &fog.eyeWorld);
+    }
+}
+
 void CPass::setViewProjectionMatrix (const glm::mat4* viewProjection) { this->m_viewProjectionMatrix = viewProjection; }
 void CPass::setViewProjectionMatrix (const glm::mat4* viewProjection) { this->m_viewProjectionMatrix = viewProjection; }
 
 
 void CPass::setLightingTransform (const glm::mat4* model, const glm::mat3* normal, const glm::mat4* viewProjection) {
 void CPass::setLightingTransform (const glm::mat4* model, const glm::mat3* normal, const glm::mat4* viewProjection) {
@@ -722,6 +754,10 @@ BlendingMode CPass::getBlendingMode () const { return this->m_blendingmode; }
 
 
 void CPass::setTexCoord (GLuint texcoord) { this->a_TexCoord = texcoord; }
 void CPass::setTexCoord (GLuint texcoord) { this->a_TexCoord = texcoord; }
 
 
+void CPass::setClearColor (const glm::vec4* color) { this->m_clearColor = color; }
+
+void CPass::setKeepDestination (bool keep) { this->m_keepDestination = keep; }
+
 void CPass::setPosition (GLuint position) { this->a_Position = position; }
 void CPass::setPosition (GLuint position) { this->a_Position = position; }
 
 
 const MaterialPass& CPass::getPass () const { return this->m_pass; }
 const MaterialPass& CPass::getPass () const { return this->m_pass; }
@@ -832,6 +868,11 @@ void CPass::setupShaders () {
 	this->m_combos.insert_or_assign ("PRELIGHTING", 1);
 	this->m_combos.insert_or_assign ("PRELIGHTING", 1);
     }
     }
 
 
+    // HDR scene rendering defines HDR for every pass (sub_1401A5C40)
+    if (this->m_renderable.getScene ().isHDR ()) {
+	this->m_combos.insert_or_assign ("HDR", 1);
+    }
+
     // particle shaders read TEX0FORMAT without a formatcombo sampler, the other slots are handled below
     // particle shaders read TEX0FORMAT without a formatcombo sampler, the other slots are handled below
     if (texture0 != nullptr) {
     if (texture0 != nullptr) {
 	if (texture0->getFormat () == TextureFormat_RG88) {
 	if (texture0->getFormat () == TextureFormat_RG88) {
@@ -864,22 +905,50 @@ void CPass::setupShaders () {
 	}
 	}
     }
     }
 
 
-    this->m_shader = new Render::Shaders::Shader (
-	this->m_renderable.getAssetLocator (), shaderName, this->m_combos, this->m_override.combos, passTextures,
-	overrideTextures, this->m_override.constants
-    );
+    this->m_compiled = this->compileShaderSources (shaderName, passTextures, overrideTextures);
+    this->m_shader = this->m_compiled->shader.get ();
 
 
     // samplers marked "formatcombo" get TEX<slot>FORMAT set to their texture's format, like wallpaper64.exe
     // samplers marked "formatcombo" get TEX<slot>FORMAT set to their texture's format, like wallpaper64.exe
     // (sub_14015EC30). Which slots those are is only known once the shader is parsed, so rebuild it when one changed
     // (sub_14015EC30). Which slots those are is only known once the shader is parsed, so rebuild it when one changed
     if (this->applyFormatCombos (passTextures, overrideTextures)) {
     if (this->applyFormatCombos (passTextures, overrideTextures)) {
-	delete this->m_shader;
-	this->m_shader = new Render::Shaders::Shader (
-	    this->m_renderable.getAssetLocator (), shaderName, this->m_combos, this->m_override.combos, passTextures,
-	    overrideTextures, this->m_override.constants
-	);
+	this->m_compiled = this->compileShaderSources (shaderName, passTextures, overrideTextures);
+	this->m_shader = this->m_compiled->shader.get ();
     }
     }
 
 
-    auto [vertex, fragment] = Shaders::GLSLContext::get ().toGlsl (this->m_shader->vertex (), this->m_shader->fragment ());
+    // fog turns into FOG_DIST/FOG_HEIGHT for every pass whose FOG combo (the shader default included) is on
+    // (sub_1401A5C40); that default is only known once the shader is parsed
+    const auto& scene = this->m_renderable.getScene ();
+
+    if (scene.hasDistanceFog () || scene.hasHeightFog ()) {
+	const auto fogCombo = [this] () {
+	    for (const ComboMap* combos : std::initializer_list<const ComboMap*> { &this->m_override.combos, &this->m_combos }) {
+		if (const auto it = combos->find ("FOG"); it != combos->end ()) {
+		    return it->second;
+		}
+	    }
+	    for (const auto* unit : { &this->m_shader->getFragment (), &this->m_shader->getVertex () }) {
+		if (const auto it = unit->getDiscoveredCombos ().find ("FOG"); it != unit->getDiscoveredCombos ().end ()) {
+		    return it->second;
+		}
+	    }
+	    return 0;
+	};
+
+	if (fogCombo () != 0) {
+	    if (scene.hasDistanceFog ()) {
+		this->m_combos.insert_or_assign ("FOG_DIST", 1);
+	    }
+	    if (scene.hasHeightFog ()) {
+		this->m_combos.insert_or_assign ("FOG_HEIGHT", 1);
+	    }
+
+	    this->m_compiled = this->compileShaderSources (shaderName, passTextures, overrideTextures);
+	    this->m_shader = this->m_compiled->shader.get ();
+	}
+    }
+
+    std::string vertex = this->m_compiled->vertex;
+    std::string fragment = this->m_compiled->fragment;
 
 
     if (shaderName == XRAY_EFFECT_SHADER) {
     if (shaderName == XRAY_EFFECT_SHADER) {
 	this->m_xrayFullRevealPatched = patchXrayFullRevealBypass (fragment);
 	this->m_xrayFullRevealPatched = patchXrayFullRevealBypass (fragment);
@@ -890,22 +959,136 @@ void CPass::setupShaders () {
 	}
 	}
     }
     }
 
 
+    // passes with the same sources share one program (every particle system instance of a child would link its own
+    // otherwise, hundreds in the first seconds of a rain wallpaper). Uniforms are uploaded on every draw, the sampler
+    // units below are the same for all of them
+    this->m_programKey = vertex + '\0' + fragment;
+    if (const auto cached = sharedPrograms ().find (this->m_programKey); cached != sharedPrograms ().end ()) {
+	this->m_programID = cached->second.program;
+	cached->second.users++;
+    } else {
+	this->m_programID = this->linkProgram (vertex, fragment, shaderName);
+	sharedPrograms ().emplace (this->m_programKey, SharedProgram { this->m_programID, 1 });
+    }
+
+    // bind each g_TextureN sampler to unit N explicitly, the translated GLSL collapses every layout(binding) to 0
+    {
+	glUseProgram (this->m_programID);
+	for (int index = 0; index <= 9; index++) {
+	    const std::string name = "g_Texture" + std::to_string (index);
+	    const GLint loc = glGetUniformLocation (this->m_programID, name.c_str ());
+	    if (loc != -1) {
+		glUniform1i (loc, index);
+	    }
+	}
+    }
+
+    // first setup the default values, these will be overwritten by future values
+    this->setupShaderVariables ();
+    this->setupUniforms ();
+    this->setupAttributes ();
+    this->g_Texture0Rotation = glGetUniformLocation (this->m_programID, "g_Texture0Rotation");
+    this->g_Texture0Translation = glGetUniformLocation (this->m_programID, "g_Texture0Translation");
+}
+
+std::unordered_map<std::string, std::weak_ptr<CPass::CompiledShader>>& CPass::sharedShaders () {
+    static std::unordered_map<std::string, std::weak_ptr<CompiledShader>> shaders;
+    return shaders;
+}
+
+std::shared_ptr<CPass::CompiledShader> CPass::compileShaderSources (
+    const std::string& shaderName, const TextureMap& passTextures, const TextureMap& overrideTextures
+) {
+    // parsing and translating a shader takes a few ms, and every instance of a child particle system builds the same
+    // one. Override constants can change the parsed defaults, those passes keep a shader of their own
+    const bool shareable = this->m_override.constants.empty ();
+    std::string key;
+
+    if (shareable) {
+	std::ostringstream stream;
+	stream << shaderName << '\0' << &this->m_renderable.getAssetLocator ();
+	const ComboMap* comboMaps[] = { &this->m_combos, &this->m_override.combos };
+	for (const ComboMap* combos : comboMaps) {
+	    stream << '\1';
+	    for (const auto& [name, value] : *combos) {
+		stream << name << '=' << value << ';';
+	    }
+	}
+	for (const TextureMap* textures : { &passTextures, &overrideTextures }) {
+	    stream << '\1';
+	    for (const auto& [index, name] : *textures) {
+		stream << index << '=' << name << ';';
+	    }
+	}
+	key = stream.str ();
+
+	if (const auto it = sharedShaders ().find (key); it != sharedShaders ().end ()) {
+	    if (auto cached = it->second.lock ()) {
+		return cached;
+	    }
+	}
+    }
+
+    static const ShaderConstantMap noConstants;
+    auto compiled = std::make_shared<CompiledShader> ();
+    compiled->combos = this->m_combos;
+    compiled->overrideCombos = this->m_override.combos;
+    compiled->passTextures = passTextures;
+    compiled->overrideTextures = overrideTextures;
+    compiled->shader = std::make_unique<Render::Shaders::Shader> (
+	this->m_renderable.getAssetLocator (), shaderName, compiled->combos, compiled->overrideCombos,
+	compiled->passTextures, compiled->overrideTextures, shareable ? noConstants : this->m_override.constants
+    );
+
+    auto [vertex, fragment]
+	= Shaders::GLSLContext::get ().toGlsl (compiled->shader->vertex (), compiled->shader->fragment ());
+    compiled->vertex = std::move (vertex);
+    compiled->fragment = std::move (fragment);
+
+    if (shareable) {
+	std::erase_if (sharedShaders (), [] (const auto& entry) { return entry.second.expired (); });
+	sharedShaders ().insert_or_assign (key, compiled);
+    }
+
+    return compiled;
+}
+
+std::unordered_map<std::string, CPass::SharedProgram>& CPass::sharedPrograms () {
+    static std::unordered_map<std::string, SharedProgram> programs;
+    return programs;
+}
+
+bool CPass::releaseSharedProgram () {
+    const auto it = sharedPrograms ().find (this->m_programKey);
+    if (this->m_programKey.empty () || it == sharedPrograms ().end ()) {
+	return false;
+    }
+
+    if (--it->second.users == 0) {
+	glDeleteProgram (it->second.program);
+	sharedPrograms ().erase (it);
+    }
+    this->m_programID = 0;
+    return true;
+}
+
+GLuint CPass::linkProgram (const std::string& vertex, const std::string& fragment, const std::string& shaderName) {
     const GLuint vertexShaderID = compileShader (vertex.c_str (), GL_VERTEX_SHADER);
     const GLuint vertexShaderID = compileShader (vertex.c_str (), GL_VERTEX_SHADER);
     const GLuint fragmentShaderID = compileShader (fragment.c_str (), GL_FRAGMENT_SHADER);
     const GLuint fragmentShaderID = compileShader (fragment.c_str (), GL_FRAGMENT_SHADER);
-    this->m_programID = glCreateProgram ();
-    glAttachShader (this->m_programID, vertexShaderID);
-    glAttachShader (this->m_programID, fragmentShaderID);
-    glLinkProgram (this->m_programID);
+    const GLuint program = glCreateProgram ();
+    glAttachShader (program, vertexShaderID);
+    glAttachShader (program, fragmentShaderID);
+    glLinkProgram (program);
     GLint result = GL_FALSE;
     GLint result = GL_FALSE;
     int infoLogLength = 0;
     int infoLogLength = 0;
 
 
-    glGetProgramiv (this->m_programID, GL_LINK_STATUS, &result);
-    glGetProgramiv (this->m_programID, GL_INFO_LOG_LENGTH, &infoLogLength);
+    glGetProgramiv (program, GL_LINK_STATUS, &result);
+    glGetProgramiv (program, GL_INFO_LOG_LENGTH, &infoLogLength);
 
 
     if (infoLogLength > 0) {
     if (infoLogLength > 0) {
 	const auto logBuffer = new char[infoLogLength + 1];
 	const auto logBuffer = new char[infoLogLength + 1];
 	memset (logBuffer, 0, infoLogLength + 1);
 	memset (logBuffer, 0, infoLogLength + 1);
-	glGetProgramInfoLog (this->m_programID, infoLogLength, nullptr, logBuffer);
+	glGetProgramInfoLog (program, infoLogLength, nullptr, logBuffer);
 	const std::string message = logBuffer;
 	const std::string message = logBuffer;
 	delete[] logBuffer;
 	delete[] logBuffer;
 	if (result == GL_FALSE) {
 	if (result == GL_FALSE) {
@@ -916,36 +1099,19 @@ void CPass::setupShaders () {
     }
     }
 
 
 #if !NDEBUG
 #if !NDEBUG
-    glObjectLabel (GL_PROGRAM, this->m_programID, -1, shaderName.c_str ());
+    glObjectLabel (GL_PROGRAM, program, -1, shaderName.c_str ());
     glObjectLabel (GL_SHADER, vertexShaderID, -1, (shaderName + ".vert").c_str ());
     glObjectLabel (GL_SHADER, vertexShaderID, -1, (shaderName + ".vert").c_str ());
     glObjectLabel (GL_SHADER, fragmentShaderID, -1, (shaderName + ".frag").c_str ());
     glObjectLabel (GL_SHADER, fragmentShaderID, -1, (shaderName + ".frag").c_str ());
 #endif /* DEBUG */
 #endif /* DEBUG */
 
 
     // once linked, the shaders themselves are no longer needed and can be detached/deleted
     // once linked, the shaders themselves are no longer needed and can be detached/deleted
-    glDetachShader (this->m_programID, vertexShaderID);
-    glDetachShader (this->m_programID, fragmentShaderID);
+    glDetachShader (program, vertexShaderID);
+    glDetachShader (program, fragmentShaderID);
 
 
     glDeleteShader (vertexShaderID);
     glDeleteShader (vertexShaderID);
     glDeleteShader (fragmentShaderID);
     glDeleteShader (fragmentShaderID);
 
 
-    // bind each g_TextureN sampler to unit N explicitly, the translated GLSL collapses every layout(binding) to 0
-    {
-	glUseProgram (this->m_programID);
-	for (int index = 0; index <= 9; index++) {
-	    const std::string name = "g_Texture" + std::to_string (index);
-	    const GLint loc = glGetUniformLocation (this->m_programID, name.c_str ());
-	    if (loc != -1) {
-		glUniform1i (loc, index);
-	    }
-	}
-    }
-
-    // first setup the default values, these will be overwritten by future values
-    this->setupShaderVariables ();
-    this->setupUniforms ();
-    this->setupAttributes ();
-    this->g_Texture0Rotation = glGetUniformLocation (this->m_programID, "g_Texture0Rotation");
-    this->g_Texture0Translation = glGetUniformLocation (this->m_programID, "g_Texture0Translation");
+    return program;
 }
 }
 
 
 bool CPass::applyFormatCombos (const TextureMap& passTextures, const TextureMap& overrideTextures) {
 bool CPass::applyFormatCombos (const TextureMap& passTextures, const TextureMap& overrideTextures) {
@@ -1223,6 +1389,11 @@ void CPass::setupUniforms () {
     this->addUniform ("g_LightSkylightColor", sceneData.colors.skylight->value->getVec3 ());
     this->addUniform ("g_LightSkylightColor", sceneData.colors.skylight->value->getVec3 ());
     this->addUniform ("g_LightsPosition", UniformType::Vector3, scene.getLightsPosition (), 4);
     this->addUniform ("g_LightsPosition", UniformType::Vector3, scene.getLightsPosition (), 4);
     this->addUniform ("g_LightsColorPremultiplied", UniformType::Vector4, scene.getLightsColorPremultiplied (), 3);
     this->addUniform ("g_LightsColorPremultiplied", UniformType::Vector4, scene.getLightsColorPremultiplied (), 3);
+    this->addUniform ("g_LightsColorRadius", UniformType::Vector4, scene.getLightsColorRadius (), 4);
+    this->addUniform ("g_FogDistanceColor", &scene.getFog ().distanceColor);
+    this->addUniform ("g_FogDistanceParams", &scene.getFog ().distanceParams);
+    this->addUniform ("g_FogHeightColor", &scene.getFog ().heightColor);
+    this->addUniform ("g_FogHeightParams", this->m_fogWorld ? &scene.getFog ().heightParamsWorld : &scene.getFog ().heightParamsLocal);
     this->addUniform ("g_AltModelMatrix", &this->m_lightingModelMatrix);
     this->addUniform ("g_AltModelMatrix", &this->m_lightingModelMatrix);
     this->addUniform ("g_AltNormalModelMatrix", &this->m_lightingNormalMatrix);
     this->addUniform ("g_AltNormalModelMatrix", &this->m_lightingNormalMatrix);
     this->addUniform ("g_AltViewProjectionMatrix", &this->m_lightingViewProjectionMatrix);
     this->addUniform ("g_AltViewProjectionMatrix", &this->m_lightingViewProjectionMatrix);

+ 43 - 1
src/WallpaperEngine/Render/Objects/Effects/CPass.h

@@ -1,5 +1,7 @@
 #pragma once
 #pragma once
 
 
+#include <unordered_map>
+
 #include <functional>
 #include <functional>
 #include <glm/gtc/type_ptr.hpp>
 #include <glm/gtc/type_ptr.hpp>
 #include <utility>
 #include <utility>
@@ -32,6 +34,8 @@ public:
     ~CPass ();
     ~CPass ();
 
 
     void render ();
     void render ();
+    /** Binds the destination and clears it to transparent black without drawing anything */
+    void clearDestination () const;
     /** Advances video textures this pass pulls in from user/material slots, the image's own texture is updated by the scene */
     /** Advances video textures this pass pulls in from user/material slots, the image's own texture is updated by the scene */
     void updatePlaybackTextures () const;
     void updatePlaybackTextures () const;
 
 
@@ -43,6 +47,8 @@ public:
     void setModelViewProjectionMatrix (const glm::mat4* projection);
     void setModelViewProjectionMatrix (const glm::mat4* projection);
     void setModelViewProjectionMatrixInverse (const glm::mat4* projection);
     void setModelViewProjectionMatrixInverse (const glm::mat4* projection);
     void setModelMatrix (const glm::mat4* model);
     void setModelMatrix (const glm::mat4* model);
+    /** The pass draws in WE's world space (the fog composite of image layers), fog measures from WE's own eye then */
+    void setFogWorld (bool world);
     void setViewProjectionMatrix (const glm::mat4* viewProjection);
     void setViewProjectionMatrix (const glm::mat4* viewProjection);
     /**
     /**
      * Where the vertices of a lit pass really are in the scene (y up, WE's coordinates). Lit passes are built with
      * Where the vertices of a lit pass really are in the scene (y up, WE's coordinates). Lit passes are built with
@@ -50,6 +56,10 @@ public:
      */
      */
     void setLightingTransform (const glm::mat4* model, const glm::mat3* normal, const glm::mat4* viewProjection);
     void setLightingTransform (const glm::mat4* model, const glm::mat3* normal, const glm::mat4* viewProjection);
     void setEffectTextureProjectionMatrix (const glm::mat4* projection, const glm::mat4* inverse);
     void setEffectTextureProjectionMatrix (const glm::mat4* projection, const glm::mat4* inverse);
+    /** What a private destination is cleared to before drawing (transparent black when unset) */
+    void setClearColor (const glm::vec4* color);
+    /** Draw over what a private destination already holds instead of clearing it first */
+    void setKeepDestination (bool keep);
     void setBlendingMode (BlendingMode blendingmode);
     void setBlendingMode (BlendingMode blendingmode);
     [[nodiscard]] BlendingMode getBlendingMode () const;
     [[nodiscard]] BlendingMode getBlendingMode () const;
     [[nodiscard]] std::shared_ptr<const CFBO> resolveFBO (const std::string& name) const;
     [[nodiscard]] std::shared_ptr<const CFBO> resolveFBO (const std::string& name) const;
@@ -70,6 +80,7 @@ public:
     void addUniform (const std::string& name, const float* value, int count = 1);
     void addUniform (const std::string& name, const float* value, int count = 1);
     void addUniform (const std::string& name, const glm::vec3* value);
     void addUniform (const std::string& name, const glm::vec3* value);
     void addUniform (const std::string& name, const glm::vec4* value);
     void addUniform (const std::string& name, const glm::vec4* value);
+    void addUniform (const std::string& name, const glm::mat3* value);
     void addUniform (const std::string& name, const glm::mat4* value);
     void addUniform (const std::string& name, const glm::mat4* value);
 
 
 private:
 private:
@@ -137,6 +148,32 @@ private:
     };
     };
 
 
     static GLuint compileShader (const char* shader, GLuint type);
     static GLuint compileShader (const char* shader, GLuint type);
+    static GLuint linkProgram (const std::string& vertex, const std::string& fragment, const std::string& shaderName);
+
+    struct SharedProgram {
+	GLuint program;
+	int users;
+    };
+    /** Linked programs by vertex + fragment source, shared by every pass built from the same sources */
+    static std::unordered_map<std::string, SharedProgram>& sharedPrograms ();
+    /** Drops this pass's use of a shared program, false if its program isn't one */
+    bool releaseSharedProgram ();
+
+    /** A parsed shader plus the GLSL it translates to. Owns copies of what the shader units reference */
+    struct CompiledShader {
+	ComboMap combos;
+	ComboMap overrideCombos;
+	TextureMap passTextures;
+	TextureMap overrideTextures;
+	std::unique_ptr<Render::Shaders::Shader> shader;
+	std::string vertex;
+	std::string fragment;
+    };
+    /** Compiled shaders by their inputs, alive while a pass uses them */
+    static std::unordered_map<std::string, std::weak_ptr<CompiledShader>>& sharedShaders ();
+    std::shared_ptr<CompiledShader> compileShaderSources (
+	const std::string& shaderName, const TextureMap& passTextures, const TextureMap& overrideTextures
+    );
     void setupShaders ();
     void setupShaders ();
     /** Sets TEX<slot>FORMAT for the shader's formatcombo samplers, true if a combo changed */
     /** Sets TEX<slot>FORMAT for the shader's formatcombo samplers, true if a combo changed */
     bool applyFormatCombos (const TextureMap& passTextures, const TextureMap& overrideTextures);
     bool applyFormatCombos (const TextureMap& passTextures, const TextureMap& overrideTextures);
@@ -161,7 +198,6 @@ private:
     void addUniform (const std::string& name, const int* value, int count = 1);
     void addUniform (const std::string& name, const int* value, int count = 1);
     void addUniform (const std::string& name, const double* value, int count = 1);
     void addUniform (const std::string& name, const double* value, int count = 1);
     void addUniform (const std::string& name, const glm::vec2* value);
     void addUniform (const std::string& name, const glm::vec2* value);
-    void addUniform (const std::string& name, const glm::mat3* value);
     void addUniform (const std::string& name, const int** value);
     void addUniform (const std::string& name, const int** value);
     void addUniform (const std::string& name, const double** value);
     void addUniform (const std::string& name, const double** value);
     void addUniform (const std::string& name, const float** value);
     void addUniform (const std::string& name, const float** value);
@@ -213,12 +249,15 @@ private:
     std::map<std::string, int> m_combos = {};
     std::map<std::string, int> m_combos = {};
     std::vector<AttribEntry*> m_attribs = {};
     std::vector<AttribEntry*> m_attribs = {};
     std::map<std::string, UniformEntry*> m_uniforms = {};
     std::map<std::string, UniformEntry*> m_uniforms = {};
+    const glm::vec4* m_clearColor = nullptr;
+    bool m_keepDestination = false;
     std::map<std::string, ReferenceUniformEntry*> m_referenceUniforms = {};
     std::map<std::string, ReferenceUniformEntry*> m_referenceUniforms = {};
     BlendingMode m_blendingmode = BlendingMode_Normal;
     BlendingMode m_blendingmode = BlendingMode_Normal;
     const glm::mat4* m_modelViewProjectionMatrix;
     const glm::mat4* m_modelViewProjectionMatrix;
     const glm::mat4* m_modelViewProjectionMatrixInverse;
     const glm::mat4* m_modelViewProjectionMatrixInverse;
     const glm::mat4* m_modelMatrix;
     const glm::mat4* m_modelMatrix;
     const glm::mat4* m_lightingModelMatrix;
     const glm::mat4* m_lightingModelMatrix;
+    bool m_fogWorld = false;
     const glm::mat3* m_lightingNormalMatrix;
     const glm::mat3* m_lightingNormalMatrix;
     const glm::mat4* m_lightingViewProjectionMatrix;
     const glm::mat4* m_lightingViewProjectionMatrix;
     const glm::mat4* m_viewProjectionMatrix;
     const glm::mat4* m_viewProjectionMatrix;
@@ -237,6 +276,7 @@ private:
 
 
     void trackPlayback (const std::shared_ptr<const TextureProvider>& texture);
     void trackPlayback (const std::shared_ptr<const TextureProvider>& texture);
 
 
+    std::shared_ptr<CompiledShader> m_compiled = nullptr;
     Render::Shaders::Shader* m_shader = nullptr;
     Render::Shaders::Shader* m_shader = nullptr;
 
 
     std::shared_ptr<const CFBO> m_drawTo = nullptr;
     std::shared_ptr<const CFBO> m_drawTo = nullptr;
@@ -245,6 +285,8 @@ private:
     glm::vec4 m_texture0Resolution = {};
     glm::vec4 m_texture0Resolution = {};
 
 
     GLuint m_programID;
     GLuint m_programID;
+    /** Key of m_programID in the shared program cache (identical sources link once, see setupShaders) */
+    std::string m_programKey;
 
 
     GLint g_Texture0Rotation;
     GLint g_Texture0Rotation;
     GLint g_Texture0Translation;
     GLint g_Texture0Translation;

+ 930 - 0
src/WallpaperEngine/Render/Objects/TextLayout.cpp

@@ -0,0 +1,930 @@
+#include "TextLayout.h"
+
+#include <algorithm>
+#include <cmath>
+#include <cstdio>
+#include <filesystem>
+
+#include <ft2build.h>
+#include FT_FREETYPE_H
+#include FT_GLYPH_H
+#include FT_OUTLINE_H
+
+#include <hb-ft.h>
+#include <hb.h>
+
+#include <msdfgen.h>
+
+#include "WallpaperEngine/Logging/Log.h"
+
+using namespace WallpaperEngine::Render::Objects;
+
+namespace {
+constexpr char32_t kEllipsis = U'…';
+constexpr int kMaxAtlasSize = 4096;
+
+// the whitespace set wallpaper64.exe breaks lines at and trims (bit mask 0x100002200: tab, CR, space)
+bool isBreakSpace (char32_t c) { return c == U'\t' || c == U'\r' || c == U' '; }
+
+std::u32string decodeUtf8 (const std::string& text) {
+    std::u32string codepoints;
+    size_t i = 0;
+
+    while (i < text.size ()) {
+	const auto lead = static_cast<unsigned char> (text[i]);
+	size_t extraBytes;
+	char32_t codepoint;
+
+	if ((lead & 0x80) == 0x00) {
+	    codepoint = lead;
+	    extraBytes = 0;
+	} else if ((lead & 0xE0) == 0xC0) {
+	    codepoint = lead & 0x1F;
+	    extraBytes = 1;
+	} else if ((lead & 0xF0) == 0xE0) {
+	    codepoint = lead & 0x0F;
+	    extraBytes = 2;
+	} else if ((lead & 0xF8) == 0xF0) {
+	    codepoint = lead & 0x07;
+	    extraBytes = 3;
+	} else {
+	    i++;
+	    continue;
+	}
+
+	if (i + extraBytes >= text.size ()) {
+	    break;
+	}
+
+	bool valid = true;
+	for (size_t k = 1; k <= extraBytes; k++) {
+	    const auto cont = static_cast<unsigned char> (text[i + k]);
+	    if ((cont & 0xC0) != 0x80) {
+		valid = false;
+		break;
+	    }
+	    codepoint = (codepoint << 6) | (cont & 0x3F);
+	}
+
+	if (!valid) {
+	    i++;
+	    continue;
+	}
+
+	codepoints.push_back (codepoint);
+	i += extraBytes + 1;
+    }
+
+    return codepoints;
+}
+
+void trimTrailingSpaces (std::u32string& line) {
+    while (!line.empty () && isBreakSpace (line.back ())) {
+	line.pop_back ();
+    }
+}
+
+// sub_1401B0360: what the last line kept by the row limit gets
+void finishLastRow (std::u32string& line, bool ellipsis) {
+    trimTrailingSpaces (line);
+
+    if (ellipsis && (line.empty () || line.back () != kEllipsis)) {
+	line.push_back (kEllipsis);
+    }
+}
+
+uint64_t glyphKey (size_t face, uint32_t glyph) { return (static_cast<uint64_t> (face) << 32) | glyph; }
+
+// WE falls back to a fixed list of Windows fonts for characters the wallpaper's font lacks, ask fontconfig for any
+// installed outline font that has the character instead
+std::string fontForCodepoint (char32_t codepoint) {
+    // every text object asks for the same characters, fc-match is only run once per character
+    static std::unordered_map<char32_t, std::string> cache;
+
+    if (const auto it = cache.find (codepoint); it != cache.end ()) {
+	return it->second;
+    }
+
+    char pattern[64];
+    std::snprintf (pattern, sizeof (pattern), "sans-serif:scalable=true:charset=%x", static_cast<unsigned> (codepoint));
+
+    const std::string command = std::string ("fc-match -f '%{file}' '") + pattern + "' 2>/dev/null";
+    FILE* pipe = popen (command.c_str (), "r");
+
+    if (pipe == nullptr) {
+	return {};
+    }
+
+    std::string path;
+    char buffer[512];
+
+    while (fgets (buffer, sizeof (buffer), pipe) != nullptr) {
+	path += buffer;
+    }
+
+    pclose (pipe);
+
+    if (path.empty () || !std::filesystem::exists (path)) {
+	path.clear ();
+    }
+
+    return cache.emplace (codepoint, path).first->second;
+}
+
+struct OutlineContext {
+    msdfgen::Shape* shape;
+    msdfgen::Contour* contour = nullptr;
+    msdfgen::Point2 position;
+};
+
+// FT_Outline_Decompose into a msdfgen shape at 1/64 scale (26.6 -> pixels), like wallpaper64.exe's callbacks
+msdfgen::Point2 outlinePoint (const FT_Vector* vector) { return { vector->x / 64.0, vector->y / 64.0 }; }
+
+int outlineMoveTo (const FT_Vector* to, void* user) {
+    auto* context = static_cast<OutlineContext*> (user);
+
+    if (!(context->contour != nullptr && context->contour->edges.empty ())) {
+	context->contour = &context->shape->addContour ();
+    }
+
+    context->position = outlinePoint (to);
+    return 0;
+}
+
+int outlineLineTo (const FT_Vector* to, void* user) {
+    auto* context = static_cast<OutlineContext*> (user);
+    const msdfgen::Point2 endpoint = outlinePoint (to);
+
+    if (context->contour != nullptr && endpoint != context->position) {
+	context->contour->addEdge (msdfgen::EdgeHolder (context->position, endpoint));
+	context->position = endpoint;
+    }
+
+    return 0;
+}
+
+int outlineConicTo (const FT_Vector* control, const FT_Vector* to, void* user) {
+    auto* context = static_cast<OutlineContext*> (user);
+
+    if (context->contour != nullptr) {
+	const msdfgen::Point2 endpoint = outlinePoint (to);
+	context->contour->addEdge (msdfgen::EdgeHolder (context->position, outlinePoint (control), endpoint));
+	context->position = endpoint;
+    }
+
+    return 0;
+}
+
+int outlineCubicTo (const FT_Vector* control1, const FT_Vector* control2, const FT_Vector* to, void* user) {
+    auto* context = static_cast<OutlineContext*> (user);
+
+    if (context->contour != nullptr) {
+	const msdfgen::Point2 endpoint = outlinePoint (to);
+	context->contour->addEdge (
+	    msdfgen::EdgeHolder (context->position, outlinePoint (control1), outlinePoint (control2), endpoint)
+	);
+	context->position = endpoint;
+    }
+
+    return 0;
+}
+} // namespace
+
+TextLayout::TextLayout () {
+    if (FT_Init_FreeType (&m_library) != 0) {
+	m_library = nullptr;
+	sLog.error ("TextLayout: FT_Init_FreeType failed");
+    }
+
+    m_atlasPixels.assign (static_cast<size_t> (m_atlasSize) * m_atlasSize, 0);
+}
+
+TextLayout::~TextLayout () {
+    this->clearFaces ();
+
+    if (m_library != nullptr) {
+	FT_Done_FreeType (m_library);
+    }
+}
+
+void TextLayout::clearFaces () {
+    for (const auto& face : m_faces) {
+	if (face->font != nullptr) {
+	    hb_font_destroy (face->font);
+	}
+	if (face->face != nullptr) {
+	    FT_Done_Face (face->face);
+	}
+    }
+
+    m_faces.clear ();
+    m_fallbackTried.clear ();
+    m_boxes.clear ();
+    m_glyphs.clear ();
+    this->resetAtlas ();
+}
+
+bool TextLayout::setPrimaryFont (std::vector<uint8_t> data, const std::string& path) {
+    this->clearFaces ();
+    return this->addFace (std::move (data), path);
+}
+
+bool TextLayout::addFace (std::vector<uint8_t> data, const std::string& path) {
+    if (m_library == nullptr) {
+	return false;
+    }
+
+    auto face = std::make_unique<Face> ();
+    face->data = std::move (data);
+    face->path = path;
+
+    const FT_Error error = face->data.empty ()
+	? FT_New_Face (m_library, path.c_str (), 0, &face->face)
+	: FT_New_Memory_Face (
+	      m_library, face->data.data (), static_cast<FT_Long> (face->data.size ()), 0, &face->face
+	  );
+
+    if (error != 0) {
+	sLog.error ("TextLayout: cannot load font '", path, "' (FreeType error ", error, ")");
+	return false;
+    }
+
+    // bitmap-only fonts (colour emoji strikes) can't be sized to an arbitrary raster size
+    if (!FT_IS_SCALABLE (face->face)) {
+	FT_Done_Face (face->face);
+	return false;
+    }
+
+    FT_Select_Charmap (face->face, FT_ENCODING_UNICODE);
+
+    if (m_size > 0.0f) {
+	FT_Set_Char_Size (face->face, 0, static_cast<FT_F26Dot6> (m_size * 64.0f), 300, 300);
+    }
+
+    face->font = hb_ft_font_create (face->face, nullptr);
+    m_faces.push_back (std::move (face));
+    return true;
+}
+
+void TextLayout::applySize (float size) {
+    if (size == m_size) {
+	return;
+    }
+
+    m_size = size;
+
+    for (const auto& face : m_faces) {
+	FT_Set_Char_Size (face->face, 0, static_cast<FT_F26Dot6> (size * 64.0f), 300, 300);
+	hb_ft_font_changed (face->font);
+    }
+
+    m_boxes.clear ();
+    m_glyphs.clear ();
+    this->resetAtlas ();
+}
+
+int TextLayout::faceFor (char32_t codepoint) {
+    for (size_t i = 0; i < m_faces.size (); i++) {
+	if (FT_Get_Char_Index (m_faces[i]->face, codepoint) != 0) {
+	    return static_cast<int> (i);
+	}
+    }
+
+    if (codepoint < 0x20 || !m_fallbackTried.insert (codepoint).second) {
+	return -1;
+    }
+
+    const std::string path = fontForCodepoint (codepoint);
+
+    if (path.empty ()) {
+	return -1;
+    }
+
+    for (const auto& face : m_faces) {
+	if (face->path == path) {
+	    return -1;
+	}
+    }
+
+    if (!this->addFace ({}, path) || FT_Get_Char_Index (m_faces.back ()->face, codepoint) == 0) {
+	return -1;
+    }
+
+    return static_cast<int> (m_faces.size () - 1);
+}
+
+// sub_1401AD670: one run per stretch of text a single face covers, zero width joiners and whitespace stay in the
+// current run
+std::vector<TextLayout::Run> TextLayout::itemize (const std::u32string& line) {
+    std::vector<Run> runs;
+
+    for (size_t i = 0; i < line.size (); i++) {
+	const char32_t c = line[i];
+	const bool keepsRun = c == 0x200D || c == U'\t' || c == U'\n' || c == U'\r' || c == U' ';
+
+	if (keepsRun && !runs.empty ()) {
+	    runs.back ().text.push_back (c);
+	    continue;
+	}
+
+	int face = this->faceFor (c);
+
+	if (face < 0) {
+	    face = runs.empty () ? 0 : static_cast<int> (runs.back ().face);
+	}
+
+	if (runs.empty () || runs.back ().face != static_cast<size_t> (face)) {
+	    runs.push_back ({ static_cast<size_t> (face), i, {} });
+	}
+
+	runs.back ().text.push_back (c);
+    }
+
+    return runs;
+}
+
+std::vector<TextLayout::ShapedGlyph> TextLayout::shape (const std::u32string& line, const TextLayoutParams& params) {
+    std::vector<ShapedGlyph> glyphs;
+
+    for (const auto& run : this->itemize (line)) {
+	const Face& face = *m_faces[run.face];
+	hb_buffer_t* buffer = hb_buffer_create ();
+	hb_buffer_add_utf32 (
+	    buffer, reinterpret_cast<const uint32_t*> (run.text.data ()), static_cast<int> (run.text.size ()), 0, -1
+	);
+	hb_buffer_guess_segment_properties (buffer);
+	hb_shape (face.font, buffer, nullptr, 0);
+
+	unsigned int count = 0;
+	const hb_glyph_info_t* infos = hb_buffer_get_glyph_infos (buffer, &count);
+	const hb_glyph_position_t* positions = hb_buffer_get_glyph_positions (buffer, nullptr);
+	const float pad = params.msdf ? static_cast<float> (std::max<int> (face.face->size->metrics.y_ppem, 1)) * 12.0f * 0.03125f : 0.0f;
+
+	for (unsigned int i = 0; i < count; i++) {
+	    const uint32_t glyph = infos[i].codepoint;
+
+	    // WE's glyph fetch fails on .notdef, which leaves the character out without advancing the pen
+	    if (glyph == 0 || this->atlasGlyph (run.face, glyph) == nullptr) {
+		continue;
+	    }
+
+	    const GlyphBox* box = this->glyphBox (run.face, glyph);
+	    const GlyphBox bounds = box != nullptr ? *box : GlyphBox {};
+	    const int xOffset = positions[i].x_offset >> 6;
+	    const int yOffset = positions[i].y_offset >> 6;
+	    const uint32_t cluster = infos[i].cluster + static_cast<uint32_t> (run.start);
+
+	    glyphs.push_back ({
+		.key = glyphKey (run.face, glyph),
+		.cluster = cluster,
+		.codepoint = cluster < line.size () ? line[cluster] : 0,
+		.advance = static_cast<float> (positions[i].x_advance >> 6) + params.spacing.x,
+		.yAdvance = static_cast<float> (positions[i].y_advance >> 6),
+		.x0 = static_cast<float> (xOffset + bounds.x0),
+		.y0 = static_cast<float> (yOffset + bounds.y0),
+		.x1 = static_cast<float> (xOffset + bounds.x1),
+		.y1 = static_cast<float> (yOffset + bounds.y1),
+		.pad = pad,
+	    });
+	}
+
+	hb_buffer_destroy (buffer);
+    }
+
+    return glyphs;
+}
+
+float TextLayout::measureRight (const std::u32string& line) {
+    float pen = 0.0f;
+    float right = 0.0f;
+
+    for (const auto& run : this->itemize (line)) {
+	hb_buffer_t* buffer = hb_buffer_create ();
+	hb_buffer_add_utf32 (
+	    buffer, reinterpret_cast<const uint32_t*> (run.text.data ()), static_cast<int> (run.text.size ()), 0, -1
+	);
+	hb_buffer_guess_segment_properties (buffer);
+	hb_shape (m_faces[run.face]->font, buffer, nullptr, 0);
+
+	unsigned int count = 0;
+	const hb_glyph_info_t* infos = hb_buffer_get_glyph_infos (buffer, &count);
+	const hb_glyph_position_t* positions = hb_buffer_get_glyph_positions (buffer, nullptr);
+
+	for (unsigned int i = 0; i < count; i++) {
+	    if (const GlyphBox* box = this->glyphBox (run.face, infos[i].codepoint); box != nullptr) {
+		right = static_cast<float> (positions[i].x_offset >> 6) + pen + static_cast<float> (box->x1);
+		pen += static_cast<float> (positions[i].x_advance >> 6);
+	    }
+	}
+
+	hb_buffer_destroy (buffer);
+    }
+
+    return right;
+}
+
+// sub_1401ADDB0: the outline's pixel grid fitted control box, loaded without bitmaps
+const TextLayout::GlyphBox* TextLayout::glyphBox (size_t face, uint32_t glyph) {
+    const uint64_t key = glyphKey (face, glyph);
+
+    if (const auto it = m_boxes.find (key); it != m_boxes.end ()) {
+	return &it->second;
+    }
+
+    FT_Face ftFace = m_faces[face]->face;
+    FT_Glyph ftGlyph = nullptr;
+
+    if (FT_Load_Glyph (ftFace, glyph, FT_LOAD_NO_BITMAP) != 0 || FT_Get_Glyph (ftFace->glyph, &ftGlyph) != 0) {
+	return nullptr;
+    }
+
+    FT_BBox bbox;
+    FT_Glyph_Get_CBox (ftGlyph, FT_GLYPH_BBOX_PIXELS, &bbox);
+    FT_Done_Glyph (ftGlyph);
+
+    return &m_boxes
+		.emplace (
+		    key,
+		    GlyphBox {
+			static_cast<int> (bbox.xMin), static_cast<int> (bbox.yMin), static_cast<int> (bbox.xMax),
+			static_cast<int> (bbox.yMax)
+		    }
+		)
+		.first->second;
+}
+
+TextLayout::AtlasGlyph* TextLayout::atlasGlyph (size_t face, uint32_t glyph) {
+    const uint64_t key = glyphKey (face, glyph);
+
+    if (const auto it = m_glyphs.find (key); it != m_glyphs.end ()) {
+	return &it->second;
+    }
+
+    AtlasGlyph entry;
+    FT_Face ftFace = m_faces[face]->face;
+
+    if (!(m_atlasMsdf ? this->renderMsdfGlyph (ftFace, glyph, entry) : this->renderPlainGlyph (ftFace, glyph, entry))) {
+	return nullptr;
+    }
+
+    auto& stored = m_glyphs.emplace (key, std::move (entry)).first->second;
+
+    if (!this->pack (key, stored)) {
+	m_atlasFull = true;
+    }
+
+    return &stored;
+}
+
+bool TextLayout::renderPlainGlyph (FT_Face face, uint32_t glyph, AtlasGlyph& out) {
+    if (FT_Load_Glyph (face, glyph, FT_LOAD_RENDER) != 0) {
+	return false;
+    }
+
+    const FT_Bitmap& bitmap = face->glyph->bitmap;
+    out.width = static_cast<int> (bitmap.width);
+    out.height = static_cast<int> (bitmap.rows);
+    out.pixels.assign (static_cast<size_t> (out.width) * out.height, 0);
+
+    if (bitmap.pixel_mode == FT_PIXEL_MODE_GRAY) {
+	for (int row = 0; row < out.height; row++) {
+	    std::copy_n (bitmap.buffer + row * bitmap.pitch, out.width, out.pixels.begin () + row * out.width);
+	}
+    }
+
+    return true;
+}
+
+// sub_1401AE080: an MSDF of the glyph at 32 pixels per em with 12 pixels of distance range around it
+bool TextLayout::renderMsdfGlyph (FT_Face face, uint32_t glyph, AtlasGlyph& out) {
+    FT_Glyph ftGlyph = nullptr;
+
+    if (FT_Load_Glyph (face, glyph, FT_LOAD_NO_BITMAP) != 0 || face->glyph->format != FT_GLYPH_FORMAT_OUTLINE
+	|| FT_Get_Glyph (face->glyph, &ftGlyph) != 0) {
+	return false;
+    }
+
+    FT_BBox bbox;
+    FT_Glyph_Get_CBox (ftGlyph, FT_GLYPH_BBOX_PIXELS, &bbox);
+    FT_Done_Glyph (ftGlyph);
+
+    const int boxWidth = static_cast<int> (bbox.xMax - bbox.xMin);
+    const int boxHeight = static_cast<int> (bbox.yMax - bbox.yMin);
+    int width = 0;
+    int height = 0;
+
+    if (boxWidth > 0 && boxHeight > 0) {
+	const float toAtlas = 32.0f / static_cast<float> (std::max<int> (face->size->metrics.y_ppem, 1));
+	width = std::max (1, static_cast<int> (static_cast<float> (boxWidth) * toAtlas));
+	height = std::max (1, static_cast<int> (static_cast<float> (boxHeight) * toAtlas));
+    }
+
+    out.width = width + 24;
+    out.height = height + 24;
+    out.pixels.assign (static_cast<size_t> (out.width) * out.height * 4, 0);
+
+    if (width <= 0 || height <= 0) {
+	return true;
+    }
+
+    msdfgen::Shape shape;
+    OutlineContext context { &shape };
+    FT_Outline_Funcs funcs {
+	.move_to = outlineMoveTo,
+	.line_to = outlineLineTo,
+	.conic_to = outlineConicTo,
+	.cubic_to = outlineCubicTo,
+	.shift = 0,
+	.delta = 0,
+    };
+
+    if (FT_Outline_Decompose (&face->glyph->outline, &funcs, &context) != 0) {
+	return true;
+    }
+
+    if (!shape.contours.empty () && shape.contours.back ().edges.empty ()) {
+	shape.contours.pop_back ();
+    }
+
+    if (shape.contours.empty ()) {
+	return true;
+    }
+
+    shape.normalize ();
+    shape.orientContours ();
+    // WE's seed argument is whatever the previous call left in the register, msdfgen's default is used here
+    msdfgen::edgeColoringSimple (shape, 3.0);
+
+    const float scale = static_cast<float> (std::max (width, height))
+	/ std::max (static_cast<float> (boxHeight), static_cast<float> (boxWidth));
+    const float range = 24.0f / scale;
+    const msdfgen::Projection projection (
+	msdfgen::Vector2 (scale),
+	msdfgen::Vector2 (12.0f / scale - static_cast<float> (bbox.xMin), 12.0f / scale - static_cast<float> (bbox.yMin))
+    );
+    const msdfgen::SDFTransformation transformation (projection, msdfgen::DistanceMapping (msdfgen::Range (range)));
+
+    msdfgen::Bitmap<float, 3> msdf (out.width, out.height);
+    msdfgen::MSDFGeneratorConfig generatorConfig (
+	true, msdfgen::ErrorCorrectionConfig (msdfgen::ErrorCorrectionConfig::DISABLED)
+    );
+    msdfgen::generateMSDF (msdf, shape, transformation, generatorConfig);
+
+    const msdfgen::MSDFGeneratorConfig correctionConfig (
+	true,
+	msdfgen::ErrorCorrectionConfig (
+	    msdfgen::ErrorCorrectionConfig::EDGE_PRIORITY, msdfgen::ErrorCorrectionConfig::CHECK_DISTANCE_AT_EDGE,
+	    1.1111111111111112, 1.1111111111111112
+	)
+    );
+    msdfgen::msdfErrorCorrection (msdf, shape, transformation, correctionConfig);
+
+    const auto toByte = [] (float value) {
+	const int scaled = static_cast<int> (value * 256.0f);
+	return static_cast<uint8_t> (std::clamp (scaled, 0, 255));
+    };
+
+    // msdfgen's rows go bottom up, the atlas is stored top down
+    for (int row = 0; row < out.height; row++) {
+	for (int col = 0; col < out.width; col++) {
+	    const float* texel = msdf (col, out.height - row - 1);
+	    uint8_t* dst = &out.pixels[(static_cast<size_t> (row) * out.width + col) * 4];
+	    dst[0] = toByte (texel[0]);
+	    dst[1] = toByte (texel[1]);
+	    dst[2] = toByte (texel[2]);
+	    dst[3] = 255;
+	}
+    }
+
+    return true;
+}
+
+void TextLayout::resetAtlas () {
+    m_atlasSize = 512;
+    m_atlasPixels.assign (static_cast<size_t> (m_atlasSize) * m_atlasSize * this->getAtlasChannels (), 0);
+    m_packOrder.clear ();
+    m_shelfX = m_shelfY = m_shelfHeight = 0;
+    m_atlasFull = false;
+    m_atlasChanged = true;
+}
+
+bool TextLayout::pack (uint64_t key, AtlasGlyph& glyph) {
+    // one pixel between glyphs so linear filtering never picks up a neighbour
+    const int width = glyph.width + 1;
+    const int height = glyph.height + 1;
+
+    while (true) {
+	if (m_shelfX + width > m_atlasSize) {
+	    m_shelfY += m_shelfHeight;
+	    m_shelfX = 0;
+	    m_shelfHeight = 0;
+	}
+
+	if (width <= m_atlasSize && m_shelfY + height <= m_atlasSize) {
+	    break;
+	}
+
+	// the atlas doubles up to 4096 like WE's
+	if (m_atlasSize >= kMaxAtlasSize) {
+	    return false;
+	}
+
+	this->repackAtlas (m_atlasSize * 2);
+    }
+
+    glyph.x = m_shelfX;
+    glyph.y = m_shelfY;
+    m_shelfX += width;
+    m_shelfHeight = std::max (m_shelfHeight, height);
+
+    const int channels = this->getAtlasChannels ();
+
+    for (int row = 0; row < glyph.height; row++) {
+	std::copy_n (
+	    glyph.pixels.begin () + static_cast<long> (row) * glyph.width * channels, glyph.width * channels,
+	    m_atlasPixels.begin () + (static_cast<long> (glyph.y + row) * m_atlasSize + glyph.x) * channels
+	);
+    }
+
+    m_packOrder.push_back (key);
+    m_atlasChanged = true;
+    return true;
+}
+
+void TextLayout::repackAtlas (int size) {
+    const std::vector<uint64_t> order = std::move (m_packOrder);
+
+    m_atlasSize = size;
+    m_atlasPixels.assign (static_cast<size_t> (size) * size * this->getAtlasChannels (), 0);
+    m_packOrder.clear ();
+    m_shelfX = m_shelfY = m_shelfHeight = 0;
+
+    for (const uint64_t key : order) {
+	if (auto it = m_glyphs.find (key); it != m_glyphs.end ()) {
+	    this->pack (key, it->second);
+	}
+    }
+}
+
+bool TextLayout::takeAtlasChanged () {
+    const bool changed = m_atlasChanged;
+    m_atlasChanged = false;
+    return changed;
+}
+
+TextLayoutResult TextLayout::layout (const std::string& utf8, const TextLayoutParams& params) {
+    TextLayoutResult result;
+
+    if (m_faces.empty ()) {
+	return result;
+    }
+
+    if (params.msdf != m_atlasMsdf) {
+	m_atlasMsdf = params.msdf;
+	m_glyphs.clear ();
+	this->resetAtlas ();
+    }
+
+    this->applySize (params.size);
+
+    const std::u32string text = decodeUtf8 (utf8);
+
+    // WE flushes a full atlas and lays the text out again with only what it needs
+    for (int attempt = 0; attempt < 2; attempt++) {
+	m_atlasFull = false;
+
+	std::vector<std::u32string> lines;
+
+	for (size_t start = 0; start < text.size ();) {
+	    const size_t newline = text.find (U'\n', start);
+
+	    if (params.maxRows > 0 && lines.size () >= static_cast<size_t> (params.maxRows)) {
+		finishLastRow (lines.back (), params.ellipsis);
+		break;
+	    }
+
+	    lines.push_back (text.substr (start, newline == std::u32string::npos ? newline : newline - start));
+
+	    if (newline == std::u32string::npos) {
+		break;
+	    }
+
+	    start = newline + 1;
+	}
+
+	std::vector<bool> wrapped (lines.size (), false);
+
+	struct Line {
+	    std::vector<ShapedGlyph> glyphs;
+	    float width;
+	};
+
+	std::vector<Line> finished;
+	size_t glyphCount = 0;
+	float minX = 0.0f;
+	float maxX = 0.0f;
+	float minY = 0.0f;
+	float maxY = 0.0f;
+	float widest = 0.0f;
+	const FT_Size_Metrics& metrics = m_faces.front ()->face->size->metrics;
+	const float pitch = static_cast<float> (metrics.height >> 6) + params.spacing.y;
+
+	for (size_t i = 0; i < lines.size ();) {
+	    const std::vector<ShapedGlyph> glyphs = this->shape (lines[i], params);
+	    std::vector<ShapedGlyph> kept;
+	    float pen = 0.0f;
+	    int64_t previousCluster = -1;
+	    bool reshape = false;
+	    bool lastLine = false;
+
+	    for (const auto& glyph : glyphs) {
+		const bool overflows = params.maxWidth > 0.0f && !kept.empty () && !isBreakSpace (glyph.codepoint)
+		    && static_cast<int64_t> (glyph.cluster) != previousCluster && pen + glyph.x1 > params.maxWidth;
+
+		if (!overflows) {
+		    kept.push_back (glyph);
+		    pen += glyph.advance;
+		    previousCluster = glyph.cluster;
+		    continue;
+		}
+
+		std::u32string& line = lines[i];
+
+		if (params.maxRows <= 0 || finished.size () + 1 < static_cast<size_t> (params.maxRows)) {
+		    // back to the last whitespace before the overflowing character, or break inside the word
+		    size_t breakAt = glyph.cluster;
+
+		    while (breakAt > 1 && isBreakSpace (line[breakAt])) {
+			breakAt--;
+		    }
+
+		    for (size_t j = breakAt; j-- > 0;) {
+			if (isBreakSpace (line[j])) {
+			    breakAt = j;
+			    break;
+			}
+		    }
+
+		    std::u32string rest = line.substr (breakAt);
+		    rest.erase (0, std::min (rest.size (), rest.find_first_not_of (U"\t\r ")));
+		    line.resize (breakAt);
+		    trimTrailingSpaces (line);
+		    wrapped[i] = true;
+
+		    std::erase_if (kept, [breakAt] (const ShapedGlyph& g) { return g.cluster >= breakAt; });
+		    lines.insert (lines.begin () + static_cast<long> (i) + 1, std::move (rest));
+		    wrapped.insert (wrapped.begin () + static_cast<long> (i) + 1, false);
+		} else {
+		    wrapped[i] = false;
+		    lines.resize (i + 1);
+		    wrapped.resize (i + 1);
+
+		    if (params.ellipsis) {
+			// the longest start of the line that still fits with the ellipsis after it
+			size_t length = glyph.cluster;
+
+			while (true) {
+			    std::u32string candidate = line.substr (0, std::min (length, line.size ()));
+			    candidate.push_back (kEllipsis);
+
+			    if (this->measureRight (candidate) <= params.maxWidth || length == 0) {
+				break;
+			    }
+
+			    length--;
+			}
+
+			line.resize (std::min (length, line.size ()));
+
+			if (line.empty () || line.back () != kEllipsis) {
+			    trimTrailingSpaces (line);
+			    line.push_back (kEllipsis);
+			}
+
+			reshape = true;
+		    } else {
+			finishLastRow (line, false);
+			lastLine = true;
+		    }
+		}
+
+		break;
+	    }
+
+	    if (reshape) {
+		continue;
+	    }
+
+	    float lineMinX = 0.0f;
+	    float lineMinY = 0.0f;
+	    float lineMaxX = 0.0f;
+	    float lineMaxY = 0.0f;
+	    float penX = 0.0f;
+	    float penY = 0.0f;
+
+	    for (const auto& glyph : kept) {
+		lineMinX = std::min (lineMinX, penX + glyph.x0);
+		lineMinY = std::min (lineMinY, penY + glyph.y0);
+		lineMaxX = std::max (lineMaxX, penX + glyph.x1);
+		lineMaxY = std::max (lineMaxY, penY + glyph.y1);
+		penX += glyph.advance;
+		penY += glyph.yAdvance;
+	    }
+
+	    // blockalign stretches the spaces of wrapped lines until they fill maxwidth
+	    if (params.blockAlign && wrapped[i]) {
+		const auto spaces = std::ranges::count_if (lines[i], isBreakSpace);
+
+		if (spaces > 0) {
+		    const float extra = (params.maxWidth - (lineMaxX - lineMinX)) / static_cast<float> (spaces);
+
+		    for (auto& glyph : kept) {
+			if (isBreakSpace (glyph.codepoint)) {
+			    glyph.advance += extra;
+			}
+		    }
+
+		    lineMaxX = lineMinX + params.maxWidth;
+		}
+	    }
+
+	    const float width = lineMaxX - lineMinX;
+	    const float lineY = static_cast<float> (finished.size ()) * -pitch;
+
+	    minY = std::min (minY, lineY + lineMinY);
+	    maxY = std::max (maxY, lineY + lineMaxY);
+	    minX = std::min (lineMinX, minX);
+	    maxX = std::max (lineMaxX, maxX);
+	    widest = std::max (width, widest);
+	    glyphCount += kept.size ();
+	    finished.push_back ({ std::move (kept), width });
+
+	    if (lastLine) {
+		break;
+	    }
+
+	    i++;
+	}
+
+	if (m_atlasFull && attempt == 0) {
+	    m_glyphs.clear ();
+	    this->resetAtlas ();
+	    continue;
+	}
+
+	if (finished.empty () || glyphCount == 0) {
+	    return result;
+	}
+
+	const float ascender = static_cast<float> (metrics.ascender >> 6);
+
+	result.minX = minX;
+	result.maxX = maxX;
+	result.top = std::max (ascender, maxY);
+	result.bottom = std::min (ascender - static_cast<float> (finished.size ()) * pitch, minY);
+	result.pitch = pitch;
+	result.lines = static_cast<int> (finished.size ());
+	result.ascender = ascender;
+	result.descender = static_cast<float> (metrics.descender >> 6);
+
+	const float atlasSize = static_cast<float> (m_atlasSize);
+	float lineY = 0.0f;
+
+	for (const auto& line : finished) {
+	    float shift = 0.0f;
+
+	    if (params.align == TextAlign::Center) {
+		shift = (widest - line.width) * 0.5f;
+	    } else if (params.align == TextAlign::Right) {
+		shift = widest - line.width;
+	    }
+
+	    float pen = 0.0f;
+
+	    for (const auto& glyph : line.glyphs) {
+		const auto it = m_glyphs.find (glyph.key);
+
+		if (it != m_glyphs.end () && it->second.x >= 0) {
+		    const AtlasGlyph& entry = it->second;
+
+		    result.quads.push_back ({
+			.rect = { pen + glyph.x0 - glyph.pad + shift, lineY + glyph.y0 - glyph.pad,
+				  pen + glyph.x1 + glyph.pad + shift, lineY + glyph.y1 + glyph.pad },
+			.uv = { static_cast<float> (entry.x) / atlasSize, static_cast<float> (entry.y) / atlasSize,
+				static_cast<float> (entry.x + entry.width) / atlasSize,
+				static_cast<float> (entry.y + entry.height) / atlasSize },
+		    });
+		}
+
+		pen += glyph.advance;
+	    }
+
+	    lineY -= pitch;
+	}
+
+	result.valid = true;
+	break;
+    }
+
+    return result;
+}

+ 160 - 0
src/WallpaperEngine/Render/Objects/TextLayout.h

@@ -0,0 +1,160 @@
+#pragma once
+
+#include <cstdint>
+#include <memory>
+#include <set>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <glm/vec2.hpp>
+#include <glm/vec4.hpp>
+
+struct FT_LibraryRec_;
+struct FT_FaceRec_;
+typedef struct FT_LibraryRec_* FT_Library;
+typedef struct FT_FaceRec_* FT_Face;
+struct hb_font_t;
+
+namespace WallpaperEngine::Render::Objects {
+
+enum class TextAlign { Left = 0, Center = 1, Right = 2 };
+
+struct TextLayoutParams {
+    /** pointsize clamped to [1, 256], glyphs are rasterized at size * 300 / 72 pixels */
+    float size = 32.0f;
+    glm::vec2 spacing = { 0.0f, 0.0f };
+    bool msdf = false;
+    TextAlign align = TextAlign::Center;
+    /** 0 = no wrapping */
+    float maxWidth = 0.0f;
+    /** 0 = no limit */
+    int maxRows = 0;
+    bool ellipsis = false;
+    bool blockAlign = false;
+
+    bool operator== (const TextLayoutParams&) const = default;
+};
+
+struct TextGlyphQuad {
+    /** x0, y0, x1, y1 in raster pixels, y up, first baseline at 0 */
+    glm::vec4 rect;
+    /** u0, v0 (top), u1, v1 (bottom) */
+    glm::vec4 uv;
+};
+
+struct TextLayoutResult {
+    std::vector<TextGlyphQuad> quads;
+    float minX = 0.0f;
+    float bottom = 0.0f;
+    float maxX = 0.0f;
+    float top = 0.0f;
+    float pitch = 0.0f;
+    int lines = 0;
+    float ascender = 0.0f;
+    float descender = 0.0f;
+    bool valid = false;
+};
+
+/**
+ * Text layout of wallpaper64.exe 2.8.42 (sub_1401B0410): HarfBuzz shaped runs per font, wrapping, row limit with
+ * ellipsis, justification, and a glyph atlas of either plain coverage (R8) or MSDF glyphs (RGBA, msdfgen, 32 px per em
+ * plus 12 px of range on every side). Everything is in raster pixels, before the object's scale.
+ */
+class TextLayout {
+public:
+    TextLayout ();
+    ~TextLayout ();
+
+    TextLayout (const TextLayout&) = delete;
+    TextLayout& operator= (const TextLayout&) = delete;
+
+    /** Replaces every loaded face. An empty data vector loads the file at path instead */
+    bool setPrimaryFont (std::vector<uint8_t> data, const std::string& path);
+    [[nodiscard]] bool hasFont () const { return !m_faces.empty (); }
+
+    TextLayoutResult layout (const std::string& text, const TextLayoutParams& params);
+
+    [[nodiscard]] int getAtlasSize () const { return m_atlasSize; }
+    [[nodiscard]] int getAtlasChannels () const { return m_atlasMsdf ? 4 : 1; }
+    [[nodiscard]] const std::vector<uint8_t>& getAtlasPixels () const { return m_atlasPixels; }
+    /** true once after the atlas pixels changed */
+    bool takeAtlasChanged ();
+
+private:
+    struct Face {
+	FT_Face face = nullptr;
+	hb_font_t* font = nullptr;
+	std::vector<uint8_t> data;
+	std::string path;
+    };
+
+    struct GlyphBox {
+	int x0 = 0;
+	int y0 = 0;
+	int x1 = 0;
+	int y1 = 0;
+    };
+
+    struct AtlasGlyph {
+	int width = 0;
+	int height = 0;
+	std::vector<uint8_t> pixels;
+	int x = -1;
+	int y = -1;
+    };
+
+    struct ShapedGlyph {
+	uint64_t key = 0;
+	uint32_t cluster = 0;
+	char32_t codepoint = 0;
+	float advance = 0.0f;
+	float yAdvance = 0.0f;
+	float x0 = 0.0f;
+	float y0 = 0.0f;
+	float x1 = 0.0f;
+	float y1 = 0.0f;
+	float pad = 0.0f;
+    };
+
+    struct Run {
+	size_t face = 0;
+	size_t start = 0;
+	std::u32string text;
+    };
+
+    bool addFace (std::vector<uint8_t> data, const std::string& path);
+    void clearFaces ();
+    void applySize (float size);
+    int faceFor (char32_t codepoint);
+    std::vector<Run> itemize (const std::u32string& line);
+    std::vector<ShapedGlyph> shape (const std::u32string& line, const TextLayoutParams& params);
+    /** right edge of the last glyph of a shaped string, what WE measures an ellipsis candidate by */
+    float measureRight (const std::u32string& line);
+    const GlyphBox* glyphBox (size_t face, uint32_t glyph);
+    AtlasGlyph* atlasGlyph (size_t face, uint32_t glyph);
+    bool renderPlainGlyph (FT_Face face, uint32_t glyph, AtlasGlyph& out);
+    bool renderMsdfGlyph (FT_Face face, uint32_t glyph, AtlasGlyph& out);
+    bool pack (uint64_t key, AtlasGlyph& glyph);
+    void repackAtlas (int size);
+    void resetAtlas ();
+
+    FT_Library m_library = nullptr;
+    std::vector<std::unique_ptr<Face>> m_faces;
+    std::set<char32_t> m_fallbackTried;
+    float m_size = 0.0f;
+    bool m_atlasMsdf = false;
+
+    std::unordered_map<uint64_t, GlyphBox> m_boxes;
+    std::unordered_map<uint64_t, AtlasGlyph> m_glyphs;
+    std::vector<uint64_t> m_packOrder;
+
+    int m_atlasSize = 512;
+    std::vector<uint8_t> m_atlasPixels;
+    int m_shelfY = 0;
+    int m_shelfHeight = 0;
+    int m_shelfX = 0;
+    bool m_atlasFull = false;
+    bool m_atlasChanged = true;
+};
+} // namespace WallpaperEngine::Render::Objects

+ 2 - 0
src/WallpaperEngine/Render/RenderContext.cpp

@@ -62,6 +62,7 @@ void RenderContext::render (Drivers::Output::OutputViewport* viewport) {
 #endif /* DEBUG */
 #endif /* DEBUG */
 
 
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
+	ref->second->setOutputHDR (viewport->isHDR ());
 	ref->second->render (
 	ref->second->render (
 	    viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition, viewport->logicalSize
 	    viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition, viewport->logicalSize
 	);
 	);
@@ -91,6 +92,7 @@ void RenderContext::renderWithStats (Drivers::Output::OutputViewport* viewport)
     const auto sceneStart = Clock::now ();
     const auto sceneStart = Clock::now ();
 
 
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
+	ref->second->setOutputHDR (viewport->isHDR ());
 	ref->second->render (
 	ref->second->render (
 	    viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition, viewport->logicalSize
 	    viewport->viewport, this->getOutput ().renderVFlip (), viewport->globalPosition, viewport->logicalSize
 	);
 	);

+ 29 - 0
src/WallpaperEngine/Render/Shaders/ShaderUnit.cpp

@@ -107,6 +107,7 @@ void ShaderUnit::preprocess () {
     this->preprocessVariables ();
     this->preprocessVariables ();
     this->preprocessBalanceConditionals ();
     this->preprocessBalanceConditionals ();
     this->preprocessSwizzledDeclarations ();
     this->preprocessSwizzledDeclarations ();
+    this->preprocessScalarSwizzles ();
 
 
     const std::string from = "gl_FragColor";
     const std::string from = "gl_FragColor";
     const std::string to = "out_FragColor";
     const std::string to = "out_FragColor";
@@ -422,6 +423,34 @@ void ShaderUnit::preprocessSwizzledDeclarations () {
     }
     }
 }
 }
 
 
+void ShaderUnit::preprocessScalarSwizzles () {
+    // genericropeparticle's non geometry shader TRAILSCROLLALPHA + TRAILFADESIZE branch writes sizeStart.w on a
+    // float, WE never compiles that branch (it draws ropes with a geometry shader) and GLSL rejects it. A single
+    // component swizzle of a variable that is only ever declared as float is the variable itself
+    static const std::regex declaration (R"(\b(float|int|bool|u?int|[biu]?vec[234]|mat[234](?:x[234])?)\s+([A-Za-z_][A-Za-z0-9_]*)\b)");
+
+    std::set<std::string> scalars;
+    std::set<std::string> others;
+    for (auto it = std::sregex_iterator (this->m_preprocessed.begin (), this->m_preprocessed.end (), declaration);
+	 it != std::sregex_iterator (); ++it) {
+	((*it)[1] == "float" ? scalars : others).insert ((*it)[2]);
+    }
+
+    for (const auto& name : scalars) {
+	// std::regex is slow, most names never show up with a dot after them
+	if (others.contains (name) || this->m_preprocessed.find (name + ".") == std::string::npos) {
+	    continue;
+	}
+
+	const std::regex swizzle ("\\b" + name + "\\.[xyzwrgba](?![A-Za-z0-9_])");
+	const std::string fixed = std::regex_replace (this->m_preprocessed, swizzle, name);
+	if (fixed != this->m_preprocessed) {
+	    sLog.out ("Dropped swizzle on float ", name, " in shader ", this->m_file);
+	    this->m_preprocessed = fixed;
+	}
+    }
+}
+
 std::string ShaderUnit::applyVectorTruncationCompatibility (std::string source) const {
 std::string ShaderUnit::applyVectorTruncationCompatibility (std::string source) const {
     static const std::regex vectorDecl (R"(\b(vec[234])\s+([A-Za-z_][A-Za-z0-9_]*)\b)");
     static const std::regex vectorDecl (R"(\b(vec[234])\s+([A-Za-z_][A-Za-z0-9_]*)\b)");
     static const std::regex narrowAssign (R"(\b(vec[23])\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([^;{}]+);)");
     static const std::regex narrowAssign (R"(\b(vec[23])\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([^;{}]+);)");

+ 1 - 0
src/WallpaperEngine/Render/Shaders/ShaderUnit.h

@@ -56,6 +56,7 @@ private:
     /** Some workshop shaders declare a varying/uniform with a swizzle in its name (`varying vec4 v_Size.xy;`),
     /** Some workshop shaders declare a varying/uniform with a swizzle in its name (`varying vec4 v_Size.xy;`),
      *  which the original compiler tolerates and glslang rejects. Drops the swizzle from the declaration. */
      *  which the original compiler tolerates and glslang rejects. Drops the swizzle from the declaration. */
     void preprocessSwizzledDeclarations ();
     void preprocessSwizzledDeclarations ();
+    void preprocessScalarSwizzles ();
     /** Resolves a #require module name (e.g. "LightingV1") to generated GLSL code, or "" if unknown */
     /** Resolves a #require module name (e.g. "LightingV1") to generated GLSL code, or "" if unknown */
     [[nodiscard]] std::string resolveRequireModule (const std::string& moduleName) const;
     [[nodiscard]] std::string resolveRequireModule (const std::string& moduleName) const;
     /** Generates the LightingV1 module stub (PerformLighting_V1 function) */
     /** Generates the LightingV1 module stub (PerformLighting_V1 function) */

+ 139 - 0
src/WallpaperEngine/Render/Utils/NoiseUtils.h

@@ -1,6 +1,8 @@
 #pragma once
 #pragma once
 
 
+#include <algorithm>
 #include <cmath>
 #include <cmath>
+#include <cstdint>
 #include <glm/glm.hpp>
 #include <glm/glm.hpp>
 
 
 namespace WallpaperEngine::Render::Utils {
 namespace WallpaperEngine::Render::Utils {
@@ -234,4 +236,141 @@ inline float simplexNoise3D (float x, float y, float z) {
     return 32.0f * total;
     return 32.0f * total;
 }
 }
 
 
+/**
+ * wallpaper64.exe's 2D noise object (vtable off_140488440 slot 1, sub_1400FBE90, four lanes at once there): a hashed
+ * simplex with the integer seed mixed into every corner hash, roughly -1..1. Same operations in the same order
+ */
+inline float hashedNoise2D (int32_t seed, float x, float y) {
+    constexpr float F2 = 0.36602540f;
+    constexpr float G2 = 0.21132487f;
+    constexpr uint32_t primeX = 501125321u;
+    constexpr uint32_t primeY = 1136930381u;
+    constexpr uint32_t hashMultiplier = 0x27D4EB2Du;
+    constexpr float cornerOffset = -0.57735026f;
+    constexpr float gradientRatio = 2.4142137f;
+
+    const float skew = (x + y) * F2;
+    const float i = std::floor (skew + x);
+    const float j = std::floor (skew + y);
+    const float unskew = G2 * (j + i);
+    const float x0 = x - (i - unskew);
+    const float y0 = y - (j - unskew);
+    const uint32_t hashX = static_cast<uint32_t> (static_cast<int32_t> (i)) * primeX;
+    const uint32_t hashY = static_cast<uint32_t> (static_cast<int32_t> (j)) * primeY;
+
+    const bool xFirst = y0 < x0;
+    const float x1 = (x0 - (xFirst ? 1.0f : 0.0f)) + G2;
+    const float y1 = (y0 - (xFirst ? 0.0f : 1.0f)) + G2;
+    const float x2 = x0 + cornerOffset;
+    const float y2 = y0 + cornerOffset;
+
+    float a0 = std::max (-(x0 * x0) + (-(y0 * y0) + 0.5f), 0.0f);
+    float a1 = std::max (-(x1 * x1) + (-(y1 * y1) + 0.5f), 0.0f);
+    float a2 = std::max (-(x2 * x2) + (-(y2 * y2) + 0.5f), 0.0f);
+    a0 *= a0;
+    a1 *= a1;
+    a2 *= a2;
+
+    const auto hash = [seed] (uint32_t hx, uint32_t hy) {
+	int32_t h = static_cast<int32_t> (((hy ^ hx) ^ static_cast<uint32_t> (seed)) * hashMultiplier);
+	return h ^ (h >> 15);
+    };
+    const auto flip = [] (float value, int32_t bit) { return bit != 0 ? -value : value; };
+    // bit 0 and 1 flip the signs of x and y, bit 2 decides which one gets the bigger weight
+    const auto gradient = [&flip, gradientRatio] (int32_t h, float gx, float gy) {
+	const float a = flip (gx, h & 1);
+	const float b = flip (gy, (h >> 1) & 1);
+	return (h & 4) != 0 ? b * gradientRatio + a : a * gradientRatio + b;
+    };
+
+    const int32_t h0 = hash (hashX, hashY);
+    const int32_t h1 = hash (hashX + (xFirst ? primeX : 0u), hashY + (xFirst ? 0u : primeY));
+    const int32_t h2 = hash (hashX + primeX, hashY + primeY);
+
+    return (gradient (h0, x0, y0) * (a0 * a0) + (gradient (h1, x1, y1) * (a1 * a1) + gradient (h2, x2, y2) * (a2 * a2)))
+	* 38.283688f;
+}
+
+/** 1 / (1 + 0.5 + 0.25 ...) over the octaves, what wallpaper64.exe scales its fbm by (off_140488440 slot 3) */
+inline float hashedNoiseFbmNormalizer (int octaves) {
+    float total = 1.0f;
+    float amplitude = 0.5f;
+    for (int octave = 1; octave < octaves; octave++) {
+	total += amplitude;
+	amplitude *= 0.5f;
+    }
+    return 1.0f / total;
+}
+
+/** off_140488440 slot 4 (sub_1400FBC70): octaves of hashedNoise2D, frequency x2, seed +1 and amplitude x0.5 each */
+inline float hashedNoiseFbm (int octaves, float amplitude, int32_t seed, float x, float y) {
+    float sum = hashedNoise2D (seed, x, y) * amplitude;
+    for (int octave = 1; octave < octaves; octave++) {
+	x *= 2.0f;
+	y *= 2.0f;
+	seed++;
+	amplitude *= 0.5f;
+	sum += hashedNoise2D (seed, x, y) * amplitude;
+    }
+    return sum;
+}
+
+/** wallpaper64.exe sub_14027B170: 2D simplex over the permutation table, roughly -1..1 */
+inline float simplexNoise2D (float x, float y) {
+    constexpr float F2 = 0.36602540f;
+    constexpr float G2 = 0.21132487f;
+    constexpr float G2x2 = 0.42264974f;
+
+    const float skew = (x + y) * F2;
+    const int i = static_cast<int> (std::floor (skew + x));
+    const int j = static_cast<int> (std::floor (skew + y));
+    const float unskew = static_cast<float> (j + i) * G2;
+    const float x0 = x - (static_cast<float> (i) - unskew);
+    const float y0 = y - (static_cast<float> (j) - unskew);
+    const int i1 = x0 > y0 ? 1 : 0;
+    const int j1 = 1 - i1;
+    const float x1 = (x0 - static_cast<float> (i1)) + G2;
+    const float y1 = (y0 - static_cast<float> (j1)) + G2;
+    const float x2 = (x0 - 1.0f) + G2x2;
+    const float y2 = (y0 - 1.0f) + G2x2;
+
+    const auto perm = [] (int index) { return static_cast<int> (PERLIN_PERM[index & 0xff]); };
+    // (h & 0x3c) swaps the axes, bit 0 negates the first one, bit 1 doubles the second one negative instead
+    const auto corner = [] (int h, float cx, float cy, float falloff) {
+	if (falloff < 0.0f) {
+	    return 0.0f;
+	}
+	float u = cx;
+	float v = cy;
+	if ((h & 0x3c) != 0) {
+	    std::swap (u, v);
+	}
+	if ((h & 1) != 0) {
+	    u = -u;
+	}
+	v = (h & 2) != 0 ? v * -2.0f : v + v;
+	falloff *= falloff;
+	return (v + u) * (falloff * falloff);
+    };
+
+    const float n0 = corner (perm (i + perm (j)), x0, y0, (0.5f - x0 * x0) - y0 * y0);
+    const float n1 = corner (perm (i1 + perm (j + j1) + i), x1, y1, (0.5f - x1 * x1) - y1 * y1);
+    const float n2 = corner (perm (i + 1 + perm (j + 1)), x2, y2, (0.5f - x2 * x2) - y2 * y2);
+    return ((n1 + n0) + n2) * 45.230652f;
+}
+
+/** wallpaper64.exe sub_14027B4B0: octaves of simplexNoise1D, frequency x2 and amplitude x0.5 each, averaged */
+inline float simplexFbm1D (float x, float frequency, int octaves) {
+    float sum = 0.0f;
+    float total = 0.0f;
+    float amplitude = 1.0f;
+    for (int octave = 0; octave < octaves; octave++) {
+	sum += simplexNoise1D (frequency * x) * amplitude;
+	total += amplitude;
+	frequency *= 2.0f;
+	amplitude *= 0.5f;
+    }
+    return sum / total;
+}
+
 } // namespace WallpaperEngine::Render::Utils
 } // namespace WallpaperEngine::Render::Utils

+ 500 - 0
src/WallpaperEngine/Render/Volumetrics.cpp

@@ -0,0 +1,500 @@
+#include "Volumetrics.h"
+
+#include <cmath>
+#include <string>
+
+#include <glm/gtc/matrix_transform.hpp>
+
+#include "WallpaperEngine/Application/ApplicationContext.h"
+#include "WallpaperEngine/Application/WallpaperApplication.h"
+#include "WallpaperEngine/Data/Model/Object.h"
+#include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Render/RenderContext.h"
+#include "WallpaperEngine/Render/Wallpapers/CScene.h"
+
+using namespace WallpaperEngine::Render;
+
+namespace {
+const char* kVolumeVertex = R"(
+uniform mat4 u_ViewProjection;
+uniform mat4 u_Volume;
+layout (location = 0) in vec3 a_Position;
+out vec4 v_ScreenPos;
+
+void main () {
+    gl_Position = u_ViewProjection * u_Volume * vec4 (a_Position, 1.0);
+#if FULLSCREEN
+    gl_Position = vec4 (a_Position.xy, -1.0, 1.0);
+#endif
+    v_ScreenPos = gl_Position;
+}
+)";
+
+// the far side of the volume, as normalized depth
+const char* kBackFragment = R"(
+in vec4 v_ScreenPos;
+out vec4 fragColor;
+
+void main () {
+    fragColor = vec4 (v_ScreenPos.z / v_ScreenPos.w, 0.0, 0.0, 1.0);
+}
+)";
+
+// assets/shaders/volumetricsfront.frag for point lights: from the near side of the volume to the far one, the light's
+// falloff summed over a few samples. The shadow atlas has nothing in it (no shadow casters), so every sample is lit
+const char* kFrontFragment = R"(
+uniform sampler2D u_Back;
+uniform mat4 u_InverseViewProjection;
+uniform vec2 u_BufferSize;
+uniform vec3 u_LightOrigin;
+uniform float u_Radius;
+uniform float u_Intensity;
+uniform float u_Density;
+uniform float u_Exponent;
+uniform vec3 u_Color;
+uniform vec3 u_EyePosition;
+uniform vec3 g_FogDistanceColor;
+uniform vec4 g_FogDistanceParams;
+uniform vec3 g_FogHeightColor;
+uniform vec4 g_FogHeightParams;
+in vec4 v_ScreenPos;
+out vec4 fragColor;
+
+float hash12 (vec2 p) {
+    vec3 p3 = fract (vec3 (p.xyx) * 43758.5453);
+    p3 += vec3 (dot (p3, p3.yzx + 19.19));
+    return fract ((p3.x + p3.y) * p3.z);
+}
+
+float fogAlpha (float alpha, float viewLength, float height) {
+    float fogDistance = 0.0;
+    float fogHeight = 0.0;
+#if FOG_DIST
+    fogDistance = clamp ((viewLength - g_FogDistanceParams.x) / g_FogDistanceParams.y, 0.0, 1.0);
+    fogDistance = g_FogDistanceParams.z + g_FogDistanceParams.w * fogDistance * fogDistance;
+#endif
+#if FOG_HEIGHT
+    fogHeight = clamp ((height - g_FogHeightParams.x) / g_FogHeightParams.y, 0.0, 1.0);
+    fogHeight = g_FogHeightParams.z + g_FogHeightParams.w * fogHeight * fogHeight;
+#endif
+    float fogFactor = clamp (max (fogDistance, fogHeight), 0.0, 1.0);
+    return alpha * (1.0 - fogFactor * fogFactor);
+}
+
+void main () {
+    vec3 screenDepth = v_ScreenPos.xyz / v_ScreenPos.w;
+    float backDepth = texture (u_Back, gl_FragCoord.xy / u_BufferSize).r;
+
+    if (backDepth - screenDepth.z < 0.0) {
+	discard;
+    }
+
+    // nothing in the scene limits the ray, the scene buffer has no depth there
+    backDepth = min (backDepth, 1.0);
+
+    vec4 worldStart = u_InverseViewProjection * vec4 (screenDepth, 1.0);
+    vec4 worldEnd = u_InverseViewProjection * vec4 (screenDepth.xy, backDepth, 1.0);
+    worldStart.xyz /= worldStart.w;
+    worldEnd.xyz /= worldEnd.w;
+
+    const float sampleCount = float (SAMPLES);
+    vec3 worldStep = (worldEnd.xyz - worldStart.xyz) / (sampleCount + 1.0);
+    float invRadius = 1.0 / u_Radius;
+    float maxLightScale = u_Intensity * length (worldEnd.xyz - worldStart.xyz) * invRadius * 0.5;
+
+#if SHADOW
+    // WE's screen UV runs top down, this buffer bottom up
+    worldStart.xyz += worldStep * hash12 (screenDepth.xy * 0.5 + 0.5);
+#endif
+
+    float shadowFactor = 0.0;
+
+    for (int s = 0; s < SAMPLES; ++s) {
+	worldStart.xyz += worldStep;
+	vec3 lightDelta = worldStart.xyz - u_LightOrigin;
+	float sampleValue = pow (clamp (1.0 - length (lightDelta) * invRadius, 0.0, 1.0), u_Exponent);
+#if FOG_DIST || FOG_HEIGHT
+	sampleValue *= fogAlpha (sampleValue, length (u_EyePosition - worldStart.xyz), worldStart.y);
+#endif
+	shadowFactor += sampleValue;
+    }
+
+    shadowFactor /= sampleCount;
+    fragColor = vec4 (u_Density * maxLightScale * shadowFactor * u_Color * 0.1, 1.0);
+}
+)";
+
+const char* kQuadVertex = R"(
+out vec2 v_TexCoord;
+
+void main () {
+    vec2 corner = vec2 ((gl_VertexID << 1) & 2, gl_VertexID & 2);
+    v_TexCoord = corner;
+    gl_Position = vec4 (corner * 2.0 - 1.0, 0.0, 1.0);
+}
+)";
+
+// assets/shaders/blur_k3.frag
+const char* kBlurFragment = R"(
+uniform sampler2D g_Texture0;
+uniform vec2 u_Direction;
+in vec2 v_TexCoord;
+out vec4 fragColor;
+
+void main () {
+    vec3 albedo = texture (g_Texture0, v_TexCoord + u_Direction).rgb * 0.25 + texture (g_Texture0, v_TexCoord).rgb * 0.5
+	+ texture (g_Texture0, v_TexCoord - u_Direction).rgb * 0.25;
+    fragColor = vec4 (albedo, 1.0);
+}
+)";
+
+const char* kCompositeFragment = R"(
+uniform sampler2D g_Texture0;
+in vec2 v_TexCoord;
+out vec4 fragColor;
+
+void main () {
+    fragColor = texture (g_Texture0, v_TexCoord);
+}
+)";
+
+GLuint compile (const std::string& defines, const char* vertex, const char* fragment) {
+    const std::string header = "#version 330\n" + defines;
+    const std::string vertexSource = header + vertex;
+    const std::string fragmentSource = header + fragment;
+    const char* sources[] = { vertexSource.c_str (), fragmentSource.c_str () };
+    const GLenum types[] = { GL_VERTEX_SHADER, GL_FRAGMENT_SHADER };
+    const GLuint program = glCreateProgram ();
+
+    for (int unit = 0; unit < 2; unit++) {
+	const GLuint shader = glCreateShader (types[unit]);
+	glShaderSource (shader, 1, &sources[unit], nullptr);
+	glCompileShader (shader);
+
+	GLint compiled = GL_FALSE;
+	glGetShaderiv (shader, GL_COMPILE_STATUS, &compiled);
+
+	if (compiled == GL_FALSE) {
+	    char log[2048] = {};
+	    glGetShaderInfoLog (shader, sizeof (log) - 1, nullptr, log);
+	    sLog.error ("Volumetrics shader failed to compile: ", log);
+	}
+
+	glAttachShader (program, shader);
+	glDeleteShader (shader);
+    }
+
+    glLinkProgram (program);
+    return program;
+}
+
+void createTarget (auto& target, const glm::ivec2 size, const GLenum format, const GLenum type, const GLenum layout) {
+    glGenTextures (1, &target.texture);
+    glBindTexture (GL_TEXTURE_2D, target.texture);
+    glTexImage2D (GL_TEXTURE_2D, 0, format, size.x, size.y, 0, layout, type, nullptr);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+    glGenFramebuffers (1, &target.framebuffer);
+    glBindFramebuffer (GL_FRAMEBUFFER, target.framebuffer);
+    glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, target.texture, 0);
+}
+
+void destroyTarget (auto& target) {
+    glDeleteFramebuffers (1, &target.framebuffer);
+    glDeleteTextures (1, &target.texture);
+    target = {};
+}
+} // namespace
+
+Volumetrics::Volumetrics (Wallpapers::CScene& scene) :
+    m_scene (scene), m_quality (scene.getContext ().getApp ().getContext ().settings.general.volumetricsQuality) { }
+
+Volumetrics::~Volumetrics () {
+    this->release ();
+
+    for (const GLuint program :
+	 { this->m_backProgram, this->m_frontProgram, this->m_frontFullscreenProgram, this->m_frontShadowProgram,
+	   this->m_frontShadowFullscreenProgram, this->m_blurProgram, this->m_compositeProgram }) {
+	if (program != GL_NONE) {
+	    glDeleteProgram (program);
+	}
+    }
+
+    if (this->m_sphereVertices != GL_NONE) {
+	glDeleteBuffers (1, &this->m_sphereVertices);
+	glDeleteBuffers (1, &this->m_sphereIndices);
+	glDeleteVertexArrays (1, &this->m_vao);
+    }
+}
+
+void Volumetrics::setup () {
+    // the light's volume, a unit sphere (sub_14025C660): poles, then 23 rings of 25 vertices (seam doubled) every 7.5
+    // degrees from the top, 24 slices
+    std::vector<glm::vec3> vertices = { { 0.0f, 1.0f, 0.0f }, { 0.0f, -1.0f, 0.0f } };
+
+    for (int ring = 1; ring < 24; ring++) {
+	const float latitude = static_cast<float> (ring) * 0.1308997f;
+
+	for (int slice = 0; slice <= 24; slice++) {
+	    const float longitude = static_cast<float> (slice) * 0.2617994f;
+	    vertices.emplace_back (
+		std::cos (longitude) * std::sin (latitude), std::cos (latitude), std::sin (longitude) * std::sin (latitude)
+	    );
+	}
+    }
+
+    std::vector<GLushort> indices;
+
+    for (int ring = 0; ring < 22; ring++) {
+	for (int slice = 0; slice < 24; slice++) {
+	    const auto a = static_cast<GLushort> (2 + 25 * ring + slice);
+	    const auto c = static_cast<GLushort> (a + 25);
+	    indices.insert (indices.end (), { a, static_cast<GLushort> (a + 1), c, static_cast<GLushort> (a + 1),
+					      static_cast<GLushort> (c + 1), c });
+	}
+    }
+
+    for (int slice = 0; slice < 24; slice++) {
+	indices.insert (indices.end (), { 0, static_cast<GLushort> (3 + slice), static_cast<GLushort> (2 + slice) });
+	indices.insert (indices.end (), { 1, static_cast<GLushort> (552 + slice), static_cast<GLushort> (553 + slice) });
+    }
+
+    glGenVertexArrays (1, &this->m_vao);
+    glBindVertexArray (this->m_vao);
+    glGenBuffers (1, &this->m_sphereVertices);
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_sphereVertices);
+    glBufferData (
+	GL_ARRAY_BUFFER, static_cast<GLsizeiptr> (vertices.size () * sizeof (glm::vec3)), vertices.data (), GL_STATIC_DRAW
+    );
+    glGenBuffers (1, &this->m_sphereIndices);
+    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_sphereIndices);
+    glBufferData (
+	GL_ELEMENT_ARRAY_BUFFER, static_cast<GLsizeiptr> (indices.size () * sizeof (GLushort)), indices.data (),
+	GL_STATIC_DRAW
+    );
+    this->m_sphereIndexCount = static_cast<GLsizei> (indices.size ());
+    glEnableVertexAttribArray (0);
+    glVertexAttribPointer (0, 3, GL_FLOAT, GL_FALSE, sizeof (glm::vec3), nullptr);
+
+    // sample counts per quality, lights casting shadows take more and jitter the start (volumetricsfront.frag)
+    const int quality = std::clamp (this->m_quality, 1, 4);
+    const int plain[] = { 2, 3, 5, 8 };
+    const int shadowed[] = { 12, 24, 32, 64 };
+    std::string fog;
+
+    if (this->m_scene.hasDistanceFog ()) {
+	fog += "#define FOG_DIST 1\n";
+    }
+    if (this->m_scene.hasHeightFog ()) {
+	fog += "#define FOG_HEIGHT 1\n";
+    }
+
+    const std::string samples = "#define SAMPLES " + std::to_string (plain[quality - 1]) + "\n";
+    const std::string shadowSamples
+	= "#define SHADOW 1\n#define SAMPLES " + std::to_string (shadowed[quality - 1]) + "\n";
+
+    this->m_backProgram = compile ("", kVolumeVertex, kBackFragment);
+    this->m_frontProgram = compile (fog + samples, kVolumeVertex, kFrontFragment);
+    this->m_frontFullscreenProgram = compile (fog + samples + "#define FULLSCREEN 1\n", kVolumeVertex, kFrontFragment);
+    this->m_frontShadowProgram = compile (fog + shadowSamples, kVolumeVertex, kFrontFragment);
+    this->m_frontShadowFullscreenProgram
+	= compile (fog + shadowSamples + "#define FULLSCREEN 1\n", kVolumeVertex, kFrontFragment);
+    this->m_blurProgram = compile ("", kQuadVertex, kBlurFragment);
+    this->m_compositeProgram = compile ("", kQuadVertex, kCompositeFragment);
+}
+
+void Volumetrics::allocate (const glm::ivec2 size) {
+    this->release ();
+    this->m_size = size;
+    // the light buffer is 8 bit unless the scene renders in HDR (format 1 or 15, sub_140196CE0)
+    const bool hdr = this->m_scene.isHDR ();
+    createTarget (this->m_light, size, hdr ? GL_RGBA16F : GL_RGBA8, hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE, GL_RGBA);
+    createTarget (this->m_lightB, size, hdr ? GL_RGBA16F : GL_RGBA8, hdr ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE, GL_RGBA);
+    createTarget (this->m_back, size, GL_R32F, GL_FLOAT, GL_RED);
+    glBindTexture (GL_TEXTURE_2D, this->m_back.texture);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
+    // shared by the back pass (farthest surface) and the light buffer (nearest one), the sphere's winding doesn't
+    // matter that way
+    glGenRenderbuffers (1, &this->m_backDepth);
+    glBindRenderbuffer (GL_RENDERBUFFER, this->m_backDepth);
+    glRenderbufferStorage (GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, size.x, size.y);
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_back.framebuffer);
+    glFramebufferRenderbuffer (GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, this->m_backDepth);
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_light.framebuffer);
+    glFramebufferRenderbuffer (GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, this->m_backDepth);
+}
+
+void Volumetrics::release () {
+    if (this->m_light.texture == GL_NONE) {
+	return;
+    }
+
+    destroyTarget (this->m_light);
+    destroyTarget (this->m_lightB);
+    destroyTarget (this->m_back);
+    glDeleteRenderbuffers (1, &this->m_backDepth);
+    this->m_backDepth = GL_NONE;
+}
+
+void Volumetrics::renderLight (const Data::Model::Light& light, const glm::mat4& world) {
+    if (this->m_quality <= 0) {
+	return;
+    }
+
+    if (this->m_sphereVertices == GL_NONE) {
+	this->setup ();
+    }
+
+    // light buffer at an eighth of the output resolution, a quarter from high quality up
+    const glm::ivec2 size = glm::max (this->m_scene.getOutputResolution () / (this->m_quality >= 3 ? 4 : 8), glm::ivec2 (1));
+
+    if (size != this->m_size) {
+	this->allocate (size);
+    }
+
+    GLint previousFramebuffer = 0;
+    GLint previousVao = 0;
+    GLint previousViewport[4] = {};
+    glGetIntegerv (GL_FRAMEBUFFER_BINDING, &previousFramebuffer);
+    glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &previousVao);
+    glGetIntegerv (GL_VIEWPORT, previousViewport);
+
+    const float radius = light.radius->value->getFloat ();
+    const glm::mat4 volume = world * glm::scale (glm::mat4 (1.0f), glm::vec3 (radius));
+    const glm::mat4 viewProjection = this->m_scene.getWorldViewProjection ();
+    const glm::vec3 origin (world[3]);
+    const auto& fog = this->m_scene.getFog ();
+    const auto& camera = this->m_scene.getCamera ();
+    const glm::vec3 forward = camera.isOrthogonal ()
+	? glm::vec3 (0.0f, 0.0f, -1.0f)
+	: -glm::vec3 (camera.getView ()[0][2], camera.getView ()[1][2], camera.getView ()[2][2]);
+    const glm::vec3 probe = fog.eyeWorld + forward * 0.2f - origin;
+    const bool inside = radius * radius > glm::dot (probe, probe);
+    const bool shadow = light.castShadow
+	&& this->m_scene.getContext ().getApp ().getContext ().settings.general.shadowQuality > 0;
+
+    glBindVertexArray (this->m_vao);
+    glViewport (0, 0, size.x, size.y);
+    glDisable (GL_CULL_FACE);
+    glDisable (GL_BLEND);
+    glEnable (GL_DEPTH_TEST);
+    glDepthMask (GL_TRUE);
+    glColorMask (GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE);
+
+    // far side of the volume
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_back.framebuffer);
+    glClearColor (1.0f, 0.0f, 0.0f, 0.0f);
+    glClearDepth (0.0);
+    glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+    glDepthFunc (GL_GREATER);
+    glUseProgram (this->m_backProgram);
+    glUniformMatrix4fv (glGetUniformLocation (this->m_backProgram, "u_ViewProjection"), 1, GL_FALSE, &viewProjection[0][0]);
+    glUniformMatrix4fv (glGetUniformLocation (this->m_backProgram, "u_Volume"), 1, GL_FALSE, &volume[0][0]);
+    glDrawElements (GL_TRIANGLES, this->m_sphereIndexCount, GL_UNSIGNED_SHORT, nullptr);
+
+    // near side, added into the light buffer. Cleared by the first light since the last composite
+    glBindFramebuffer (GL_FRAMEBUFFER, this->m_light.framebuffer);
+    glClearDepth (1.0);
+
+    if (!this->m_pending) {
+	glClearColor (0.0f, 0.0f, 0.0f, 0.0f);
+	glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
+	this->m_pending = true;
+    } else {
+	glClear (GL_DEPTH_BUFFER_BIT);
+    }
+
+    glDepthFunc (GL_LESS);
+    glEnable (GL_BLEND);
+    glBlendEquation (GL_FUNC_ADD);
+    glBlendFunc (GL_ONE, GL_ONE);
+
+    const GLuint program = shadow ? (inside ? this->m_frontShadowFullscreenProgram : this->m_frontShadowProgram)
+				  : (inside ? this->m_frontFullscreenProgram : this->m_frontProgram);
+    const glm::mat4 inverse = glm::inverse (viewProjection);
+    const glm::vec3 color = light.color->value->getVec3 ();
+
+    glUseProgram (program);
+    glActiveTexture (GL_TEXTURE0);
+    glBindTexture (GL_TEXTURE_2D, this->m_back.texture);
+    glUniform1i (glGetUniformLocation (program, "u_Back"), 0);
+    glUniformMatrix4fv (glGetUniformLocation (program, "u_ViewProjection"), 1, GL_FALSE, &viewProjection[0][0]);
+    glUniformMatrix4fv (glGetUniformLocation (program, "u_Volume"), 1, GL_FALSE, &volume[0][0]);
+    glUniformMatrix4fv (glGetUniformLocation (program, "u_InverseViewProjection"), 1, GL_FALSE, &inverse[0][0]);
+    glUniform2f (glGetUniformLocation (program, "u_BufferSize"), static_cast<float> (size.x), static_cast<float> (size.y));
+    glUniform3fv (glGetUniformLocation (program, "u_LightOrigin"), 1, &origin[0]);
+    // g_RenderVar1.x is 99% of the radius, the falloff ends just inside the mesh
+    glUniform1f (glGetUniformLocation (program, "u_Radius"), radius * 0.99f);
+    glUniform1f (glGetUniformLocation (program, "u_Intensity"), light.intensity->value->getFloat ());
+    glUniform1f (glGetUniformLocation (program, "u_Density"), light.density->value->getFloat ());
+    glUniform1f (glGetUniformLocation (program, "u_Exponent"), light.volumetricsExponent->value->getFloat ());
+    glUniform3fv (glGetUniformLocation (program, "u_Color"), 1, &color[0]);
+    glUniform3fv (glGetUniformLocation (program, "u_EyePosition"), 1, &fog.eyeWorld[0]);
+    glUniform3fv (glGetUniformLocation (program, "g_FogDistanceColor"), 1, &fog.distanceColor[0]);
+    glUniform4fv (glGetUniformLocation (program, "g_FogDistanceParams"), 1, &fog.distanceParams[0]);
+    glUniform3fv (glGetUniformLocation (program, "g_FogHeightColor"), 1, &fog.heightColor[0]);
+    glUniform4fv (glGetUniformLocation (program, "g_FogHeightParams"), 1, &fog.heightParamsWorld[0]);
+    glDrawElements (GL_TRIANGLES, this->m_sphereIndexCount, GL_UNSIGNED_SHORT, nullptr);
+
+    glDisable (GL_DEPTH_TEST);
+    glDepthFunc (GL_LESS);
+    glDisable (GL_BLEND);
+    glBindFramebuffer (GL_FRAMEBUFFER, previousFramebuffer);
+    glBindVertexArray (previousVao);
+    glViewport (previousViewport[0], previousViewport[1], previousViewport[2], previousViewport[3]);
+}
+
+void Volumetrics::composite () {
+    if (!this->m_pending) {
+	return;
+    }
+
+    this->m_pending = false;
+
+    GLint previousFramebuffer = 0;
+    GLint previousVao = 0;
+    GLint previousViewport[4] = {};
+    glGetIntegerv (GL_FRAMEBUFFER_BINDING, &previousFramebuffer);
+    glGetIntegerv (GL_VERTEX_ARRAY_BINDING, &previousVao);
+    glGetIntegerv (GL_VIEWPORT, previousViewport);
+
+    glBindVertexArray (this->m_vao);
+    glDisable (GL_DEPTH_TEST);
+    glDisable (GL_CULL_FACE);
+    glDisable (GL_BLEND);
+    glActiveTexture (GL_TEXTURE0);
+
+    // volumetrics_blur_h/_v below high quality, buffer A -> B -> A
+    if (this->m_quality < 3) {
+	glViewport (0, 0, this->m_size.x, this->m_size.y);
+	glUseProgram (this->m_blurProgram);
+	glUniform1i (glGetUniformLocation (this->m_blurProgram, "g_Texture0"), 0);
+
+	glBindFramebuffer (GL_FRAMEBUFFER, this->m_lightB.framebuffer);
+	glBindTexture (GL_TEXTURE_2D, this->m_light.texture);
+	glUniform2f (glGetUniformLocation (this->m_blurProgram, "u_Direction"), 1.0f / static_cast<float> (this->m_size.x), 0.0f);
+	glDrawArrays (GL_TRIANGLES, 0, 3);
+
+	glBindFramebuffer (GL_FRAMEBUFFER, this->m_light.framebuffer);
+	glBindTexture (GL_TEXTURE_2D, this->m_lightB.texture);
+	glUniform2f (glGetUniformLocation (this->m_blurProgram, "u_Direction"), 0.0f, 1.0f / static_cast<float> (this->m_size.y));
+	glDrawArrays (GL_TRIANGLES, 0, 3);
+    }
+
+    // volumetrics_combine: added onto the scene
+    glBindFramebuffer (GL_FRAMEBUFFER, previousFramebuffer);
+    glViewport (previousViewport[0], previousViewport[1], previousViewport[2], previousViewport[3]);
+    glEnable (GL_BLEND);
+    glBlendEquation (GL_FUNC_ADD);
+    glBlendFuncSeparate (GL_ONE, GL_ONE, GL_ZERO, GL_ONE);
+    glUseProgram (this->m_compositeProgram);
+    glUniform1i (glGetUniformLocation (this->m_compositeProgram, "g_Texture0"), 0);
+    glBindTexture (GL_TEXTURE_2D, this->m_light.texture);
+    glDrawArrays (GL_TRIANGLES, 0, 3);
+
+    glDisable (GL_BLEND);
+    glBindVertexArray (previousVao);
+}

+ 61 - 0
src/WallpaperEngine/Render/Volumetrics.h

@@ -0,0 +1,61 @@
+#pragma once
+
+#include <GL/glew.h>
+#include <glm/glm.hpp>
+#include <vector>
+
+namespace WallpaperEngine::Render::Wallpapers {
+class CScene;
+}
+
+namespace WallpaperEngine::Data::Model {
+class Light;
+}
+
+namespace WallpaperEngine::Render {
+/**
+ * Volumetric light of LightingV1 point lights, wallpaper64.exe 2.8.42 sub_140196CE0 (one light into the light buffer)
+ * and sub_140198D00 (blur, then added onto the scene before the next object that isn't a light)
+ */
+class Volumetrics {
+public:
+    explicit Volumetrics (Wallpapers::CScene& scene);
+    ~Volumetrics ();
+
+    /** Raymarches the light's sphere (its world matrix scaled by the radius) into the light buffer, the first light
+     *  after a composite clears it */
+    void renderLight (const Data::Model::Light& light, const glm::mat4& world);
+    /** Adds the light buffer onto the scene buffer if a light went into it since the last time */
+    void composite ();
+
+private:
+    struct Target {
+	GLuint texture = GL_NONE;
+	GLuint framebuffer = GL_NONE;
+    };
+
+    void setup ();
+    void allocate (glm::ivec2 size);
+    void release ();
+
+    Wallpapers::CScene& m_scene;
+    int m_quality;
+    bool m_pending = false;
+    glm::ivec2 m_size {};
+    Target m_light;
+    Target m_lightB;
+    Target m_back;
+    GLuint m_backDepth = GL_NONE;
+    GLuint m_sphereVertices = GL_NONE;
+    GLuint m_sphereIndices = GL_NONE;
+    GLsizei m_sphereIndexCount = 0;
+    GLuint m_backProgram = GL_NONE;
+    GLuint m_frontProgram = GL_NONE;
+    GLuint m_frontFullscreenProgram = GL_NONE;
+    GLuint m_frontShadowProgram = GL_NONE;
+    GLuint m_frontShadowFullscreenProgram = GL_NONE;
+    GLuint m_vao = GL_NONE;
+    GLuint m_blurProgram = GL_NONE;
+    GLuint m_compositeProgram = GL_NONE;
+};
+} // namespace WallpaperEngine::Render

+ 2 - 0
src/WallpaperEngine/Render/WallpaperState.h

@@ -38,6 +38,8 @@ public:
     void updateVs (const int& projectionWidth, const int& projectionHeight);
     void updateVs (const int& projectionWidth, const int& projectionHeight);
 
 
     [[nodiscard]] auto getTextureUVs () const { return m_UVs; };
     [[nodiscard]] auto getTextureUVs () const { return m_UVs; };
+    /** Whether the texture's first row is the top of the screen (the UVs' v runs top down then) */
+    [[nodiscard]] bool isVFlipped () const { return m_vflip; }
 
 
     template <WallpaperState::TextureUVsScaling> void updateTextureUVs ();
     template <WallpaperState::TextureUVsScaling> void updateTextureUVs ();
 
 

+ 751 - 65
src/WallpaperEngine/Render/Wallpapers/CScene.cpp

@@ -1,5 +1,8 @@
 #include "WallpaperEngine/Render/Objects/CImage.h"
 #include "WallpaperEngine/Render/Objects/CImage.h"
+#include "WallpaperEngine/Data/Model/Property.h"
+#include "WallpaperEngine/Render/Objects/CCamera.h"
 #include "WallpaperEngine/Render/Objects/CLight.h"
 #include "WallpaperEngine/Render/Objects/CLight.h"
+#include "WallpaperEngine/Render/Objects/CMesh.h"
 #include "WallpaperEngine/Render/Objects/CParticle.h"
 #include "WallpaperEngine/Render/Objects/CParticle.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
 #include "WallpaperEngine/Render/Objects/CText.h"
 #include "WallpaperEngine/Render/Objects/CText.h"
@@ -34,9 +37,13 @@ CScene::CScene (
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
     // caller should check this, if not a std::bad_cast is good to throw
     // caller should check this, if not a std::bad_cast is good to throw
     auto scene = wallpaper.as<Scene> ();
     auto scene = wallpaper.as<Scene> ();
+    this->m_startTime = g_Time;
 
 
     this->m_scriptEngine = std::make_unique<Scripting::ScriptEngine> (*this, context.getMediaSource ());
     this->m_scriptEngine = std::make_unique<Scripting::ScriptEngine> (*this, context.getMediaSource ());
     this->m_camera = std::make_unique<Camera> (*this, scene->camera);
     this->m_camera = std::make_unique<Camera> (*this, scene->camera);
+    // shaders get FOG_DIST/FOG_HEIGHT from the renderer flags set when the scene loads (sub_140186440, sub_1401A5C40)
+    this->m_fogDistance = scene->fog.distanceEnabled->value->getBool ();
+    this->m_fogHeight = scene->fog.heightEnabled->value->getBool ();
 
 
     float width = scene->camera.projection.width;
     float width = scene->camera.projection.width;
     float height = scene->camera.projection.height;
     float height = scene->camera.projection.height;
@@ -54,17 +61,30 @@ CScene::CScene (
     this->m_parallaxBias = { 0, 0 };
     this->m_parallaxBias = { 0, 0 };
     this->m_parallaxPosition = { 0.5f, 0.5f };
     this->m_parallaxPosition = { 0.5f, 0.5f };
 
 
-    float canvasWidth = width;
-    float canvasHeight = height;
+    const bool perspective = scene->camera.projection.isPerspective;
 
 
-    if (this->getContext ().getApp ().getContext ().settings.general.expandCanvas) {
-	this->expandCanvasToContent (*scene, width, height, canvasWidth, canvasHeight);
+    if (perspective) {
+	// 3D scenes render at the output's size, the perspective camera stretches to whatever aspect that has
+	this->m_camera->setPerspectiveProjection (width, height);
+    } else {
+	float canvasWidth = width;
+	float canvasHeight = height;
+
+	if (this->getContext ().getApp ().getContext ().settings.general.expandCanvas) {
+	    this->expandCanvasToContent (*scene, width, height, canvasWidth, canvasHeight);
+	}
+
+	this->m_camera->setOrthogonalProjection (width, height, canvasWidth, canvasHeight);
     }
     }
 
 
-    this->m_camera->setOrthogonalProjection (width, height, canvasWidth, canvasHeight);
+    // HDR rendering needs WE's "ultra" post processing plus bloom and hdr in the scene (sub_14010DF40), then the
+    // scene and layer buffers are 16 bit float and bloom is the HDR mip chain (sub_140183610)
+    this->m_hdr = this->getContext ().getApp ().getContext ().settings.general.ultraPostProcessing
+	&& scene->camera.bloom.enabled->value->getBool () && scene->camera.bloom.hdr->value->getBool ();
+    this->m_volumetrics = std::make_unique<Volumetrics> (*this);
 
 
     // needed before scene setup below, which creates FBOs
     // needed before scene setup below, which creates FBOs
-    this->setupFramebuffers ();
+    this->setupFramebuffers (perspective, this->m_hdr ? TextureFormat_RGBA16161616f : TextureFormat_ARGB8888);
 
 
     const uint32_t sceneWidth = this->m_camera->getCanvasWidth ();
     const uint32_t sceneWidth = this->m_camera->getCanvasWidth ();
     const uint32_t sceneHeight = this->m_camera->getCanvasHeight ();
     const uint32_t sceneHeight = this->m_camera->getCanvasHeight ();
@@ -75,6 +95,14 @@ CScene::CScene (
     );
     );
     this->alias ("_alias_lightCookie", "_rt_shadowAtlas");
     this->alias ("_alias_lightCookie", "_rt_shadowAtlas");
 
 
+    // generic2's REFLECTION samples the planar reflection target, not rendered here, so reflections stay black
+    if (perspective) {
+	this->create (
+	    "_rt_Reflection", TextureFormat_ARGB8888, TextureFlags_ClampUVs, 1.0, { sceneWidth / 2, sceneHeight / 2 },
+	    { sceneWidth / 2, sceneHeight / 2 }
+	);
+    }
+
     const glm::vec3 clearColor = scene->colors.clear->value->getVec3 ();
     const glm::vec3 clearColor = scene->colors.clear->value->getVec3 ();
 
 
     glClearColor (clearColor.r, clearColor.g, clearColor.b, 1.0f);
     glClearColor (clearColor.r, clearColor.g, clearColor.b, 1.0f);
@@ -151,7 +179,7 @@ CScene::CScene (
 			) } } }
 			) } } }
 	      ) } };
 	      ) } };
 
 
-    if (scene->camera.bloom.enabled->value->getBool ()) {
+    if (scene->camera.bloom.enabled->value->getBool () && !this->m_hdr) {
 	this->m_bloomObjectData = ObjectParser::parse (bloom, scene->project);
 	this->m_bloomObjectData = ObjectParser::parse (bloom, scene->project);
 	this->m_bloomObject = this->createObject (*this->m_bloomObjectData);
 	this->m_bloomObject = this->createObject (*this->m_bloomObjectData);
 
 
@@ -169,6 +197,19 @@ CScene::~CScene () {
 
 
     this->m_objectsByRenderOrder.clear ();
     this->m_objectsByRenderOrder.clear ();
     this->m_objects.clear ();
     this->m_objects.clear ();
+
+    if (this->m_fadeProgram != GL_NONE) {
+	glDeleteProgram (this->m_fadeProgram);
+    }
+
+    this->releaseHDRBloom ();
+
+    for (const GLuint program : { this->m_bloomDownsample, this->m_bloomDownsampleThreshold, this->m_bloomUpsample,
+				  this->m_bloomUpsampleCubic, this->m_bloomCombine }) {
+	if (program != GL_NONE) {
+	    glDeleteProgram (program);
+	}
+    }
 }
 }
 
 
 Render::CObject* CScene::createObject (const Object& object) {
 Render::CObject* CScene::createObject (const Object& object) {
@@ -243,6 +284,12 @@ Render::CObject* CScene::dispatchObjectType (const Object& object) {
 	renderObject = new Objects::CText (*this, *object.as<Text> ());
 	renderObject = new Objects::CText (*this, *object.as<Text> ());
     } else if (object.is<Light> ()) {
     } else if (object.is<Light> ()) {
 	renderObject = new Objects::CLight (*this, *object.as<Light> (), this->nextFreeLightSlot ());
 	renderObject = new Objects::CLight (*this, *object.as<Light> (), this->nextFreeLightSlot ());
+    } else if (object.is<SceneCamera> ()) {
+	auto* camera = new Objects::CCamera (*this, *object.as<SceneCamera> ());
+	this->m_sceneCameras.push_back (camera);
+	renderObject = camera;
+    } else if (object.is<Mesh> () && this->m_camera->isPerspective ()) {
+	renderObject = new Objects::CMesh (*this, *object.as<Mesh> ());
     } else if (object.is<Particle> ()) {
     } else if (object.is<Particle> ()) {
 	const auto& particleData = *object.as<Particle> ();
 	const auto& particleData = *object.as<Particle> ();
 
 
@@ -352,39 +399,11 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 void CScene::renderFrameSteps (const glm::ivec4& viewport) {
 void CScene::renderFrameSteps (const glm::ivec4& viewport) {
     timeStep ("updateMouse", [&] { this->updateMouse (viewport); });
     timeStep ("updateMouse", [&] { this->updateMouse (viewport); });
 
 
-    if (this->getScene ().camera.parallax.enabled->value->getBool ()) {
-	// the wallpaper's own position rides through the same per-layer depth/clamp mechanism as mouse parallax, halved to match its range
-	// X keeps WallpaperState's sign flip, Y does not because this displacement is applied after the Y-up conversion
-	const glm::vec2 positionBias
-	    = { -this->getState ().getOffsetX () * 0.5f, this->getState ().getOffsetY () * 0.5f };
-
-	if (this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
-	    // no mouse contribution left to smooth toward, apply position directly
-	    this->m_parallaxBias = positionBias;
-	    this->m_cameraParallax = { 0.0f, 0.0f };
-	} else {
-	    const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
-	    const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
-	    const float delay = this->getScene ().camera.parallax.delay->value->getFloat ();
-
-	    this->m_parallaxBias = positionBias * amount * influence;
-
-	    // same easing as the real engine: no delay snaps to the mouse, otherwise a fast exponential follow
-	    const glm::vec2 target = (this->m_mousePosition - glm::vec2 (0.5f, 0.5f)) * influence;
-	    if (delay <= 0.0f) {
-		this->m_cameraParallax = target;
-	    } else {
-		const float dt = std::max (g_Time - g_TimeLast, 0.0f);
-		const float follow = std::min (1.0f, (1.0f - delay / 3.0f) * 10.0f * dt);
-		this->m_cameraParallax += (target - this->m_cameraParallax) * follow;
-	    }
-	}
-
-	this->m_parallaxPosition = glm::clamp (glm::vec2 (0.5f) + this->m_cameraParallax, 0.0f, 1.0f);
-    }
-
     // after the tick, so a layer a script moves this frame (e.g. onto input.cursorWorldPosition) is hit tested where it is now
     // after the tick, so a layer a script moves this frame (e.g. onto input.cursorWorldPosition) is hit tested where it is now
     timeStep ("script tick", [&] { this->getScriptEngine ().tick (); });
     timeStep ("script tick", [&] { this->getScriptEngine ().tick (); });
+    // WE runs the object updates first, then the camera, then the parallax camera
+    this->updateCamera ();
+    this->updateParallax ();
     timeStep ("cursor events", [&] { this->dispatchCursorEvents (); });
     timeStep ("cursor events", [&] { this->dispatchCursorEvents (); });
     this->updateLights ();
     this->updateLights ();
 
 
@@ -413,6 +432,8 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
     glViewport (0, 0, this->m_sceneFBO->getRealWidth (), this->m_sceneFBO->getRealHeight ());
     glViewport (0, 0, this->m_sceneFBO->getRealWidth (), this->m_sceneFBO->getRealHeight ());
 
 
+    // passes leave their own depthwrite setting behind, and a masked depth buffer isn't cleared
+    glDepthMask (GL_TRUE);
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
     glClear (GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
 
 
     for (const auto& cur : this->m_objectsByRenderOrder) {
     for (const auto& cur : this->m_objectsByRenderOrder) {
@@ -434,8 +455,631 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
 	    continue;
 	    continue;
 	}
 	}
 
 
+	// lights draw their volumes into the light buffer, which goes onto the scene before the next other object
+	if (cur->is<Objects::CLight> ()) {
+	    const auto& light = cur->as<Objects::CLight> ()->getLight ();
+
+	    if (light.type == LightType::Point && light.castVolumetrics && light.visible->value->getBool ()
+		&& (!visibility.has_value () || visibility.value ())) {
+		timeStep ("volumetrics " + light.name, [&] {
+		    this->m_volumetrics->renderLight (light, this->objectWorldMatrix (light));
+		});
+	    }
+
+	    continue;
+	}
+
+	this->m_volumetrics->composite ();
 	timeStep ("render " + cur->getObject ().name, [&] { cur->render (); });
 	timeStep ("render " + cur->getObject ().name, [&] { cur->render (); });
     }
     }
+
+    this->m_volumetrics->composite ();
+
+    if (this->m_hdr) {
+	this->renderHDRBloom ();
+    }
+
+    if (this->m_cameraFade > 0.0f) {
+	this->renderCameraFade ();
+    }
+}
+
+namespace {
+GLuint buildProgram (const std::string& defines, const char* fragmentBody) {
+    static const char* vertex = "#version 330\n"
+				"out vec2 v_TexCoord;\n"
+				"void main () {\n"
+				"vec2 corner = vec2 ((gl_VertexID << 1) & 2, gl_VertexID & 2);\n"
+				"v_TexCoord = corner;\n"
+				"gl_Position = vec4 (corner * 2.0 - 1.0, 0.0, 1.0);\n"
+				"}";
+    const std::string fragment = "#version 330\n" + defines + fragmentBody;
+    const char* fragmentSource = fragment.c_str ();
+    const GLuint vertexShader = glCreateShader (GL_VERTEX_SHADER);
+    const GLuint fragmentShader = glCreateShader (GL_FRAGMENT_SHADER);
+    glShaderSource (vertexShader, 1, &vertex, nullptr);
+    glShaderSource (fragmentShader, 1, &fragmentSource, nullptr);
+    glCompileShader (vertexShader);
+    glCompileShader (fragmentShader);
+
+    GLint compiled = GL_FALSE;
+    glGetShaderiv (fragmentShader, GL_COMPILE_STATUS, &compiled);
+
+    if (compiled == GL_FALSE) {
+	char log[2048] = {};
+	glGetShaderInfoLog (fragmentShader, sizeof (log) - 1, nullptr, log);
+	sLog.error ("HDR bloom shader failed to compile: ", log);
+    }
+
+    const GLuint program = glCreateProgram ();
+    glAttachShader (program, vertexShader);
+    glAttachShader (program, fragmentShader);
+    glLinkProgram (program);
+    glDeleteShader (vertexShader);
+    glDeleteShader (fragmentShader);
+    return program;
+}
+
+// assets/shaders/hdr_downsample.frag
+const char* kDownsample = R"(
+in vec2 v_TexCoord;
+out vec4 fragColor;
+uniform sampler2D g_Texture0;
+uniform vec4 g_RenderVar0;
+uniform float g_BloomStrength;
+uniform vec4 g_BloomBlendParams;
+uniform vec3 g_BloomTint;
+uniform float g_BloomScatter;
+
+#if BICUBIC
+vec4 cubic (float v) {
+    vec4 n = vec4 (1.0, 2.0, 3.0, 4.0) - v;
+    vec4 s = n * n * n;
+    float x = s.x;
+    float y = s.y - 4.0 * s.x;
+    float z = s.z - 4.0 * s.y + 6.0 * s.x;
+    float w = 6.0 - x - y - z;
+    return vec4 (x, y, z, w) * (1.0 / 6.0);
+}
+
+vec4 textureBicubic (vec2 texCoords) {
+    float sc = 0.5;
+    vec2 texSize = sc / g_RenderVar0.xy;
+    vec2 invTexSize = g_RenderVar0.xy / sc;
+    texCoords = texCoords * texSize - 0.5;
+    vec2 fxy = fract (texCoords);
+    texCoords -= fxy;
+    vec4 xcubic = cubic (fxy.x);
+    vec4 ycubic = cubic (fxy.y);
+    vec4 c = texCoords.xxyy + vec2 (-0.5, +1.5).xyxy;
+    vec4 s = vec4 (xcubic.xz + xcubic.yw, ycubic.xz + ycubic.yw);
+    vec4 offset = c + vec4 (xcubic.yw, ycubic.yw) / s;
+    offset *= invTexSize.xxyy;
+    vec4 sample0 = texture (g_Texture0, offset.xz);
+    vec4 sample1 = texture (g_Texture0, offset.yz);
+    vec4 sample2 = texture (g_Texture0, offset.xw);
+    vec4 sample3 = texture (g_Texture0, offset.yw);
+    float sx = s.x / (s.x + s.y);
+    float sy = s.z / (s.z + s.w);
+    return mix (mix (sample3, sample2, sx), mix (sample1, sample0, sx), sy);
+}
+#define SAMPLE(uv) textureBicubic (uv)
+#else
+#define SAMPLE(uv) texture (g_Texture0, uv)
+#endif
+
+void main () {
+    vec3 albedo = SAMPLE (v_TexCoord + g_RenderVar0.xy).rgb + SAMPLE (v_TexCoord + g_RenderVar0.zy).rgb
+	+ SAMPLE (v_TexCoord + g_RenderVar0.xw).rgb + SAMPLE (v_TexCoord + g_RenderVar0.zw).rgb;
+#if UPSAMPLE
+    albedo *= 0.25 * g_BloomScatter;
+#else
+    albedo *= 0.25;
+#endif
+#if BLOOM
+    albedo = max (vec3 (0.0), albedo);
+    float brightness = max (albedo.r, max (albedo.g, albedo.b));
+    float soft = brightness - g_BloomBlendParams.y;
+    soft = clamp (soft, 0.0, g_BloomBlendParams.z);
+    soft = soft * soft * g_BloomBlendParams.w;
+    float contribution = max (soft, brightness - g_BloomBlendParams.x);
+    contribution /= max (brightness, 0.00001);
+    albedo *= contribution * g_BloomStrength * g_BloomTint;
+#endif
+    fragColor = vec4 (albedo, 1.0);
+}
+)";
+
+// assets/shaders/combine_hdr.frag (combine_hdr_upsample): the output is the linear color on an sRGB back buffer,
+// which comes back as the clamped sum
+const char* kCombine = R"(
+in vec2 v_TexCoord;
+out vec4 fragColor;
+uniform sampler2D g_Texture0;
+uniform sampler2D g_Texture1;
+uniform vec2 g_TexelSize;
+
+void main () {
+    vec3 albedo = texture (g_Texture0, v_TexCoord).rgb;
+    vec3 bloom = texture (g_Texture1, v_TexCoord + g_TexelSize).rgb + texture (g_Texture1, v_TexCoord - g_TexelSize).rgb
+	+ texture (g_Texture1, v_TexCoord + vec2 (g_TexelSize.x, -g_TexelSize.y)).rgb
+	+ texture (g_Texture1, v_TexCoord + vec2 (-g_TexelSize.x, g_TexelSize.y)).rgb;
+    fragColor = vec4 (clamp (albedo + bloom * 0.25, 0.0, 1.0), 1.0);
+}
+)";
+
+void allocateLevel (auto& level, const glm::ivec2 size) {
+    level.size = size;
+    glGenTextures (1, &level.texture);
+    glBindTexture (GL_TEXTURE_2D, level.texture);
+    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA16F, size.x, size.y, 0, GL_RGBA, GL_HALF_FLOAT, nullptr);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glTexParameteri (GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+    glGenFramebuffers (1, &level.framebuffer);
+    glBindFramebuffer (GL_FRAMEBUFFER, level.framebuffer);
+    glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, level.texture, 0);
+}
+
+void releaseLevel (auto& level) {
+    glDeleteFramebuffers (1, &level.framebuffer);
+    glDeleteTextures (1, &level.texture);
+    level = {};
+}
+} // namespace
+
+void CScene::releaseHDRBloom () {
+    for (auto& level : this->m_bloomLevels) {
+	releaseLevel (level);
+    }
+
+    this->m_bloomLevels.clear ();
+
+    if (this->m_hdrCopy.texture != GL_NONE) {
+	releaseLevel (this->m_hdrCopy);
+    }
+}
+
+glm::ivec2 CScene::getOutputResolution () const {
+    const auto [ustart, uend, vstart, vend] = this->getState ().getTextureUVs ();
+    const int viewportWidth = std::max (this->getState ().getViewportWidth (), 1);
+    const int viewportHeight = std::max (this->getState ().getViewportHeight (), 1);
+    const float coverU = std::abs (uend - ustart) > 0.0f ? std::abs (uend - ustart) : 1.0f;
+    const float coverV = std::abs (vend - vstart) > 0.0f ? std::abs (vend - vstart) : 1.0f;
+
+    return {
+	std::max (1, static_cast<int> (static_cast<float> (viewportWidth) / coverU)),
+	std::max (1, static_cast<int> (static_cast<float> (viewportHeight) / coverV))
+    };
+}
+
+glm::mat4 CScene::getWorldViewProjection () const {
+    const auto& camera = this->getCamera ();
+
+    if (!camera.isOrthogonal ()) {
+	return camera.getPerspective () * camera.getView ();
+    }
+
+    const glm::vec3 center (this->getWidth () * 0.5f, this->getHeight () * 0.5f, 0.0f);
+
+    return camera.getProjection () * camera.getLookAt () * glm::scale (glm::mat4 (1.0f), glm::vec3 (1.0f, -1.0f, 1.0f))
+	* glm::translate (glm::mat4 (1.0f), -center);
+}
+
+void CScene::renderHDRBloom () {
+    if (this->m_bloomDownsample == GL_NONE) {
+	this->m_bloomDownsample = buildProgram ("", kDownsample);
+	this->m_bloomDownsampleThreshold = buildProgram ("#define BLOOM 1\n", kDownsample);
+	this->m_bloomUpsample = buildProgram ("#define UPSAMPLE 1\n", kDownsample);
+	this->m_bloomUpsampleCubic = buildProgram ("#define UPSAMPLE 1\n#define BICUBIC 1\n", kDownsample);
+	this->m_bloomCombine = buildProgram ("", kCombine);
+    }
+
+    // WE renders at the output's resolution and its chain starts at half of that: here the scene buffer is cut
+    // down to the output later, so the chain is sized by what the whole scene measures in output pixels
+    const glm::ivec2 resolution = this->getOutputResolution ();
+    const int viewportWidth = std::max (this->getState ().getViewportWidth (), 1);
+    const int viewportHeight = std::max (this->getState ().getViewportHeight (), 1);
+
+    // levels: halvings of the output's smaller side, at most "bloomhdriterations" and the 8 buffers WE creates
+    int levels = 0;
+
+    for (int side = std::min (viewportWidth, viewportHeight) / 2; side > 0; side /= 2) {
+	levels++;
+    }
+
+    const auto& bloom = this->getScene ().camera.bloom;
+    levels = std::clamp (std::min (levels, bloom.hdrIterations->value->getInt ()), 1, 8);
+
+    if (resolution != this->m_bloomResolution || static_cast<int> (this->m_bloomLevels.size ()) != levels) {
+	this->releaseHDRBloom ();
+	this->m_bloomResolution = resolution;
+
+	for (int level = 0; level < levels; level++) {
+	    auto& entry = this->m_bloomLevels.emplace_back ();
+	    allocateLevel (entry, glm::max (resolution / (2 << level), glm::ivec2 (1)));
+	}
+
+	allocateLevel (this->m_hdrCopy, { this->m_sceneFBO->getRealWidth (), this->m_sceneFBO->getRealHeight () });
+    }
+
+    const float strength = bloom.hdrStrength->value->getFloat ();
+    const float threshold = bloom.hdrThreshold->value->getFloat ();
+    const float feather = bloom.hdrFeather->value->getFloat ();
+    const float scatter = bloom.hdrScatter->value->getFloat ();
+    const glm::vec3 tint = bloom.tint->value->getVec3 ();
+    // sub_140184020
+    const float scaledStrength = strength / (std::pow (scatter, static_cast<float> (std::max (levels, 2) - 2)) + 1.0f);
+    const float knee = threshold * feather;
+    const glm::vec4 blend (threshold, threshold - knee, knee + knee, 0.25f / (knee + 0.0000099999997f));
+    const glm::vec2 texel (1.0f / static_cast<float> (resolution.x), 1.0f / static_cast<float> (resolution.y));
+
+    const auto draw = [&] (GLuint program, const BloomLevel& target, GLuint source, float step) {
+	glBindFramebuffer (GL_FRAMEBUFFER, target.framebuffer);
+	glViewport (0, 0, target.size.x, target.size.y);
+	glUseProgram (program);
+	glActiveTexture (GL_TEXTURE0);
+	glBindTexture (GL_TEXTURE_2D, source);
+	glUniform1i (glGetUniformLocation (program, "g_Texture0"), 0);
+	glUniform4f (
+	    glGetUniformLocation (program, "g_RenderVar0"), texel.x * step, texel.y * step, -texel.x * step,
+	    -texel.y * step
+	);
+	glUniform1f (glGetUniformLocation (program, "g_BloomStrength"), scaledStrength);
+	glUniform4fv (glGetUniformLocation (program, "g_BloomBlendParams"), 1, &blend.x);
+	glUniform3fv (glGetUniformLocation (program, "g_BloomTint"), 1, &tint.x);
+	glUniform1f (glGetUniformLocation (program, "g_BloomScatter"), scatter);
+	glDrawArrays (GL_TRIANGLES, 0, 3);
+    };
+
+    glBindVertexArray (this->m_vaoBuffer);
+    glDisable (GL_DEPTH_TEST);
+    glDisable (GL_CULL_FACE);
+    glDisable (GL_BLEND);
+    glColorMask (true, true, true, true);
+
+    // sub_140183610: threshold into the first level, halve down the chain, then add each level back onto the one
+    // above it, the last two of those with the bicubic filter
+    draw (this->m_bloomDownsampleThreshold, this->m_bloomLevels[0], this->m_sceneFBO->getTextureID (0), 1.0f);
+
+    for (int level = 1; level < levels; level++) {
+	draw (this->m_bloomDownsample, this->m_bloomLevels[level], this->m_bloomLevels[level - 1].texture,
+	      static_cast<float> (1 << level));
+    }
+
+    glEnable (GL_BLEND);
+    glBlendFunc (GL_ONE, GL_ONE);
+
+    for (int level = levels - 1; level > 0; level--) {
+	draw (level < levels - 2 ? this->m_bloomUpsample : this->m_bloomUpsampleCubic, this->m_bloomLevels[level - 1],
+	      this->m_bloomLevels[level].texture, static_cast<float> (2 << (level - 1)));
+    }
+
+    glDisable (GL_BLEND);
+
+    // combine_hdr_upsample reads the scene, so it gets a copy of it and writes back into the scene buffer
+    const GLuint sceneFramebuffer = this->m_sceneFBO->getFramebuffer ();
+    const int width = this->m_sceneFBO->getRealWidth ();
+    const int height = this->m_sceneFBO->getRealHeight ();
+    glBindFramebuffer (GL_READ_FRAMEBUFFER, sceneFramebuffer);
+    glBindFramebuffer (GL_DRAW_FRAMEBUFFER, this->m_hdrCopy.framebuffer);
+    glBlitFramebuffer (0, 0, width, height, 0, 0, width, height, GL_COLOR_BUFFER_BIT, GL_NEAREST);
+
+    glBindFramebuffer (GL_FRAMEBUFFER, sceneFramebuffer);
+    glViewport (0, 0, width, height);
+    glUseProgram (this->m_bloomCombine);
+    glActiveTexture (GL_TEXTURE0);
+    glBindTexture (GL_TEXTURE_2D, this->m_hdrCopy.texture);
+    glActiveTexture (GL_TEXTURE1);
+    glBindTexture (GL_TEXTURE_2D, this->m_bloomLevels[0].texture);
+    glActiveTexture (GL_TEXTURE0);
+    glUniform1i (glGetUniformLocation (this->m_bloomCombine, "g_Texture0"), 0);
+    glUniform1i (glGetUniformLocation (this->m_bloomCombine, "g_Texture1"), 1);
+    glUniform2f (glGetUniformLocation (this->m_bloomCombine, "g_TexelSize"), texel.x, texel.y);
+    glDrawArrays (GL_TRIANGLES, 0, 3);
+}
+
+void CScene::renderCameraFade () {
+    if (this->m_fadeProgram == GL_NONE) {
+	// materials/util/fade.json: flat color * 0.7 at the fade's alpha over the whole frame
+	const char* vertex = "#version 330\n"
+			     "void main () {\n"
+			     "vec2 corner = vec2 ((gl_VertexID << 1) & 2, gl_VertexID & 2);\n"
+			     "gl_Position = vec4 (corner * 2.0 - 1.0, 0.0, 1.0);\n"
+			     "}";
+	const char* fragment = "#version 330\n"
+			       "uniform vec3 color;\n"
+			       "uniform float g_Alpha;\n"
+			       "out vec4 fragColor;\n"
+			       "void main () { fragColor = vec4 (color * 0.7, g_Alpha); }";
+	const GLuint vertexShader = glCreateShader (GL_VERTEX_SHADER);
+	const GLuint fragmentShader = glCreateShader (GL_FRAGMENT_SHADER);
+	glShaderSource (vertexShader, 1, &vertex, nullptr);
+	glShaderSource (fragmentShader, 1, &fragment, nullptr);
+	glCompileShader (vertexShader);
+	glCompileShader (fragmentShader);
+	this->m_fadeProgram = glCreateProgram ();
+	glAttachShader (this->m_fadeProgram, vertexShader);
+	glAttachShader (this->m_fadeProgram, fragmentShader);
+	glLinkProgram (this->m_fadeProgram);
+	glDeleteShader (vertexShader);
+	glDeleteShader (fragmentShader);
+    }
+
+    // the shader's tint comes from the project's schemecolor ("usershadervalues"), its default otherwise
+    glm::vec3 color (0.315f, 0.135f, 0.1125f);
+    const auto& properties = this->getUserProperties ();
+
+    if (const auto scheme = properties.find ("schemecolor"); scheme != properties.end () && scheme->second != nullptr) {
+	color = scheme->second->getVec3 ();
+    }
+
+    glBindVertexArray (this->m_vaoBuffer);
+    glUseProgram (this->m_fadeProgram);
+    glUniform3fv (glGetUniformLocation (this->m_fadeProgram, "color"), 1, &color.x);
+    glUniform1f (glGetUniformLocation (this->m_fadeProgram, "g_Alpha"), std::min (this->m_cameraFade, 1.0f));
+    glDisable (GL_DEPTH_TEST);
+    glDisable (GL_CULL_FACE);
+    glEnable (GL_BLEND);
+    glBlendFuncSeparate (GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA);
+    glDrawArrays (GL_TRIANGLES, 0, 3);
+}
+
+void CScene::updateParallax () {
+    if (this->getScene ().camera.parallax.enabled->value->getBool ()) {
+	// the wallpaper's own position rides through the same per-layer depth/clamp mechanism as mouse parallax, halved to match its range
+	// X keeps WallpaperState's sign flip, Y does not because this displacement is applied after the Y-up conversion
+	const glm::vec2 positionBias
+	    = { -this->getState ().getOffsetX () * 0.5f, this->getState ().getOffsetY () * 0.5f };
+
+	if (this->getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	    // no mouse contribution left to smooth toward, apply position directly
+	    this->m_parallaxBias = positionBias;
+	    this->m_cameraParallax = { 0.0f, 0.0f };
+	} else {
+	    const float influence = this->getScene ().camera.parallax.mouseInfluence->value->getFloat ();
+	    const float amount = this->getScene ().camera.parallax.amount->value->getFloat ();
+	    const float delay = this->getScene ().camera.parallax.delay->value->getFloat ();
+
+	    this->m_parallaxBias = positionBias * amount * influence;
+
+	    // same easing as the real engine: no delay snaps to the mouse, otherwise a fast exponential follow
+	    // a camera object's eye moves the parallax camera too, before the smoothing (sub_1401891A0)
+	    const glm::vec2 eye = { this->m_cameraEye.x / static_cast<float> (this->getWidth ()),
+				    -this->m_cameraEye.y / static_cast<float> (this->getHeight ()) };
+	    const glm::vec2 target = (this->m_mousePosition - glm::vec2 (0.5f, 0.5f)) * influence + eye;
+	    if (delay <= 0.0f) {
+		this->m_cameraParallax = target;
+	    } else {
+		const float dt = std::max (g_Time - g_TimeLast, 0.0f);
+		const float follow = std::min (1.0f, (1.0f - delay / 3.0f) * 10.0f * dt);
+		this->m_cameraParallax += (target - this->m_cameraParallax) * follow;
+	    }
+	}
+
+	this->m_parallaxPosition = glm::clamp (glm::vec2 (0.5f) + this->m_cameraParallax, 0.0f, 1.0f);
+    }
+}
+
+glm::mat4 CScene::objectWorldMatrix (const Object& object) const {
+    const auto local = [] (const Object& current) {
+	const glm::vec3 angles = current.groupAngles->value->getVec3 ();
+	glm::mat4 transform = glm::translate (glm::mat4 (1.0f), current.origin->value->getVec3 ());
+	transform = glm::rotate (transform, angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
+	transform = glm::rotate (transform, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
+	transform = glm::rotate (transform, angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
+	return glm::scale (transform, current.groupScale->value->getVec3 ());
+    };
+
+    glm::mat4 world = local (object);
+    const Object* current = &object;
+
+    for (int depth = 0; current->parent.has_value () && depth < 64; depth++) {
+	const CObject* parent = this->getObject (current->parent.value ());
+
+	if (parent == nullptr) {
+	    break;
+	}
+
+	current = &parent->getObject ();
+	world = local (*current) * world;
+    }
+
+    return world;
+}
+
+void CScene::updateCameraPath (const float dt, glm::vec3& eye, glm::vec3& center, glm::vec3& up, float& zoom) {
+    // sub_1401891A0: the keys of one path at a time, cubic Hermite between two keys with tangents of half their
+    // difference, then the next path once the last key is done
+    const auto& paths = this->getScene ().camera.paths;
+    this->m_pathIndex %= static_cast<int> (paths.size ());
+    const auto& path = paths[this->m_pathIndex];
+
+    if (path.keys.empty ()) {
+	this->m_pathIndex = (this->m_pathIndex + 1) % static_cast<int> (paths.size ());
+	return;
+    }
+
+    const int count = static_cast<int> (path.keys.size ());
+    this->m_pathKey = std::min (this->m_pathKey, count - 1);
+    const auto& key = path.keys[this->m_pathKey];
+    const float time = this->m_pathTime;
+    float until;
+
+    eye = key.eye;
+    center = key.center;
+    up = key.up;
+    zoom = key.zoom;
+
+    if (time < key.time) {
+	until = (this->m_pathKey + 1 < count ? path.keys[this->m_pathKey + 1].time : 0.0f) + key.time;
+    } else if (this->m_pathKey + 1 < count) {
+	const auto& next = path.keys[this->m_pathKey + 1];
+	const float s = (time - key.time) / (next.time - key.time);
+	const float s2 = s * s;
+	const float s3 = s2 * s;
+	const float h00 = 2.0f * s3 - 3.0f * s2 + 1.0f;
+	const float h10 = s3 - 2.0f * s2 + s;
+	const float h01 = 3.0f * s2 - 2.0f * s3;
+	const float h11 = s3 - s2;
+	const auto hermite = [&] (const auto& a, const auto& b) {
+	    const auto tangent = (b - a) * 0.5f;
+	    return a * h00 + tangent * h10 + b * h01 + tangent * h11;
+	};
+
+	eye = hermite (key.eye, next.eye);
+	center = hermite (key.center, next.center);
+	up = hermite (key.up, next.up);
+	zoom = hermite (key.zoom, next.zoom);
+	until = next.time;
+    } else {
+	until = path.duration - key.time;
+    }
+
+    this->m_pathTime = time + dt;
+
+    if (this->m_pathTime > until) {
+	if (this->m_pathKey + 1 >= count || path.duration <= path.keys[this->m_pathKey + 1].time) {
+	    this->m_pathIndex = (this->m_pathIndex + 1) % static_cast<int> (paths.size ());
+	    this->m_pathKey = 0;
+	    this->m_pathTime = 0.0f;
+	} else {
+	    this->m_pathKey++;
+	}
+    }
+
+    // camerafade darkens the first and last half second of every path (sub_14017FA70)
+    if (this->getScene ().camera.fade->value->getBool ()) {
+	const auto& current = paths[this->m_pathIndex];
+	const float left = current.duration - this->m_pathTime;
+
+	if (left < 0.5f) {
+	    this->m_cameraFade = 1.0f - 2.0f * left;
+	} else if (left > current.duration - 0.5f) {
+	    this->m_cameraFade = 1.0f - 2.0f * (current.duration - left);
+	}
+    }
+}
+
+void CScene::updateFog (const glm::vec3& eye) {
+    const auto& fog = this->getScene ().fog;
+    const auto params = [] (const auto& start, const auto& end, const auto& startDensity, const auto& endDensity) {
+	const float from = start->value->getFloat ();
+	const float density = startDensity->value->getFloat ();
+	return glm::vec4 (from, end->value->getFloat () - from, density, endDensity->value->getFloat () - density);
+    };
+
+    this->m_fog.distanceColor = fog.distanceColor->value->getVec3 ();
+    this->m_fog.distanceParams
+	= params (fog.distanceStart, fog.distanceEnd, fog.distanceStartDensity, fog.distanceEndDensity);
+    this->m_fog.heightColor = fog.heightColor->value->getVec3 ();
+    this->m_fog.heightParamsWorld = params (fog.heightStart, fog.heightEnd, fog.heightStartDensity, fog.heightEndDensity);
+    this->m_fog.heightParamsLocal = this->m_fog.heightParamsWorld;
+    this->m_fog.eyeWorld = eye;
+    this->m_fog.eyeLocal = eye;
+
+    if (!this->m_camera->isPerspective ()) {
+	// 2D scenes: WE's eye sits 2000 units out over the scene center plus the camera (end of sub_1401891A0), and
+	// a height of y here is H/2 - y there
+	const float height = static_cast<float> (this->getHeight ());
+	this->m_fog.eyeWorld
+	    = glm::vec3 (static_cast<float> (this->getWidth ()) * 0.5f + eye.x, height * 0.5f + eye.y, 2000.0f);
+	this->m_fog.eyeLocal = glm::vec3 (eye.x, -eye.y, 2000.0f);
+	this->m_fog.heightParamsLocal.x = height * 0.5f - this->m_fog.heightParamsWorld.x;
+	this->m_fog.heightParamsLocal.y = -this->m_fog.heightParamsWorld.y;
+    }
+}
+
+void CScene::updateCamera () {
+    const auto& appContext = this->getContext ().getApp ().getContext ();
+    Objects::CCamera* active = nullptr;
+
+    for (CObject* object : this->m_sceneCameras) {
+	const auto override = appContext.resolveObjectVisibility (object->getId (), object->getObject ().name);
+
+	if (override.value_or (object->getObject ().groupVisible->value->getBool ()) && !this->isHiddenByAncestor (*object)) {
+	    active = object->as<Objects::CCamera> ();
+	}
+    }
+
+    const float dt = std::max (g_Time - g_TimeLast, 0.0f);
+
+    // 2D scenes start from the reset camera WE uses without a camera object, 3D ones from scene.json
+    const bool perspective = this->m_camera->isPerspective ();
+    glm::vec3 eye = perspective ? this->m_camera->getConfiguredEye () : glm::vec3 (0.0f);
+    glm::vec3 center = perspective ? this->m_camera->getCenter () : glm::vec3 (0.0f, 0.0f, -1.0f);
+    glm::vec3 up = perspective ? this->m_camera->getUp () : glm::vec3 (0.0f, 1.0f, 0.0f);
+    float fov = this->m_camera->getFov ();
+    float zoom = 1.0f;
+    this->m_cameraFade = 0.0f;
+
+    if (active != nullptr) {
+	glm::mat4 world = this->objectWorldMatrix (active->getObject ());
+	zoom = active->getCamera ().zoom->value->getFloat ();
+	fov = active->getCamera ().fov->value->getFloat ();
+
+	// a camera path file drives the camera's transform, zoom and fov (sub_1401F2AD0)
+	const auto& data = active->getObject ();
+	const CObject* parent = data.parent.has_value () ? this->getObject (data.parent.value ()) : nullptr;
+	const glm::mat4 parentWorld = parent != nullptr ? this->objectWorldMatrix (parent->getObject ()) : glm::mat4 (1.0f);
+
+	if (const auto pose = active->updateTimeline (dt, world, parentWorld); pose.has_value ()) {
+	    world = pose->world;
+	    zoom = pose->zoom;
+	    fov = pose->fov;
+	}
+
+	// eye at the camera's position, looking down its -z axis, its y axis up
+	eye = glm::vec3 (world[3]);
+	center = eye - glm::vec3 (world[2]);
+	up = glm::vec3 (world[1]);
+    } else if (!this->getScene ().camera.paths.empty ()) {
+	this->updateCameraPath (dt, eye, center, up, zoom);
+    }
+
+    if (this->getScene ().camera.shake.enabled->value->getBool ()) {
+	// sub_140199580: speed and roughness shape a cos/sin wobble on the scene clock, it moves eye and center alike
+	const auto& shake = this->getScene ().camera.shake;
+	const float roughness = std::pow (shake.roughness->value->getFloat (), 3.0f);
+	const float speed = shake.speed->value->getFloat ();
+	const float time = speed * speed * this->getSceneClock ();
+	glm::vec3 offset (std::cos (time), std::sin (time * 1.333f), perspective ? std::sin (time) : 0.0f);
+	float amplitude = shake.amplitude->value->getFloat () * 0.1f;
+
+	if (!perspective) {
+	    amplitude *= static_cast<float> (this->getHeight ()) * 0.1f;
+	}
+
+	if (roughness > 0.001f && roughness != 1.0f) {
+	    const float length = glm::length (offset);
+	    offset *= std::pow (length, roughness) / length;
+	}
+
+	eye += offset * amplitude;
+	center += offset * amplitude;
+    }
+
+    const glm::mat4 view = glm::lookAt (eye, center, up);
+    this->m_cameraEye = perspective ? glm::vec2 (0.0f) : glm::vec2 (eye);
+    this->updateFog (eye);
+
+    if (perspective) {
+	this->m_camera->setPerspectiveView (view, fov);
+	return;
+    }
+
+    this->m_camera->setWorldView (view);
+    this->m_camera->setZoom (this->getScene ().camera.projection.zoom->value->getFloat () * zoom);
+
+    const auto [ustart, uend, vstart, vend] = this->getState ().getTextureUVs ();
+    const int viewportWidth = this->getState ().getViewportWidth ();
+    const int viewportHeight = this->getState ().getViewportHeight ();
+    const float aspect = viewportWidth > 0 && viewportHeight > 0
+	? static_cast<float> (viewportWidth) / static_cast<float> (viewportHeight)
+	: static_cast<float> (this->getCanvasWidth ()) / static_cast<float> (this->getCanvasHeight ());
+
+    this->m_camera->updatePerspectiveLayers ({ ustart, uend, vstart, vend }, aspect);
 }
 }
 
 
 void CScene::dispatchCursorEvents () {
 void CScene::dispatchCursorEvents () {
@@ -457,55 +1101,84 @@ void CScene::dispatchCursorEvents () {
     auto& engine = this->getScriptEngine ();
     auto& engine = this->getScriptEngine ();
     // handlers are free to create layers, which would move things around under a live iteration
     // handlers are free to create layers, which would move things around under a live iteration
     const auto objects = this->m_objectsByRenderOrder;
     const auto objects = this->m_objectsByRenderOrder;
+    // WE (sub_140189E10) checks for a drag before the pass: while the button is held on something pressed,
+    // only the pressed objects hear about it, and only through cursorMove
+    const bool dragging = down && !this->m_cursorPressed.empty ();
 
 
-    for (auto* cur : objects) {
-	if (!cur->is<Objects::CImage> ()) {
+    // topmost first, like WE. Hidden objects are hit tested and get events too, they just can't stop propagation
+    for (auto it = objects.rbegin (); it != objects.rend (); ++it) {
+	auto* cur = *it;
+
+	if (!cur->getObject ().solid->value->getBool () || !cur->is<Objects::CImage> ()) {
 	    continue;
 	    continue;
 	}
 	}
 
 
 	auto* image = cur->as<Objects::CImage> ();
 	auto* image = cur->as<Objects::CImage> ();
+	const int id = image->getImage ().id;
+	const bool handlers = engine.hasCursorHandlers (*image);
+
+	image->refreshScenePosition ();
+
+	const glm::vec2 local = scenePosition - image->getSceneCenter ();
+	const auto dispatch = [&] (const char* event) {
+	    if (handlers) {
+		engine.dispatchCursorEvent (event, *image, scenePosition, local);
+	    }
+	};
+
+	if (dragging) {
+	    if (moved && this->m_cursorPressed.contains (id)) {
+		dispatch ("cursorMove");
+	    }
 
 
-	if (!engine.hasCursorHandlers (*image)) {
 	    continue;
 	    continue;
 	}
 	}
 
 
-	image->refreshScenePosition ();
+	if (!image->containsScenePoint (scenePosition)) {
+	    if (released && this->m_cursorPressed.contains (id)) {
+		dispatch ("cursorUp");
+	    }
 
 
-	const int id = image->getImage ().id;
-	const bool inside = image->getImage ().visible->value->getBool () && !this->isHiddenByAncestor (*cur)
-	    && image->containsScenePoint (scenePosition);
-	const bool wasInside = this->m_cursorInside.contains (id);
-	const glm::vec2 local = scenePosition - image->getSceneCenter ();
+	    if (this->m_cursorInside.erase (id) > 0) {
+		dispatch ("cursorLeave");
+	    }
 
 
-	if (inside && !wasInside) {
-	    this->m_cursorInside.insert (id);
-	    engine.dispatchCursorEvent ("cursorEnter", *image, scenePosition, local);
-	} else if (!inside && wasInside) {
-	    this->m_cursorInside.erase (id);
-	    engine.dispatchCursorEvent ("cursorLeave", *image, scenePosition, local);
+	    continue;
 	}
 	}
 
 
-	if (inside && moved) {
-	    engine.dispatchCursorEvent ("cursorMove", *image, scenePosition, local);
+	if (this->m_cursorInside.insert (id).second) {
+	    dispatch ("cursorEnter");
 	}
 	}
 
 
-	if (pressed && inside) {
-	    this->m_cursorPressed.insert (id);
-	    engine.dispatchCursorEvent ("cursorDown", *image, scenePosition, local);
+	if (moved) {
+	    dispatch ("cursorMove");
 	}
 	}
 
 
-	if (released) {
-	    const bool clicked = this->m_cursorPressed.erase (id) > 0 && inside;
+	if (pressed) {
+	    dispatch ("cursorDown");
+	    this->m_cursorPressed.insert (id);
+	} else if (released) {
+	    dispatch ("cursorUp");
 
 
-	    if (inside) {
-		engine.dispatchCursorEvent ("cursorUp", *image, scenePosition, local);
+	    if (this->m_cursorPressed.contains (id)) {
+		dispatch ("cursorClick");
 	    }
 	    }
+	}
 
 
-	    if (clicked) {
-		engine.dispatchCursorEvent ("cursorClick", *image, scenePosition, local);
-	    }
+	if (!cur->getObject ().disablePropagation->value->getBool ()) {
+	    continue;
+	}
+
+	const auto visibility = this->getContext ().getApp ().getContext ().resolveObjectVisibility (id, image->getImage ().name);
+
+	if (visibility.value_or (image->getImage ().visible->value->getBool ()) && !this->isHiddenByAncestor (*cur)) {
+	    break;
 	}
 	}
     }
     }
+
+    if (!down) {
+	this->m_cursorPressed.clear ();
+    }
 }
 }
 
 
 void CScene::updateMouse (const glm::ivec4& viewport) {
 void CScene::updateMouse (const glm::ivec4& viewport) {
@@ -514,6 +1187,12 @@ void CScene::updateMouse (const glm::ivec4& viewport) {
     this->m_mousePositionLast = this->m_mousePosition;
     this->m_mousePositionLast = this->m_mousePosition;
 
 
     double mouseX = glm::clamp ((position.x - viewport.x) / viewport.z, 0.0, 1.0);
     double mouseX = glm::clamp ((position.x - viewport.x) / viewport.z, 0.0, 1.0);
+
+    // WE flips by mirroring its projection, so the cursor lands on what is shown under it
+    if (this->isFlippedHorizontally ()) {
+	mouseX = 1.0 - mouseX;
+    }
+
     // OpenGL convention (0=bottom, 1=top) - particle code expects 0=bottom as negative Y (down)
     // OpenGL convention (0=bottom, 1=top) - particle code expects 0=bottom as negative Y (down)
     double normalizedMouseY = glm::clamp ((position.y - viewport.y) / viewport.w, 0.0, 1.0);
     double normalizedMouseY = glm::clamp ((position.y - viewport.y) / viewport.w, 0.0, 1.0);
 
 
@@ -597,6 +1276,11 @@ float CScene::getTime () const { return g_Time; }
 
 
 float CScene::getDeltaTime () const { return g_Time - g_TimeLast; }
 float CScene::getDeltaTime () const { return g_Time - g_TimeLast; }
 
 
+float CScene::getSceneClock () const {
+    // wallpaper64.exe sub_14017FA70 starts over at 0 once it passes 432000 (the frame crossing it is dropped there)
+    return std::fmod (std::max (g_Time - this->m_startTime, 0.0f), 432000.0f);
+}
+
 float CScene::getFps () const {
 float CScene::getFps () const {
     const float dt = g_Time - g_TimeLast;
     const float dt = g_Time - g_TimeLast;
     // avoids a division by zero / bogus fps on the first frame, where g_TimeLast is still 0
     // avoids a division by zero / bogus fps on the first frame, where g_TimeLast is still 0
@@ -717,6 +1401,8 @@ void CScene::updateLights () {
 	positions[slot] = data.origin->value->getVec3 ();
 	positions[slot] = data.origin->value->getVec3 ();
     }
     }
 
 
+    std::ranges::copy (colors, this->m_lightsColorRadius);
+
     // radiance is color / distance^2 in the shader, so the color is scaled by radius^2; the fourth light's
     // radiance is color / distance^2 in the shader, so the color is scaled by radius^2; the fourth light's
     // color rides in the .w of the other three
     // color rides in the .w of the other three
     for (int i = 0; i < 3; i++) {
     for (int i = 0; i < 3; i++) {

+ 73 - 0
src/WallpaperEngine/Render/Wallpapers/CScene.h

@@ -3,6 +3,7 @@
 #include "WallpaperEngine/Render/Camera.h"
 #include "WallpaperEngine/Render/Camera.h"
 
 
 #include "WallpaperEngine/Render/CWallpaper.h"
 #include "WallpaperEngine/Render/CWallpaper.h"
+#include "WallpaperEngine/Render/Volumetrics.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 
 
 #include <set>
 #include <set>
@@ -41,6 +42,8 @@ public:
     [[nodiscard]] float getTime () const;
     [[nodiscard]] float getTime () const;
     [[nodiscard]] float getDeltaTime () const;
     [[nodiscard]] float getDeltaTime () const;
     [[nodiscard]] float getFps () const;
     [[nodiscard]] float getFps () const;
+    /** Seconds since the scene was loaded, what wallpaper64.exe keeps in its renderer (+320) and resets past 432000 */
+    [[nodiscard]] float getSceneClock () const;
 
 
     const glm::vec2* getMousePosition () const;
     const glm::vec2* getMousePosition () const;
     const glm::vec2* getMousePositionLast () const;
     const glm::vec2* getMousePositionLast () const;
@@ -56,6 +59,30 @@ public:
     [[nodiscard]] glm::vec2 getParallaxOffset (const Data::Model::Object& object) const;
     [[nodiscard]] glm::vec2 getParallaxOffset (const Data::Model::Object& object) const;
 
 
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
     [[nodiscard]] const std::vector<CObject*>& getObjectsByRenderOrder () const;
+    /** g_Fog* uniforms (sub_140186440). Height params come in two versions: WE's world (y up from the bottom) and
+     *  the space 2D objects are laid out in here (y down from the center), the same in 3D scenes */
+    struct FogUniforms {
+	glm::vec3 distanceColor { 0.0f };
+	glm::vec4 distanceParams { 0.0f };
+	glm::vec3 heightColor { 0.0f };
+	glm::vec4 heightParamsWorld { 0.0f };
+	glm::vec4 heightParamsLocal { 0.0f };
+	/** g_EyePosition in WE's world, what the fog of passes drawing in world space measures from */
+	glm::vec3 eyeWorld { 0.0f };
+	/** The same eye in the space 2D objects are laid out in */
+	glm::vec3 eyeLocal { 0.0f };
+    };
+    /** HDR scene rendering (WE's renderer flag 0x2000): float buffers, HDR define, object brightness, HDR bloom */
+    [[nodiscard]] bool isHDR () const { return this->m_hdr; }
+    [[nodiscard]] bool hasDistanceFog () const { return this->m_fogDistance; }
+    [[nodiscard]] bool hasHeightFog () const { return this->m_fogHeight; }
+    [[nodiscard]] const FogUniforms& getFog () const { return this->m_fog; }
+    /** Size the whole scene would have at the output's scale, what WE's render targets are sized by */
+    [[nodiscard]] glm::ivec2 getOutputResolution () const;
+    /** WE's world (scene units, y up from the bottom left in 2D) to clip space of the scene buffer */
+    [[nodiscard]] glm::mat4 getWorldViewProjection () const;
+    /** Position of the active camera object in WE world units (y up, relative to the default camera), zero without one */
+    [[nodiscard]] const glm::vec2& getCameraEye () const { return this->m_cameraEye; }
     [[nodiscard]] const CObject* getObject (int id) const;
     [[nodiscard]] const CObject* getObject (int id) const;
     [[nodiscard]] CObject* getObject (int id);
     [[nodiscard]] CObject* getObject (int id);
     /** True when any group above the object (through "parent") is hidden */
     /** True when any group above the object (through "parent") is hidden */
@@ -64,6 +91,8 @@ public:
     /** The four old-style light slots as genericimage2 wants them (g_LightsPosition, g_LightsColorPremultiplied) */
     /** The four old-style light slots as genericimage2 wants them (g_LightsPosition, g_LightsColorPremultiplied) */
     [[nodiscard]] const glm::vec3* getLightsPosition () const { return this->m_lightsPosition; }
     [[nodiscard]] const glm::vec3* getLightsPosition () const { return this->m_lightsPosition; }
     [[nodiscard]] const glm::vec4* getLightsColorPremultiplied () const { return this->m_lightsColorPremultiplied; }
     [[nodiscard]] const glm::vec4* getLightsColorPremultiplied () const { return this->m_lightsColorPremultiplied; }
+    /** The same four slots unscaled, (color * intensity, radius), as the 3D shaders read them (g_LightsColorRadius) */
+    [[nodiscard]] const glm::vec4* getLightsColorRadius () const { return this->m_lightsColorRadius; }
     /** The render order as scripts see it: without the synthesized bloom layer */
     /** The render order as scripts see it: without the synthesized bloom layer */
     [[nodiscard]] std::vector<CObject*> getLayers () const;
     [[nodiscard]] std::vector<CObject*> getLayers () const;
     [[nodiscard]] int getObjectIndex (const CObject* object) const;
     [[nodiscard]] int getObjectIndex (const CObject* object) const;
@@ -102,6 +131,20 @@ private:
 
 
     /** Refreshes the light slots from the light objects after scripts ran, like sub_1401D5740 + the 0x5D uniform */
     /** Refreshes the light slots from the light objects after scripts ran, like sub_1401D5740 + the 0x5D uniform */
     void updateLights ();
     void updateLights ();
+    /** Camera object view, parallax camera and perspective layer camera for this frame (sub_1401891A0) */
+    void updateCamera ();
+    /** Scene camera paths without a camera object, advances them by dt and writes this frame's camera */
+    void updateCameraPath (float dt, glm::vec3& eye, glm::vec3& center, glm::vec3& up, float& zoom);
+    /** camerafade overlay over the finished scene */
+    void renderCameraFade ();
+    /** WE's HDR bloom (sub_140183610) and combine_hdr_upsample into the scene buffer */
+    void renderHDRBloom ();
+    void releaseHDRBloom ();
+    void updateFog (const glm::vec3& eye);
+    /** Mouse parallax smoothing, after updateCamera () since a camera object moves the parallax camera too */
+    void updateParallax ();
+    /** T * Rz * Ry * Rx * S down the parent chain, WE's object world matrix (sub_1401850A0) */
+    [[nodiscard]] glm::mat4 objectWorldMatrix (const Object& object) const;
     [[nodiscard]] int nextFreeLightSlot () const;
     [[nodiscard]] int nextFreeLightSlot () const;
 
 
     Render::CObject* createObject (const Object& object);
     Render::CObject* createObject (const Object& object);
@@ -118,6 +161,34 @@ private:
     std::set<int> m_objectsInRenderOrderWalk = {};
     std::set<int> m_objectsInRenderOrderWalk = {};
     std::map<int, bool> m_soundPlayRequests = {};
     std::map<int, bool> m_soundPlayRequests = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
     std::vector<CObject*> m_objectsByRenderOrder = {};
+    /** Camera objects in creation order, the last visible one wins */
+    std::vector<CObject*> m_sceneCameras = {};
+    /** Where the active camera object puts the eye, WE world units (y up), zero without one */
+    glm::vec2 m_cameraEye = {};
+    int m_pathIndex = 0;
+    int m_pathKey = 0;
+    float m_pathTime = 0.0f;
+    float m_cameraFade = 0.0f;
+    FogUniforms m_fog;
+    std::unique_ptr<Volumetrics> m_volumetrics;
+    bool m_fogDistance = false;
+    bool m_fogHeight = false;
+    bool m_hdr = false;
+    /** HDR bloom: mip chain at half the output resolution and down, see renderHDRBloom () */
+    struct BloomLevel {
+	GLuint texture = GL_NONE;
+	GLuint framebuffer = GL_NONE;
+	glm::ivec2 size {};
+    };
+    std::vector<BloomLevel> m_bloomLevels;
+    BloomLevel m_hdrCopy;
+    glm::ivec2 m_bloomResolution {};
+    GLuint m_bloomDownsample = GL_NONE;
+    GLuint m_bloomDownsampleThreshold = GL_NONE;
+    GLuint m_bloomUpsample = GL_NONE;
+    GLuint m_bloomUpsampleCubic = GL_NONE;
+    GLuint m_bloomCombine = GL_NONE;
+    GLuint m_fadeProgram = GL_NONE;
     std::vector<DynamicValue*> m_scriptedValues = {};
     std::vector<DynamicValue*> m_scriptedValues = {};
     // owns the synthesized model data backing createLayer()'d objects; must outlive the CObject
     // owns the synthesized model data backing createLayer()'d objects; must outlive the CObject
     // built from it (same pattern as m_bloomObjectData)
     // built from it (same pattern as m_bloomObjectData)
@@ -133,8 +204,10 @@ private:
     /** Wallpaper position offset (WallpaperState) riding through the parallax path */
     /** Wallpaper position offset (WallpaperState) riding through the parallax path */
     glm::vec2 m_parallaxBias = {};
     glm::vec2 m_parallaxBias = {};
     bool m_cursorLeftDown = false;
     bool m_cursorLeftDown = false;
+    float m_startTime = 0.0f;
     glm::vec3 m_lightsPosition[4] = {};
     glm::vec3 m_lightsPosition[4] = {};
     glm::vec4 m_lightsColorPremultiplied[3] = {};
     glm::vec4 m_lightsColorPremultiplied[3] = {};
+    glm::vec4 m_lightsColorRadius[4] = {};
     glm::vec2 m_cursorLastScenePosition = {};
     glm::vec2 m_cursorLastScenePosition = {};
     // object ids the pointer is over / that the current press started on
     // object ids the pointer is over / that the current press started on
     std::set<int> m_cursorInside = {};
     std::set<int> m_cursorInside = {};

+ 9 - 1
src/WallpaperEngine/Render/Wallpapers/CVideo.cpp

@@ -13,7 +13,11 @@ CVideo::CVideo (
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const Wallpaper& wallpaper, RenderContext& context, AudioContext& audioContext,
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
     const WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& clampMode
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
-    this->setupFramebuffers ();
+    // with --hdr the video keeps its highlights, the output shader turns it into PQ or sRGB per monitor
+    const bool hdr = this->getContext ().getDriver ().isHDRAvailable ();
+
+    this->setupFramebuffers (false, hdr ? TextureFormat_RGBA16161616f : TextureFormat_ARGB8888);
+    this->setLinearInput (hdr);
 
 
     const std::filesystem::path videopath
     const std::filesystem::path videopath
 	= this->getVideo ().project.assetLocator->physicalPath (this->getVideo ().filename);
 	= this->getVideo ().project.assetLocator->physicalPath (this->getVideo ().filename);
@@ -28,6 +32,10 @@ CVideo::CVideo (
     const auto& audioSettings = this->getContext ().getApp ().getContext ().settings.audio;
     const auto& audioSettings = this->getContext ().getApp ().getContext ().settings.audio;
     this->m_player->setVolume (audioSettings.enabled ? audioSettings.volume * 100.0 / 128.0 : 0.0);
     this->m_player->setVolume (audioSettings.enabled ? audioSettings.volume * 100.0 / 128.0 : 0.0);
     this->m_player->setSpeed (this->getContext ().getApp ().getContext ().settings.render.playbackSpeed);
     this->m_player->setSpeed (this->getContext ().getApp ().getContext ().settings.render.playbackSpeed);
+
+    if (hdr) {
+	this->m_player->setLinearOutput ();
+    }
     // needs at least one usage marked for the video to actually start playing
     // needs at least one usage marked for the video to actually start playing
     this->m_player->incrementUsageCount ();
     this->m_player->incrementUsageCount ();
 }
 }

+ 60 - 42
src/WallpaperEngine/Scripting/AnimationSystem.cpp

@@ -66,65 +66,82 @@ float cubicBezier (float p0, float p1, float p2, float p3, float t) {
     const float u = 1.0f - t;
     const float u = 1.0f - t;
     return u * u * u * p0 + 3.0f * u * u * t * p1 + 3.0f * u * t * t * p2 + t * t * t * p3;
     return u * u * u * p0 + 3.0f * u * u * t * p1 + 3.0f * u * t * t * p2 + t * t * t * p3;
 }
 }
+} // namespace
 
 
-float evaluateSegment (const AnimationKeyframe& a, const AnimationKeyframe& b, float frame) {
-    const float span = b.frame - a.frame;
-
-    if (span <= 0.0f) {
-	return b.value;
+float WallpaperEngine::Scripting::sampleAnimationFrame (const std::vector<AnimationKeyframe>& keys, const int frame) {
+    if (keys.empty ()) {
+	return 0.0f;
     }
     }
 
 
-    if (!a.front.enabled || !b.back.enabled) {
-	return a.value + (b.value - a.value) * ((frame - a.frame) / span);
+    if (static_cast<float> (frame) <= keys.front ().frame) {
+	return keys.front ().value;
     }
     }
 
 
-    // handles are offsets from their keyframe; keep the x control points inside the segment so the
-    // curve stays a function of time
-    const float x1 = std::clamp (a.frame + a.front.x, a.frame, b.frame);
-    const float x2 = std::clamp (b.frame + b.back.x, a.frame, b.frame);
-    const float y1 = a.value + a.front.y;
-    const float y2 = b.value + b.back.y;
+    for (size_t i = 1; i < keys.size (); i++) {
+	const auto& previous = keys[i - 1];
+	const auto& current = keys[i];
+	const int start = static_cast<int> (previous.frame);
+	const int end = static_cast<int> (current.frame);
 
 
-    float low = 0.0f;
-    float high = 1.0f;
+	if (frame < start || frame >= end) {
+	    continue;
+	}
 
 
-    for (int i = 0; i < 24; i++) {
-	const float mid = (low + high) * 0.5f;
+	if (start == frame || current.step) {
+	    return previous.value;
+	}
 
 
-	if (cubicBezier (a.frame, x1, x2, b.frame, mid) < frame) {
-	    low = mid;
-	} else {
-	    high = mid;
+	// handle x is in half segments. The curve parameter is found by halving steps from an integer
+	// division guess, like the 0.01 frame tolerance and 1000 step limit of sub_1401A9BC0
+	const float half = static_cast<float> (end - start) * 0.5f;
+	const float x1 = half * previous.front.x + static_cast<float> (start);
+	const float x2 = half * current.back.x + static_cast<float> (end);
+	const float target = static_cast<float> (frame);
+	float t = static_cast<float> ((frame - start) / (end - start));
+	float step = 0.999f;
+
+	for (int iteration = 0; iteration < 1000; iteration++) {
+	    const float x = cubicBezier (static_cast<float> (start), x1, x2, static_cast<float> (end), t);
+
+	    if (std::fabs (x - target) < 0.01f) {
+		break;
+	    }
+
+	    step *= 0.5f;
+	    t = x <= target ? t + step : t - step;
 	}
 	}
-    }
 
 
-    return cubicBezier (a.value, y1, y2, b.value, (low + high) * 0.5f);
-}
+	t = std::clamp (t, 0.0f, 1.0f);
 
 
-float evaluateCurve (const std::vector<AnimationKeyframe>& keys, float frame, bool wrap, float length) {
-    if (keys.size () == 1 || frame <= keys.front ().frame) {
-	return keys.front ().value;
+	return cubicBezier (
+	    previous.value, previous.value + previous.front.y, current.value + current.back.y, current.value, t
+	);
     }
     }
 
 
-    if (frame >= keys.back ().frame) {
-	if (wrap && length > 0.0f && frame < keys.front ().frame + length) {
-	    AnimationKeyframe first = keys.front ();
-	    first.frame += length;
-	    return evaluateSegment (keys.back (), first, frame);
-	}
+    return keys.back ().value;
+}
 
 
-	return keys.back ().value;
+float WallpaperEngine::Scripting::evaluateAnimationCurve (
+    const std::vector<AnimationKeyframe>& keys, const float frame, const float fps, const int frameCount
+) {
+    if (keys.empty ()) {
+	return 0.0f;
     }
     }
 
 
-    for (size_t i = 0; i + 1 < keys.size (); i++) {
-	if (frame < keys[i + 1].frame) {
-	    return evaluateSegment (keys[i], keys[i + 1], frame);
-	}
+    if (frameCount <= 0 || fps <= 0.0f) {
+	return sampleAnimationFrame (keys, static_cast<int> (frame));
     }
     }
 
 
-    return keys.back ().value;
+    // whole frames get sampled and blended, the blend comes from the time in seconds (sub_140171440)
+    const float frameTime = 1.0f / fps;
+    const float seconds = frame * frameTime;
+    const int whole = static_cast<int> (seconds / frameTime);
+    const int first = std::clamp (whole, 0, frameCount - 1);
+    const int second = std::min (first + 1, frameCount);
+    const float blend = std::fmod (seconds, frameTime) / frameTime;
+
+    return sampleAnimationFrame (keys, first) * (1.0f - blend) + sampleAnimationFrame (keys, second) * blend;
 }
 }
-} // namespace
 
 
 AnimationClock::AnimationClock (
 AnimationClock::AnimationClock (
     int id, DynamicValue& rootValue, std::shared_ptr<const PropertyAnimation> definition
     int id, DynamicValue& rootValue, std::shared_ptr<const PropertyAnimation> definition
@@ -161,7 +178,6 @@ void AnimationClock::applyBinding (Binding& binding) const {
 
 
     const glm::vec4 current = readComponents (*binding.value);
     const glm::vec4 current = readComponents (*binding.value);
     glm::vec4 target = current;
     glm::vec4 target = current;
-    const bool wrap = m_definition->mode == PropertyAnimation::Mode::Loop && binding.data->wrapLoop;
 
 
     for (int component = 0; component < count; component++) {
     for (int component = 0; component < count; component++) {
 	const auto& keys = binding.data->curves[component];
 	const auto& keys = binding.data->curves[component];
@@ -170,7 +186,9 @@ void AnimationClock::applyBinding (Binding& binding) const {
 	    continue;
 	    continue;
 	}
 	}
 
 
-	const float sampled = evaluateCurve (keys, m_frame, wrap, m_definition->length);
+	const float sampled = evaluateAnimationCurve (
+	    keys, m_frame, m_definition->fps, static_cast<int> (m_definition->length)
+	);
 
 
 	if (binding.data->relative) {
 	if (binding.data->relative) {
 	    // fold the offset in on top of whatever the value is now, so a script moving the base
 	    // fold the offset in on top of whatever the value is now, so a script moving the base

+ 5 - 0
src/WallpaperEngine/Scripting/AnimationSystem.h

@@ -13,6 +13,11 @@
 namespace WallpaperEngine::Scripting {
 namespace WallpaperEngine::Scripting {
 using namespace WallpaperEngine::Data::Model;
 using namespace WallpaperEngine::Data::Model;
 
 
+/** A keyframe curve at a whole frame, wallpaper64.exe's sub_1401A9BC0 */
+float sampleAnimationFrame (const std::vector<AnimationKeyframe>& keys, int frame);
+/** A keyframe curve at a fractional frame: the two whole frames around it, blended (sub_140171440) */
+float evaluateAnimationCurve (const std::vector<AnimationKeyframe>& keys, float frame, float fps, int frameCount);
+
 /**
 /**
  * One playback timeline shared by a root animated property and every property that lists it as
  * One playback timeline shared by a root animated property and every property that lists it as
  * its parent (e.g. an origin animation driving the scale one, or a shader's Radius driving Size).
  * its parent (e.g. an origin animation driving the scale one, or a shader's Radius driving Size).

+ 2 - 0
src/WallpaperEngine/Scripting/ScriptableObject.cpp

@@ -16,6 +16,8 @@ ScriptableObject::ScriptableObject (Wallpapers::CScene& scene, const Object& obj
     this->registerProperty ("scale", *object.groupScale->value);
     this->registerProperty ("scale", *object.groupScale->value);
     this->registerProperty ("angles", *object.groupAngles->value);
     this->registerProperty ("angles", *object.groupAngles->value);
     this->registerProperty ("visible", *object.groupVisible->value);
     this->registerProperty ("visible", *object.groupVisible->value);
+    this->registerProperty ("solid", *object.solid->value);
+    this->registerProperty ("disablepropagation", *object.disablePropagation->value);
 }
 }
 
 
 DynamicValue& ScriptableObject::getProperty (const std::string& name) {
 DynamicValue& ScriptableObject::getProperty (const std::string& name) {

+ 120 - 0
src/WallpaperEngine/Testing/Cases/TextLayoutRules.cpp

@@ -0,0 +1,120 @@
+#include <catch2/catch_test_macros.hpp>
+
+#include <cstdio>
+#include <filesystem>
+#include <string>
+
+#include "WallpaperEngine/Render/Objects/TextLayout.h"
+
+using WallpaperEngine::Render::Objects::TextAlign;
+using WallpaperEngine::Render::Objects::TextLayout;
+using WallpaperEngine::Render::Objects::TextLayoutParams;
+using WallpaperEngine::Render::Objects::TextLayoutResult;
+
+namespace {
+std::string systemFont () {
+    FILE* pipe = popen ("fc-match -f '%{file}' 'DejaVu Sans' 2>/dev/null", "r");
+
+    if (pipe == nullptr) {
+	return {};
+    }
+
+    std::string path;
+    char buffer[512];
+
+    while (fgets (buffer, sizeof (buffer), pipe) != nullptr) {
+	path += buffer;
+    }
+
+    pclose (pipe);
+    return std::filesystem::exists (path) ? path : std::string ();
+}
+
+float width (const TextLayoutResult& result) { return result.maxX - result.minX; }
+} // namespace
+
+TEST_CASE ("Text layout follows wallpaper64.exe's line rules") {
+    const std::string font = systemFont ();
+
+    if (font.empty ()) {
+	SKIP ("no system font to lay text out with");
+    }
+
+    TextLayout layout;
+    REQUIRE (layout.setPrimaryFont ({}, font));
+
+    TextLayoutParams params { .size = 12.0f, .align = TextAlign::Left };
+
+    SECTION ("one line keeps the font's ascender as the top") {
+	const auto result = layout.layout ("Plain", params);
+
+	REQUIRE (result.valid);
+	CHECK (result.lines == 1);
+	CHECK (result.top >= result.ascender);
+	CHECK (result.bottom <= result.ascender - result.pitch);
+	CHECK (result.quads.size () == 5);
+    }
+
+    SECTION ("spacing widens every advance and the line pitch") {
+	const auto plain = layout.layout ("Aa\nAa", params);
+	params.spacing = { 10.0f, 20.0f };
+	const auto spaced = layout.layout ("Aa\nAa", params);
+
+	REQUIRE (plain.valid);
+	REQUIRE (spaced.valid);
+	CHECK (spaced.pitch == plain.pitch + 20.0f);
+	CHECK (width (spaced) == width (plain) + 10.0f);
+    }
+
+    SECTION ("limitwidth breaks at the last space and blockalign fills maxwidth") {
+	params.maxWidth = 300.0f;
+	const auto wrapped = layout.layout ("the quick brown fox jumps over the lazy dog", params);
+
+	REQUIRE (wrapped.valid);
+	CHECK (wrapped.lines > 1);
+	CHECK (width (wrapped) <= 300.0f);
+
+	params.blockAlign = true;
+	const auto justified = layout.layout ("the quick brown fox jumps over the lazy dog", params);
+
+	REQUIRE (justified.valid);
+	CHECK (justified.lines == wrapped.lines);
+	// wrapped lines span exactly maxwidth from their own left edge, the unwrapped last line may reach a pixel further
+	CHECK (width (justified) >= 300.0f);
+	CHECK (width (justified) <= 302.0f);
+    }
+
+    SECTION ("limitrows cuts the text and fits an ellipsis into maxwidth") {
+	params.maxWidth = 300.0f;
+	params.maxRows = 1;
+	params.ellipsis = true;
+	const auto result = layout.layout ("the quick brown fox jumps over the lazy dog", params);
+
+	REQUIRE (result.valid);
+	CHECK (result.lines == 1);
+	CHECK (result.maxX <= 300.0f);
+    }
+
+    SECTION ("a row limit also drops whole lines") {
+	params.maxRows = 2;
+	const auto result = layout.layout ("one\ntwo\nthree", params);
+
+	REQUIRE (result.valid);
+	CHECK (result.lines == 2);
+    }
+
+    SECTION ("MSDF glyph quads carry 12 atlas pixels of range around the glyph") {
+	const auto plain = layout.layout ("A", params);
+	params.msdf = true;
+	const auto msdf = layout.layout ("A", params);
+
+	REQUIRE (plain.valid);
+	REQUIRE (msdf.valid);
+	REQUIRE (msdf.quads.size () == 1);
+	CHECK (layout.getAtlasChannels () == 4);
+	CHECK (msdf.quads[0].rect.x < plain.quads[0].rect.x);
+	CHECK (msdf.quads[0].rect.w > plain.quads[0].rect.w);
+	// the box is the glyph's, the range outside it doesn't count
+	CHECK (msdf.maxX == plain.maxX);
+    }
+}

+ 20 - 4
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.cpp

@@ -90,6 +90,8 @@ void GLPlayer::disableAudio () {
     this->m_audio = false;
     this->m_audio = false;
 }
 }
 
 
+void GLPlayer::setLinearOutput () { this->m_linearOutput = true; }
+
 void GLPlayer::clearUntimed () {
 void GLPlayer::clearUntimed () {
     if (this->m_handle) {
     if (this->m_handle) {
 	sLog.exception ("Cannot set untimed mode after playback has started");
 	sLog.exception ("Cannot set untimed mode after playback has started");
@@ -238,7 +240,10 @@ void GLPlayer::render () const {
 	this->m_height = height;
 	this->m_height = height;
 	this->m_needsRedraw = true;
 	this->m_needsRedraw = true;
 	glBindTexture (GL_TEXTURE_2D, this->m_outputTexture);
 	glBindTexture (GL_TEXTURE_2D, this->m_outputTexture);
-	glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, this->m_width, this->m_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+	glTexImage2D (
+	    GL_TEXTURE_2D, 0, this->m_linearOutput ? GL_RGBA16F : GL_RGBA8, this->m_width, this->m_height, 0, GL_RGBA,
+	    this->m_linearOutput ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE, nullptr
+	);
     }
     }
 
 
     // mpv only hands out its next frame once the update flags were collected, without this playback stalls
     // mpv only hands out its next frame once the update flags were collected, without this playback stalls
@@ -255,7 +260,7 @@ void GLPlayer::render () const {
     glViewport (0, 0, this->m_width, this->m_height);
     glViewport (0, 0, this->m_width, this->m_height);
 
 
     mpv_opengl_fbo fbo { static_cast<int> (this->m_fbo), static_cast<int> (this->m_width),
     mpv_opengl_fbo fbo { static_cast<int> (this->m_fbo), static_cast<int> (this->m_width),
-			 static_cast<int> (this->m_height), GL_RGBA8 };
+			 static_cast<int> (this->m_height), this->m_linearOutput ? GL_RGBA16F : GL_RGBA8 };
 
 
     // no need to flip as it'll be handled by the wallpaper rendering code
     // no need to flip as it'll be handled by the wallpaper rendering code
     int flip_y = 0;
     int flip_y = 0;
@@ -303,7 +308,10 @@ void GLPlayer::prepareGL () {
     glGenFramebuffers (1, &this->m_fbo);
     glGenFramebuffers (1, &this->m_fbo);
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_fbo);
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_fbo);
     glBindTexture (GL_TEXTURE_2D, this->m_outputTexture);
     glBindTexture (GL_TEXTURE_2D, this->m_outputTexture);
-    glTexImage2D (GL_TEXTURE_2D, 0, GL_RGBA8, this->m_width, this->m_height, 0, GL_RGBA, GL_UNSIGNED_BYTE, nullptr);
+    glTexImage2D (
+	GL_TEXTURE_2D, 0, this->m_linearOutput ? GL_RGBA16F : GL_RGBA8, this->m_width, this->m_height, 0, GL_RGBA,
+	this->m_linearOutput ? GL_HALF_FLOAT : GL_UNSIGNED_BYTE, nullptr
+    );
     constexpr GLenum drawBuffers[1] = { GL_COLOR_ATTACHMENT0 };
     constexpr GLenum drawBuffers[1] = { GL_COLOR_ATTACHMENT0 };
     glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->m_outputTexture, 0);
     glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->m_outputTexture, 0);
     glDrawBuffers (1, drawBuffers);
     glDrawBuffers (1, drawBuffers);
@@ -334,7 +342,15 @@ void GLPlayer::init () {
     mpv_set_option_string (this->m_handle, "input-cursor", "no");
     mpv_set_option_string (this->m_handle, "input-cursor", "no");
     mpv_set_option_string (this->m_handle, "cursor-autohide", "no");
     mpv_set_option_string (this->m_handle, "cursor-autohide", "no");
     mpv_set_option_string (this->m_handle, "config", "no");
     mpv_set_option_string (this->m_handle, "config", "no");
-    mpv_set_option_string (this->m_handle, "fbo-format", "rgba8");
+    mpv_set_option_string (this->m_handle, "fbo-format", this->m_linearOutput ? "rgba16f" : "rgba8");
+
+    if (this->m_linearOutput) {
+	mpv_set_option_string (this->m_handle, "target-trc", "linear");
+	mpv_set_option_string (this->m_handle, "target-prim", "bt.2020");
+	// linear's nominal peak is reference white, mpv would tone map everything down to SDR; the compositor
+	// maps PQ to what the monitor can show
+	mpv_set_option_string (this->m_handle, "target-peak", "10000");
+    }
     mpv_set_option_string (this->m_handle, "vo", "libmpv");
     mpv_set_option_string (this->m_handle, "vo", "libmpv");
     mpv_set_option_string (this->m_handle, "profile", "fast");
     mpv_set_option_string (this->m_handle, "profile", "fast");
     mpv_set_option_string (this->m_handle, "untimed", this->m_untimed ? "yes" : "no");
     mpv_set_option_string (this->m_handle, "untimed", this->m_untimed ? "yes" : "no");

+ 6 - 0
src/WallpaperEngine/VideoPlayback/MPV/GLPlayer.h

@@ -33,6 +33,11 @@ public:
     void setUntimed ();
     void setUntimed ();
     /** For videos that are only ever a texture. Must be called before playback starts */
     /** For videos that are only ever a texture. Must be called before playback starts */
     void disableAudio ();
     void disableAudio ();
+    /**
+     * Decodes into a half float texture as linear light with BT.2020 primaries (1.0 = reference white) and no
+     * tone mapping, so HDR videos keep their highlights for an HDR output. Must be called before playback starts
+     */
+    void setLinearOutput ();
     void clearUntimed ();
     void clearUntimed ();
     void setMuted ();
     void setMuted ();
     void clearMuted ();
     void clearMuted ();
@@ -85,6 +90,7 @@ protected:
     bool m_paused = false;
     bool m_paused = false;
     bool m_loop = true;
     bool m_loop = true;
     bool m_audio = true;
     bool m_audio = true;
+    bool m_linearOutput = false;
     // a texture we own only needs redrawing when mpv has a new frame, or after its size changed
     // a texture we own only needs redrawing when mpv has a new frame, or after its size changed
     mutable bool m_needsRedraw = true;
     mutable bool m_needsRedraw = true;
     mutable bool m_fileLoaded = false;
     mutable bool m_fileLoaded = false;

+ 28 - 9
tools/headless_render.sh

@@ -1,8 +1,10 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
-# Renders a wallpaper headlessly (no real display/GPU needed - Xvfb + Mesa llvmpipe software
-# rendering) and takes a screenshot, for environments with no way to see the real screen
-# (sandboxes, CI, this project's own dev container). Confirmed working there; a real machine
-# with a GPU and a display doesn't need this, just run the binary normally.
+# Renders a wallpaper headlessly and takes a screenshot, for environments with no way to see the
+# real screen (sandboxes, CI, this project's own dev container). When a GPU render node
+# (/dev/dri/renderD*) is accessible it renders there through the engine's headless EGL driver
+# (XDG_SESSION_TYPE=headless, no X server, a 4K scene takes ~1s instead of ~20s). Otherwise, or
+# with HEADLESS_RENDER_SOFTWARE=1, it falls back to Xvfb + Mesa llvmpipe software rendering.
+# LWE_HEADLESS_DEVICE=/dev/dri/renderDN picks the GPU when there are several.
 #
 #
 # Usage:
 # Usage:
 #   tools/headless_render.sh <path-to-linux-wallpaperengine-binary> <output.png> [engine args...]
 #   tools/headless_render.sh <path-to-linux-wallpaperengine-binary> <output.png> [engine args...]
@@ -12,14 +14,15 @@
 #       --assets-dir /path/to/wallpaper_engine/assets --window 0x0x1920x1080 --fps 25 \
 #       --assets-dir /path/to/wallpaper_engine/assets --window 0x0x1920x1080 --fps 25 \
 #       /path/to/workshop/item/dir
 #       /path/to/workshop/item/dir
 #
 #
-# Requirements: Xvfb, a software GL renderer (mesa llvmpipe is normally already present
-# alongside libgl1-mesa-dri), gcc (to build the D-Bus shim once, cached after that).
+# Requirements: a GPU render node, or Xvfb and a software GL renderer (mesa llvmpipe is normally
+# already present alongside libgl1-mesa-dri); gcc (to build the D-Bus shim once, cached after that).
 #
 #
 # Known limitations:
 # Known limitations:
 # - No real D-Bus session bus is assumed - dbus_noop_shim.so (built automatically) patches the
 # - No real D-Bus session bus is assumed - dbus_noop_shim.so (built automatically) patches the
 #   one call path that isn't already null-safe against a missing bus.
 #   one call path that isn't already null-safe against a missing bus.
 # - The engine is stopped as soon as the screenshot file appears (HEADLESS_RENDER_KEEP_RUNNING=1 disables
 # - The engine is stopped as soon as the screenshot file appears (HEADLESS_RENDER_KEEP_RUNNING=1 disables
 #   that and runs until HEADLESS_RENDER_TIMEOUT, default 60s).
 #   that and runs until HEADLESS_RENDER_TIMEOUT, default 60s).
+# - HEADLESS_RENDER_SIZE=WxH changes the window size (default 1920x1080).
 # - --screenshot-delay is capped at 5000 frames by the engine (ApplicationContext.cpp).
 # - --screenshot-delay is capped at 5000 frames by the engine (ApplicationContext.cpp).
 # - CImage.cpp's puppet/effect diagnostics are one-shot logs that fire on the first draw call,
 # - CImage.cpp's puppet/effect diagnostics are one-shot logs that fire on the first draw call,
 #   not a chosen frame.
 #   not a chosen frame.
@@ -42,9 +45,25 @@ if [ ! -f "$SHIM_SO" ] || [ "$SHIM_SRC" -nt "$SHIM_SO" ]; then
     gcc -shared -fPIC -o "$SHIM_SO" "$SHIM_SRC" -ldl
     gcc -shared -fPIC -o "$SHIM_SO" "$SHIM_SRC" -ldl
 fi
 fi
 
 
+USE_GPU=
+if [ -z "${HEADLESS_RENDER_SOFTWARE:-}" ]; then
+    for node in /dev/dri/renderD*; do
+        if [ -r "$node" ] && [ -w "$node" ]; then
+            USE_GPU=1
+            break
+        fi
+    done
+fi
+
 XVFB_DISPLAY="${HEADLESS_RENDER_DISPLAY:-:99}"
 XVFB_DISPLAY="${HEADLESS_RENDER_DISPLAY:-:99}"
 XVFB_SOCKET="/tmp/.X11-unix/X${XVFB_DISPLAY#:}"
 XVFB_SOCKET="/tmp/.X11-unix/X${XVFB_DISPLAY#:}"
-if [ ! -S "$XVFB_SOCKET" ]; then
+if [ -n "$USE_GPU" ]; then
+    SESSION=(env -u DISPLAY -u WAYLAND_DISPLAY XDG_SESSION_TYPE=headless)
+else
+    SESSION=(env -u WAYLAND_DISPLAY DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11)
+fi
+
+if [ -z "$USE_GPU" ] && [ ! -S "$XVFB_SOCKET" ]; then
     Xvfb "$XVFB_DISPLAY" -screen 0 1920x1080x24 &
     Xvfb "$XVFB_DISPLAY" -screen 0 1920x1080x24 &
     XVFB_PID=$!
     XVFB_PID=$!
     trap 'kill "$XVFB_PID" 2>/dev/null || true' EXIT
     trap 'kill "$XVFB_PID" 2>/dev/null || true' EXIT
@@ -57,11 +76,11 @@ fi
 
 
 rm -f "$OUTPUT"
 rm -f "$OUTPUT"
 
 
-DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11 \
+"${SESSION[@]}" \
 LD_LIBRARY_PATH="$BINARY_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
 LD_LIBRARY_PATH="$BINARY_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
 LD_PRELOAD="$SHIM_SO" \
 LD_PRELOAD="$SHIM_SO" \
 timeout "${HEADLESS_RENDER_TIMEOUT:-60}" "$BINARY" \
 timeout "${HEADLESS_RENDER_TIMEOUT:-60}" "$BINARY" \
-    --window 0x0x1920x1080 \
+    --window "0x0x${HEADLESS_RENDER_SIZE:-1920x1080}" \
     --screenshot "$OUTPUT" \
     --screenshot "$OUTPUT" \
     --screenshot-delay "${HEADLESS_RENDER_DELAY:-3}" \
     --screenshot-delay "${HEADLESS_RENDER_DELAY:-3}" \
     "$@" &
     "$@" &

+ 42 - 5
tools/regression/regress.py

@@ -10,6 +10,10 @@ Runs are reproducible: animation time advances by a fixed step per frame (LWE_FI
 particles are seeded per object, the wall clock is pinned (fixed_clock.c) and audio input is
 particles are seeded per object, the wall clock is pinned (fixed_clock.c) and audio input is
 off. Each worker gets its own Xvfb display, nothing touches the real screen.
 off. Each worker gets its own Xvfb display, nothing touches the real screen.
 
 
+--gpu renders on the GPU instead (the engine's headless EGL driver, XDG_SESSION_TYPE=headless),
+no Xvfb and many times faster than llvmpipe. GPU and llvmpipe pixels differ slightly, so compare
+runs made the same way; the binary needs the headless driver for this.
+
 compare looks at four things per wallpaper: whether it still renders (crash/timeout), error
 compare looks at four things per wallpaper: whether it still renders (crash/timeout), error
 lines that appear or disappear in the log, how much of the screenshot changed, and whether gif
 lines that appear or disappear in the log, how much of the screenshot changed, and whether gif
 and video textures still move (wallpapers shipping them get a second render a few frames later,
 and video textures still move (wallpapers shipping them get a second render a few frames later,
@@ -168,6 +172,8 @@ def x_sockets ():
 class Display:
 class Display:
     """One Xvfb server per worker so renders can run side by side."""
     """One Xvfb server per worker so renders can run side by side."""
 
 
+    process = None
+
     def __init__ (self, first, width, height):
     def __init__ (self, first, width, height):
         number = first
         number = first
 
 
@@ -196,6 +202,9 @@ class Display:
                 raise RuntimeError ('could not start Xvfb')
                 raise RuntimeError ('could not start Xvfb')
 
 
     def close (self):
     def close (self):
+        if self.process is None:
+            return
+
         self.process.terminate ()
         self.process.terminate ()
         try:
         try:
             self.process.wait (5)
             self.process.wait (5)
@@ -266,8 +275,8 @@ def render (args, preload, display, wallpaper):
     env = dict (os.environ)
     env = dict (os.environ)
     env.pop ('WAYLAND_DISPLAY', None)
     env.pop ('WAYLAND_DISPLAY', None)
     env.pop ('WAYLAND_SOCKET', None)
     env.pop ('WAYLAND_SOCKET', None)
+    env.pop ('DISPLAY', None)
     env.update ({
     env.update ({
-        'DISPLAY': display.name,
         'XDG_SESSION_TYPE': 'x11',
         'XDG_SESSION_TYPE': 'x11',
         'LD_LIBRARY_PATH': ':'.join (filter (None, [str (args.binary.parent), os.environ.get ('LD_LIBRARY_PATH')])),
         'LD_LIBRARY_PATH': ':'.join (filter (None, [str (args.binary.parent), os.environ.get ('LD_LIBRARY_PATH')])),
         'LD_PRELOAD': preload,
         'LD_PRELOAD': preload,
@@ -275,9 +284,15 @@ def render (args, preload, display, wallpaper):
         'LWE_FIXED_CLOCK': str (args.clock),
         'LWE_FIXED_CLOCK': str (args.clock),
         'TZ': 'UTC',
         'TZ': 'UTC',
     })
     })
-    # software rendering (llvmpipe) starts a thread per core in every instance, which just
-    # thrashes when several renders run at once
-    env.setdefault ('LP_NUM_THREADS', str (max (1, (os.cpu_count () or 1) // args.jobs)))
+    if args.gpu:
+        env['XDG_SESSION_TYPE'] = 'headless'
+        if args.gpu != 'auto':
+            env['LWE_HEADLESS_DEVICE'] = args.gpu
+    else:
+        env['DISPLAY'] = display.name
+        # software rendering (llvmpipe) starts a thread per core in every instance, which just
+        # thrashes when several renders run at once
+        env.setdefault ('LP_NUM_THREADS', str (max (1, (os.cpu_count () or 1) // args.jobs)))
 
 
     status, code, seconds = render_once (args, env, folder, target / 'shot.png', target / 'log.txt', properties)
     status, code, seconds = render_once (args, env, folder, target / 'shot.png', target / 'log.txt', properties)
 
 
@@ -316,6 +331,10 @@ def render (args, preload, display, wallpaper):
 
 
 
 
 def cmd_run (args):
 def cmd_run (args):
+    if args.jobs is None:
+        cores = os.cpu_count () or 2
+        args.jobs = max (1, cores // 2 if args.gpu else min (8, cores // 4))
+
     args.binary = args.binary.resolve ()
     args.binary = args.binary.resolve ()
     args.out = args.out.resolve ()
     args.out = args.out.resolve ()
     args.out.mkdir (parents = True, exist_ok = True)
     args.out.mkdir (parents = True, exist_ok = True)
@@ -335,6 +354,10 @@ def cmd_run (args):
 
 
     try:
     try:
         for _ in range (args.jobs):
         for _ in range (args.jobs):
+            if args.gpu:
+                displays.put (None)
+                continue
+
             display = Display (opened[-1].number + 1 if opened else 90, args.width, args.height)
             display = Display (opened[-1].number + 1 if opened else 90, args.width, args.height)
             opened.append (display)
             opened.append (display)
             displays.put (display)
             displays.put (display)
@@ -353,6 +376,7 @@ def cmd_run (args):
             'clock': args.clock,
             'clock': args.clock,
             'size': [args.width, args.height],
             'size': [args.width, args.height],
             'repeat': args.repeat,
             'repeat': args.repeat,
+            'renderer': 'gpu' if args.gpu else 'llvmpipe',
             'started': time.strftime ('%Y-%m-%d %H:%M:%S'),
             'started': time.strftime ('%Y-%m-%d %H:%M:%S'),
         }, indent = 1))
         }, indent = 1))
 
 
@@ -456,7 +480,18 @@ def load_result (folder):
         return None
         return None
 
 
 
 
+def renderer (folder):
+    try:
+        return json.loads ((folder / 'run.json').read_text ()).get ('renderer', 'llvmpipe')
+    except (OSError, ValueError):
+        return '?'
+
+
 def cmd_compare (args):
 def cmd_compare (args):
+    if renderer (args.base) != renderer (args.new):
+        print (f'warning: base was rendered with {renderer (args.base)} and new with {renderer (args.new)}, '
+               'expect small differences everywhere', file = sys.stderr)
+
     report = args.report.resolve ()
     report = args.report.resolve ()
     shutil.rmtree (report, ignore_errors = True)
     shutil.rmtree (report, ignore_errors = True)
     (report / 'img').mkdir (parents = True)
     (report / 'img').mkdir (parents = True)
@@ -648,7 +683,9 @@ def main ():
     run.add_argument ('--variants', type = Path, default = HERE / 'variants.json',
     run.add_argument ('--variants', type = Path, default = HERE / 'variants.json',
                       help = 'extra renders with user properties set (default: variants.json next to this script)')
                       help = 'extra renders with user properties set (default: variants.json next to this script)')
     run.add_argument ('--types', default = 'scene', help = 'project types to include, comma separated (default: scene)')
     run.add_argument ('--types', default = 'scene', help = 'project types to include, comma separated (default: scene)')
-    run.add_argument ('--jobs', type = int, default = max (1, min (8, (os.cpu_count () or 2) // 4)))
+    run.add_argument ('--jobs', type = int, help = 'renders at once (default: cores/4 up to 8, cores/2 with --gpu)')
+    run.add_argument ('--gpu', nargs = '?', const = 'auto', metavar = 'RENDER_NODE',
+                      help = 'render on the GPU without Xvfb, optionally on this /dev/dri/renderD* node')
     run.add_argument ('--frame', type = int, default = 30, help = 'frame to screenshot (default: 30)')
     run.add_argument ('--frame', type = int, default = 30, help = 'frame to screenshot (default: 30)')
     run.add_argument ('--step', type = float, default = 0.1, help = 'animation seconds per frame (default: 0.1)')
     run.add_argument ('--step', type = float, default = 0.1, help = 'animation seconds per frame (default: 0.1)')
     run.add_argument ('--clock', type = int, default = 1767268800, help = 'pinned wall clock, unix seconds')
     run.add_argument ('--clock', type = int, default = 1767268800, help = 'pinned wall clock, unix seconds')

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác