Sfoglia il codice sorgente

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 giorni fa
parent
commit
812a617cb1
72 ha cambiato i file con 10118 aggiunte e 1052 eliminazioni
  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"]
 	path = src/External/quickjs
 	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(PulseAudio REQUIRED)
 find_package(Freetype REQUIRED)
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(HARFBUZZ REQUIRED IMPORTED_TARGET harfbuzz)
 
 set(ENABLE_OPT OFF)
 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/SPIRV-Cross-WallpaperEngine spirv-cross)
 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)
 set(QJS_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
 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)
     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)
+    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
         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
@@ -194,6 +206,8 @@ if(WAYLAND_SUPPORT_FOUND)
         wayland-egl
         ${OPENGL_egl_LIBRARY})
     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.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.h
         ${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)
         pkg_check_modules(DBUS REQUIRED dbus-1)
@@ -476,8 +492,16 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Render/Objects/CParticle.cpp
     src/WallpaperEngine/Render/Objects/CText.h
     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.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.cpp
@@ -508,6 +532,7 @@ set(COMMON_SOURCES
     src/WallpaperEngine/Data/Assets/Package.h
     src/WallpaperEngine/Data/Model/Types.h
     src/WallpaperEngine/Data/Model/Project.h
+    src/WallpaperEngine/Data/Model/ImageAdjustments.h
     src/WallpaperEngine/Data/Model/Wallpaper.h
     src/WallpaperEngine/Data/Model/Object.h
     src/WallpaperEngine/Data/Model/Material.h
@@ -624,7 +649,8 @@ if(BUILD_TESTING)
         src/WallpaperEngine/Testing/Cases/ModuleNamespaceTiming.cpp
         src/WallpaperEngine/Testing/Cases/AudioSensitivity.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()
 
 add_library(
@@ -646,6 +672,8 @@ target_link_libraries (linux-wallpaperengine-lib PUBLIC
     ${MPV_LIBRARY}
     ${PULSEAUDIO_LIBRARY}
     Freetype::Freetype
+    PkgConfig::HARFBUZZ
+    msdfgen-core
     ${WAYLAND_LIBRARIES}
     ${X11_LIBRARIES}
     ${DBUS_LIBRARIES}
@@ -672,6 +700,8 @@ if (BUILD_TESTING)
         ${MPV_LIBRARY}
         ${PULSEAUDIO_LIBRARY}
         Freetype::Freetype
+        PkgConfig::HARFBUZZ
+        msdfgen-core
         ${WAYLAND_LIBRARIES}
         ${X11_LIBRARIES}
         ${DBUS_LIBRARIES}

+ 45 - 0
docs/rendering/MDL_FILES.md

@@ -103,6 +103,51 @@ MDLFILE file;
 // 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.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 <cstring>
 #include <fstream>
+#include <functional>
 #include <iostream>
 #include <optional>
 #include <sstream>
@@ -22,6 +23,7 @@
 #define APP_DIRECTORY "wallpaper_engine"
 
 using namespace WallpaperEngine::Application;
+using WallpaperEngine::Data::Model::ImageAdjustments;
 
 namespace {
 // 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 ();
 
+    // 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")
 	.help (
 	    "Wayland-only: which wlr-layer-shell layer to anchor the wallpaper to "
@@ -859,6 +981,35 @@ void ApplicationContext::loadSettingsFromArgv () {
 	.flag ()
 	.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")
 	.help ("Freezes all scene animation (scripts, particles, effects and puppet meshes) at its current frame")
 	.flag ()

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

@@ -100,6 +100,8 @@ public:
 	glm::vec2 offset = { 0.0f, 0.0f };
 	/** 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 };
+	/** Image filter, color options and flip, see --image-filter */
+	WallpaperEngine::Data::Model::ImageAdjustments imageAdjustments = {};
     };
 
     struct {
@@ -112,6 +114,11 @@ public:
 	    bool disableParticles;
 	    /** Grows a scene's render canvas to fit every image layer that extends past it, see --expand-canvas */
 	    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 */
 	    std::vector<std::string> disabledObjects;
 	    /** Objects/layers to force-show, matched by id or name */
@@ -137,6 +144,8 @@ public:
 	    std::map<std::string, glm::vec2> screenOffsets;
 	    /** Corner color for different screens, shown outside the wallpaper's bounds when clamp is border */
 	    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;
 	    /** Playlist used in window mode */
 	    std::optional<PlaylistDefinition> defaultPlaylist;
@@ -173,6 +182,8 @@ public:
 	     * Example: "firefox" will match "org.mozilla.firefox".
 	     */
 	    std::vector<std::string> fullscreenPauseIgnoreAppIds;
+	    /** Wayland-only: PQ output on monitors in HDR mode and HDR video, see --hdr */
+	    bool hdr;
 	    struct {
 		bool baseOnly;
 		bool noSolidFinal;
@@ -196,6 +207,8 @@ public:
 		glm::vec2 offset;
 		/** Corner color shown outside the wallpaper's bounds when clamp is border, see --corner-color */
 		glm::vec4 cornerColor;
+		/** Image filter, color options and flip, see --image-filter */
+		WallpaperEngine::Data::Model::ImageAdjustments imageAdjustments;
 	    } window;
 
 	    struct {
@@ -250,6 +263,7 @@ public:
             .screenZooms = {},
             .screenOffsets = {},
             .screenCornerColors = {},
+            .screenImageAdjustments = {},
             .screenPlaylists = {},
             .defaultPlaylist = std::nullopt,
             .spanGroups = {},
@@ -267,6 +281,7 @@ public:
             .pauseOnFullscreen = true,
             .pauseOnFullscreenOnlyWhenActive = false,
             .fullscreenPauseIgnoreAppIds = {},
+            .hdr = false,
             .debug = {
                 .baseOnly = false,
                 .noSolidFinal = false,
@@ -284,6 +299,7 @@ public:
                 .zoom = 1.0f,
                 .offset = { 0.0f, 0.0f },
                 .cornerColor = { 0.0f, 0.0f, 0.0f, 1.0f },
+                .imageAdjustments = {},
             },
             .wayland = {
                 .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") };
 }
 
+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 (
-    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 ()) {
 	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 ()) {
 	    continue;
 	}
@@ -594,6 +661,9 @@ void WallpaperApplication::advancePlaylist (
 		const auto offset = this->resolveScreenOffset (screen);
 		wallpaper->setOffset (offset.x, offset.y);
 		wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+		wallpaper->setImageAdjustments (
+		    this->resolveScreenImageAdjustments (screen, *this->m_backgrounds[screen])
+		);
 		this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 		this->applyAudioPolicy ();
 	    }
@@ -679,6 +749,9 @@ struct HotswapRequest {
     std::optional<std::string> expandCanvas;
     /** hex RGB/RGBA color, e.g. "000000" or "#1a1a1aff" */
     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" */
     std::optional<std::string> speed;
     /** 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
  * 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
  * --set-property-equivalent overrides.
  */
@@ -776,6 +850,45 @@ HotswapRequest parseHotswapRequest (std::istream& file) {
 	    request.expandCanvas = value;
 	} else if (key == "corner-color") {
 	    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") {
 	    request.speed = value;
 	} else if (key == "audio-screen") {
@@ -840,7 +953,7 @@ void WallpaperApplication::checkHotswapRequest () {
     if (!request.path.has_value () && !request.layersProvided && !request.volume.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.cornerColor.has_value ()
+	&& !request.cornerColor.has_value () && !request.imageAdjustmentsProvided
 	&& !request.speed.has_value () && !request.audioScreen.has_value () && !request.ambientVolume.has_value ()
 	&& !request.propertiesProvided && !request.audioSensitivityProvided && !request.soundVolumeProvided) {
 	sLog.error ("Hotswap requested but control file was empty");
@@ -860,6 +973,14 @@ void WallpaperApplication::checkHotswapRequest () {
 	general.properties.clear ();
 	general.audioSensitivity.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 ()) {
@@ -894,6 +1015,10 @@ void WallpaperApplication::checkHotswapRequest () {
 	this->applyCornerColorHotswap (*request.cornerColor);
     }
 
+    if (request.imageAdjustmentsProvided) {
+	this->applyImageAdjustmentsHotswap (request.imageAdjustments);
+    }
+
     if (request.speed.has_value ()) {
 	this->applySpeedHotswap (*request.speed);
     }
@@ -1003,6 +1128,7 @@ void WallpaperApplication::checkHotswapRequest () {
 		    const auto offset = this->resolveScreenOffset (screen);
 		    wallpaper->setOffset (offset.x, offset.y);
 		    wallpaper->setCornerColor (this->resolveScreenCornerColor (screen));
+		    wallpaper->setImageAdjustments (this->resolveScreenImageAdjustments (screen, *background));
 		    this->m_renderContext->setWallpaper (screen, std::move (wallpaper));
 		}
 	    } catch (...) {
@@ -1083,6 +1209,28 @@ glm::vec4 WallpaperApplication::resolveScreenCornerColor (const std::string& scr
 	: 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 {
     const auto& viewports = this->m_renderContext->getOutput ().getViewports ();
     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");
 }
 
+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) {
     float speed;
 
@@ -1853,7 +2025,7 @@ void WallpaperApplication::takeScreenshot (const std::filesystem::path& filename
 
 	// bind the wallpaper's FBO to read from it directly
 	// this is more reliable than the default framebuffer on some drivers (NVIDIA/Wayland)
-	glBindFramebuffer (GL_FRAMEBUFFER, wallpaper->getWallpaperFramebuffer ());
+	glBindFramebuffer (GL_FRAMEBUFFER, wallpaper->renderAdjustedFramebuffer ());
 
 	glFinish ();
 
@@ -2029,6 +2201,7 @@ void WallpaperApplication::prepareOutputs () {
 	const auto offset = this->resolveScreenOffset (background);
 	wallpaper->setOffset (offset.x, offset.y);
 	wallpaper->setCornerColor (this->resolveScreenCornerColor (background));
+	wallpaper->setImageAdjustments (this->resolveScreenImageAdjustments (background, *info));
 	m_renderContext->setWallpaper (background, std::move (wallpaper));
     }
 
@@ -2090,6 +2263,7 @@ void WallpaperApplication::prepareOutputs () {
 	sharedWallpaper->setZoom (spanGroup.zoom);
 	sharedWallpaper->setOffset (spanGroup.offset.x, spanGroup.offset.y);
 	sharedWallpaper->setCornerColor (spanGroup.cornerColor);
+	sharedWallpaper->setImageAdjustments (this->resolveScreenImageAdjustments (groupKey, *bgIt->second));
 
 	// Convert to shared_ptr so it can be registered for multiple viewports
 	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;
     ProjectSource openProjectSource (const std::string& path) const;
     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 loadBackgrounds ();
@@ -167,6 +167,8 @@ private:
      * currently rendered wallpaper. Only visible where clamp mode is border.
      */
     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
@@ -208,6 +210,8 @@ private:
     [[nodiscard]] float resolveScreenZoom (const std::string& screen) const;
     [[nodiscard]] glm::vec2 resolveScreenOffset (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 combined bounding box of every active screen if this one isn't registered yet.
     [[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_ClampUVsBorder = 8,
     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_All = TextureFlags_NoInterpolation | TextureFlags_ClampUVs | TextureFlags_IsGif
-	| TextureFlags_ClampUVsBorder | TextureFlags_Video | TextureFlags_AlphaChannelPriority,
+	| TextureFlags_ClampUVsBorder | TextureFlags_Video | TextureFlags_Volume | TextureFlags_AlphaChannelPriority,
 };
 
 struct Mipmap {
     uint32_t width = 0;
     uint32_t height = 0;
+    /** Only set on volume textures */
+    uint32_t depth = 1;
     /** Whether the mipmap data is compressed */
     uint32_t compression = 0;
     int uncompressedSize = 0;
@@ -136,6 +140,8 @@ struct Texture {
     /** Texture size in memory (power of 2), as opposed to real width/height above */
     uint32_t textureWidth = 0;
     uint32_t textureHeight = 0;
+    /** Only set on volume textures */
+    uint32_t depth = 1;
     uint32_t gifWidth = 0;
     uint32_t gifHeight = 0;
     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
 
+#include <array>
 #include <map>
 #include <optional>
 #include <string>
@@ -12,6 +13,7 @@
 #include "Effect.h"
 #include "Material.h"
 #include "Model.h"
+#include "PropertyAnimation.h"
 #include "Types.h"
 #include "UserSetting.h"
 #include "WallpaperEngine/Data/Utils/TypeCaster.h"
@@ -36,6 +38,12 @@ struct ObjectData {
     UserSettingUniquePtr groupAngles;
     UserSettingUniquePtr groupVisible;
     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 {
@@ -95,6 +103,8 @@ struct ImageData {
     UserSettingUniquePtr brightness;
     /** Forces UV clamping on this object's composite buffers regardless of the base texture's own flags */
     bool clampUVs;
+    /** Passthrough layers only: start from a copy of the scene behind them, otherwise from a transparent buffer */
+    UserSettingUniquePtr copyBackground;
     ModelUniquePtr model;
     /** Applied after the material is rendered */
     std::vector<ImageEffectUniquePtr> effects;
@@ -132,9 +142,14 @@ public:
  */
 struct ParticleControlPoint {
     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;
     glm::vec3 offset;
     bool lockToPointer;
+    int parentControlPoint;
 };
 
 /**
@@ -147,7 +162,7 @@ struct ParticleEmitter {
     glm::vec3 distanceMin;
     glm::vec3 distanceMax;
     glm::vec3 origin;
-    glm::ivec3 sign;
+    glm::vec3 sign;
     uint32_t instantaneous;
     float speedMin;
     float speedMax;
@@ -178,10 +193,11 @@ public:
 
 class ColorRandomInitializer : public ParticleInitializerBase {
 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 max;
+    UserSettingUniquePtr exponent;
 };
 
 class SizeRandomInitializer : public ParticleInitializerBase {
@@ -283,6 +299,169 @@ public:
     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>;
 
 /**
@@ -295,10 +474,12 @@ public:
 
 class MovementOperator : public ParticleOperatorBase {
 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 gravity;
+    /** bit 0: gravity is turned by the system's frame (local systems only) */
+    uint32_t flags;
 };
 
 class AngularMovementOperator : public ParticleOperatorBase {
@@ -430,6 +611,14 @@ public:
     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 {
 public:
     ControlPointAttractOperator (
@@ -496,6 +685,121 @@ public:
     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>;
 
 /**
@@ -515,19 +819,35 @@ struct ParticleRenderer {
     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
  */
 struct ParticleChild {
-    std::string type;
+    ParticleChildType type;
+    /** Particle file the child system is loaded from */
     std::string name;
+    /** Most instances of an event child alive at once (static children ignore it) */
     int maxCount;
-    int controlPointStartIndex;
+    /** Chance of an event spawning an instance, rolled per event */
     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 color; // Replaces 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 {
@@ -568,6 +890,19 @@ struct ParticleData {
     std::vector<ParticleRenderer> renderers;
     std::vector<ParticleControlPoint> controlPoints;
     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;
 };
@@ -600,12 +935,18 @@ struct TextData {
     UserSettingUniquePtr alpha;
     UserSettingUniquePtr visible;
     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 */
     std::vector<ImageEffectUniquePtr> effects;
     /** Wrap lines wider than maxWidth (layout pixels, before the object's scale) */
@@ -615,6 +956,26 @@ struct TextData {
     UserSettingUniquePtr limitRows;
     UserSettingUniquePtr maxRows;
     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 {
@@ -635,6 +996,11 @@ struct LightData {
     UserSettingUniquePtr intensity;
     UserSettingUniquePtr radius;
     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 {
@@ -643,4 +1009,50 @@ public:
 	Object (std::move (data)), LightData (std::move (lightData)) { };
     ~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

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

@@ -3,6 +3,7 @@
 #include <memory>
 #include <string>
 
+#include "ImageAdjustments.h"
 #include "Types.h"
 #include "WallpaperEngine/Assets/AssetLocator.h"
 
@@ -20,5 +21,9 @@ struct Project {
     Properties properties;
     WallpaperUniquePtr wallpaper;
     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;
     };
 
+    /** Whole frames, WE reads them as integers */
     float frame = 0.0f;
     float value = 0.0f;
     Handle back;
     Handle front;
+    /** Holds the previous key's value up to this one */
+    bool step = false;
 };
 
 struct AnimationEvent {

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

@@ -21,6 +21,8 @@ struct ImageEffectPassOverride;
 class Particle;
 class Text;
 class Light;
+class SceneCamera;
+class Mesh;
 class DynamicValue;
 class Property;
 class PropertySlider;
@@ -53,6 +55,8 @@ using ImageUniquePtr = std::unique_ptr<Image>;
 using ParticleUniquePtr = std::unique_ptr<Particle>;
 using TextUniquePtr = std::unique_ptr<Text>;
 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 MaterialPassUniquePtr = std::unique_ptr<MaterialPass>;
 using EffectUniquePtr = std::unique_ptr<Effect>;

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

@@ -37,12 +37,42 @@ public:
     ~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 {
 	UserSettingUniquePtr ambient;
 	UserSettingUniquePtr skylight;
 	UserSettingUniquePtr clear;
     } 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 {
 	UserSettingUniquePtr fade;
 	/** Whether the software's preview UI is allowed to show this background */
@@ -52,6 +82,14 @@ struct SceneData {
 	    UserSettingUniquePtr enabled;
 	    UserSettingUniquePtr strength;
 	    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;
 	struct {
 	    UserSettingUniquePtr enabled;
@@ -73,13 +111,20 @@ struct SceneData {
 	    glm::vec3 up;
 	} configuration;
 
+	std::vector<CameraPath> paths;
+
 	struct {
 	    int width;
 	    int height;
 	    bool isAuto;
+	    bool isPerspective;
 	    UserSettingUniquePtr nearz;
 	    UserSettingUniquePtr farz;
 	    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;
     } 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) {
     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++) {
 	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;
 }
+} // 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) {

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

@@ -1,6 +1,7 @@
 #pragma once
 
 #include "WallpaperEngine/Data/JSON.h"
+#include "WallpaperEngine/Data/Model/PropertyAnimation.h"
 #include "WallpaperEngine/Data/Model/Types.h"
 
 namespace WallpaperEngine::Data::Parsers {
@@ -10,5 +11,9 @@ using namespace WallpaperEngine::Data::Model;
 class DynamicValueParser {
 public:
     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 {
 	// TODO: avoid this std::string construction
 	.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"),
 	.textures = textures.has_value () ? TextureParser::parseTextureMap (*textures) : 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 "DynamicValueParser.h"
 #include "EffectParser.h"
 #include "MaterialParser.h"
 #include "ModelParser.h"
@@ -10,21 +11,248 @@
 #include "WallpaperEngine/Data/Model/Object.h"
 #include "WallpaperEngine/Data/Model/Project.h"
 #include "WallpaperEngine/Logging/Log.h"
+#include "WallpaperEngine/Maths.h"
 
+#include <cmath>
 #include <glm/gtc/constants.hpp>
+#include <glm/gtc/matrix_transform.hpp>
 #include <sstream>
 
 using namespace WallpaperEngine::Data::Parsers;
 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) {
     const auto imageIt = it.find ("image");
     const auto soundIt = it.find ("sound");
     const auto particleIt = it.find ("particle");
     const auto textIt = it.find ("text");
     const auto lightIt = it.find ("light");
+    const auto modelIt = it.find ("model");
     // "shape" refers to VolumeLight
     const auto shapeIt = it.find ("shape");
+    const auto cameraIt = it.find ("camera");
 
     // some particle objects have numeric 'name' fields, so handle type mismatches gracefully
     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)),
 	    .groupVisible = it.user ("visible", project.properties, true),
 	    .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) {
 	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)),
 	    .groupVisible = it.user ("visible", project.properties, true),
 	    .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));
     } else if (lightIt != it.end () && lightIt->is_string ()) {
 	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 ()) {
 	sLog.error ("VolumeLight objects are not supported yet");
     } else {
@@ -92,6 +330,127 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
     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) {
     const auto name = it.require<std::string> ("light", "Light must have a type");
     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),
 	    .radius = it.user ("radius", project.properties, 1.0f),
 	    .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) {
     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) {
     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> (
 	std::move (base),
 	TextData {
 	    .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),
 	    .size = it.optional ("size", glm::vec2 (0.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),
 	    .visible = it.user ("visible", project.properties, true),
 	    .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> {},
-	    .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),
 	    .brightness = it.user ("brightness", properties, 1.0f),
 	    .clampUVs = it.optional ("clampuvs", false),
+	    .copyBackground = it.user ("copybackground", properties, true),
 	    .model = ModelParser::load (project, image),
 	    .effects = effects.has_value () ? parseEffects (*effects, project) : std::vector<ImageEffectUniquePtr> {},
 	    .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 {
 	const auto& properties = project.properties;
 	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"
 	std::vector<ParticleRenderer> renderers;
 	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;
 	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) {
-		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> ();
 	}
 
+	const auto [colorReference, hasColor] = deriveColorReference (particleJson);
+
 	const auto flagsIt = particleJson.find ("flags");
 	if (flagsIt != particleJson.end () && flagsIt->is_number ()) {
 	    flags = flagsIt->get<uint32_t> ();
@@ -592,6 +1012,10 @@ ParticleUniquePtr ObjectParser::parseParticle (const JSON& it, const Project& pr
 		.renderers = std::move (renderers),
 		.controlPoints = std::move (controlPoints),
 		.children = std::move (children),
+		.remapOutputControlPoints = remapOutputControlPoints,
+		.colorReference = colorReference,
+		.hasColor = hasColor,
+		.overrideColorTints = !hasColor || project.sceneVersion < 5,
 		.instanceOverride = std::move (instanceOverride),
 	    }
 	);
@@ -629,17 +1053,6 @@ ParticleEmitter ObjectParser::parseParticleEmitter (const JSON& it) {
 	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 {
 	const auto fieldIt = it.find (fieldName);
 	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)),
 	    .distanceMax = parseVec3 ("distancemax", glm::vec3 (256.0f, 256.0f, 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),
 	    .speedMin = it.optional ("speedmin", 0.0f),
 	    .speedMax = it.optional ("speedmax", 0.0f),
@@ -695,7 +1108,7 @@ ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON&
     if (name == "colorrandom") {
 	return std::make_unique<ColorRandomInitializer> (
 	    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") {
 	return std::make_unique<SizeRandomInitializer> (
@@ -736,11 +1149,56 @@ ParticleInitializerUniquePtr ObjectParser::parseParticleInitializer (const JSON&
 	    it.user ("audioprocessingfrequencystart", properties, 0),
 	    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") {
 	return std::make_unique<MapSequenceAroundControlPointInitializer> (
 	    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))
 	);
+    } 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;
@@ -751,7 +1209,8 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 
     if (name == "movement") {
 	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") {
 	return std::make_unique<AngularMovementOperator> (
@@ -803,6 +1262,14 @@ ParticleOperatorUniquePtr ObjectParser::parseParticleOperator (const JSON& it, c
 	    it.user ("audioprocessingfrequencystart", properties, 0),
 	    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") {
 	return std::make_unique<ControlPointAttractOperator> (
 	    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 ("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;
@@ -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) {
     // offset can be string "x y z" or array [x,y,z]
     glm::vec3 offset (0.0f);
@@ -880,20 +1435,27 @@ ParticleControlPoint ObjectParser::parseParticleControlPoint (const JSON& it) {
 	.flags = it.optional ("flags", 0u),
 	.offset = offset,
 	.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 = "";
@@ -912,7 +1474,6 @@ ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& p
 	    return it.optional (fieldName, defaultValue);
 	}
 	if (fieldIt->is_number ()) {
-	    // Single number - use for all components
 	    float val = fieldIt->get<float> ();
 	    return glm::vec3 (val, val, val);
 	}
@@ -922,16 +1483,76 @@ ParticleChild ObjectParser::parseParticleChild (const JSON& it, const Project& p
 	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 {
 	.type = type,
 	.name = name,
-	.maxCount = it.optional ("maxcount", 20),
-	.controlPointStartIndex = it.optional ("controlpointstartindex", 0),
+	.maxCount = it.optional ("maxcount", 10),
 	.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),
 	.color = it.user ("color", 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

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

@@ -24,9 +24,11 @@ private:
     static uint32_t parseAlignment (const std::string& alignment);
     static ImageUniquePtr
     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 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 ImageEffectUniquePtr parseEffect (const JSON& it, const Project& project);
     static std::vector<ImageEffectPassOverrideUniquePtr>
@@ -41,7 +43,10 @@ private:
     static ParticleOperatorUniquePtr parseParticleOperator (const JSON& it, const Properties& properties);
     static ParticleRenderer parseParticleRenderer (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);
 };
 } // 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->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
 	|| header.containerVersion == ContainerVersion_TEXB0002) {
 	result->compression = file.nextUInt32 ();
@@ -193,6 +198,11 @@ void TextureParser::parseTextureHeader (Texture& header, const BinaryReader& fil
     header.width = file.nextUInt32 ();
     header.height = file.nextUInt32 ();
 
+    // wallpaper64.exe sub_14015C760
+    if (header.flags & TextureFlags_Volume) {
+	header.depth = file.nextUInt32 ();
+    }
+
     // ignore some more bytes
     std::ignore = file.nextUInt32 ();
 }

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

@@ -6,8 +6,123 @@
 #include "WallpaperEngine/FileSystem/Container.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include <sstream>
+
 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) {
     switch (project.type) {
 	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 camera = scene.require ("camera", "Scenes must have a camera 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& 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> (
         WallpaperData {
@@ -40,38 +160,63 @@ SceneUniquePtr WallpaperParser::parseScene (const JSON& file, Project& project)
                 .skylight = general.user ("skylightcolor", properties, glm::vec3 (0.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 = {
                 .fade = general.user ("camerafade", properties, false),
                 .preview = general.optional ("camerapreview", false),
                 .bloom = {
                     .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 = {
                     .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 = {
                     .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 = {
                     .center = camera.require <glm::vec3> ("center", "Camera must have a center 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"),
                 },
+                .paths = parseCameraPaths (camera, project),
                 .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),

+ 54 - 1
src/WallpaperEngine/Maths.cpp

@@ -1,5 +1,7 @@
 #include "Maths.h"
 
+#include <cmath>
+
 using namespace WallpaperEngine::Maths;
 
 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);
 	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);
 float lerp (float t, float a, float b);
 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 () {
+    if (this->m_depthbuffer != GL_NONE) {
+	glDeleteRenderbuffers (1, &this->m_depthbuffer);
+    }
+
     glDeleteTextures (1, &this->m_texture);
     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 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;
 
+    /** 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 float& getScale () const;
     [[nodiscard]] TextureFormat getFormat () const override;

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

@@ -1,6 +1,13 @@
+#include <cmath>
+#include <tuple>
 #include <vector>
 
+#include <stb_image.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/Render/Wallpapers/CScene.h"
 #include "WallpaperEngine/Render/Wallpapers/CSplat.h"
@@ -11,6 +18,7 @@
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 
 using namespace WallpaperEngine::Render;
+using WallpaperEngine::Data::Utils::BinaryReader;
 
 CWallpaper::CWallpaper (
     const Wallpaper& wallpaperData, RenderContext& context, AudioContext& audioContext,
@@ -59,6 +67,10 @@ CWallpaper::~CWallpaper () {
 
     glDeleteProgram (this->m_shader);
 
+    if (this->m_lutTexture != GL_NONE) {
+	glDeleteTextures (1, &this->m_lutTexture);
+    }
+
     glDeleteBuffers (1, &this->m_texCoordBuffer);
     glDeleteBuffers (1, &this->m_positionBuffer);
     glDeleteVertexArrays (1, &this->m_vaoBuffer);
@@ -105,13 +117,83 @@ void CWallpaper::setupShaders () {
 
     const GLuint fragmentShaderID = glCreateShader (GL_FRAGMENT_SHADER);
 
+    // image filter and color options are assets/shaders/ccsimple.frag
     sourcePointer = "#version 330\n"
 		    "precision highp float;\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"
 		    "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"
-		    "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);
@@ -159,6 +241,13 @@ void CWallpaper::setupShaders () {
     glDeleteShader (fragmentShaderID);
 
     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_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
 	// 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 relBottom = (static_cast<float> (globalPosition.y + logicalSize.y) - spanY) / spanH;
 
@@ -244,6 +338,10 @@ void CWallpaper::render (
 	auto uvs = this->m_state.getTextureUVs ();
 	ustart = uvs.ustart;
 	uend = uvs.uend;
+
+	if (this->m_flipHorizontal) {
+	    std::swap (ustart, uend);
+	}
 	vstart = uvs.vstart;
 	vend = uvs.vend;
     }
@@ -258,14 +356,6 @@ void CWallpaper::render (
 
     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
 	|| vend != this->m_uploadedVend) {
 	glBindBuffer (GL_ARRAY_BUFFER, this->m_texCoordBuffer);
@@ -276,7 +366,7 @@ void CWallpaper::render (
 	this->m_uploadedVend = vend;
     }
 
-    glDrawArrays (GL_TRIANGLES, 0, 6);
+    this->drawOutputQuad ();
 
     if (this->getContext ().getApp ().getContext ().settings.render.debug.brightnessLog) {
 	static uint32_t brightnessFrameCounter = 0;
@@ -299,20 +389,55 @@ void CWallpaper::render (
 #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::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 height = this->getCanvasHeight ();
     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
     );
 
+    if (depth) {
+	sceneFBO->attachDepthBuffer ();
+    }
+
+    this->m_sceneFBO = sceneFBO;
+
     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 {
     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/RenderContext.h"
 
+#include "WallpaperEngine/Data/Model/ImageAdjustments.h"
 #include "WallpaperEngine/Data/Model/Wallpaper.h"
 #include "WallpaperEngine/Media/MediaSource.h"
 
@@ -80,6 +81,21 @@ public:
      */
     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 getWallpaperTexture () 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;
 
-    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;
 
@@ -130,6 +148,11 @@ private:
     GLuint m_positionBuffer = GL_NONE;
     GLuint m_shader = 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_TexCoord = GL_NONE;
     GLuint m_destFramebuffer = GL_NONE;
@@ -144,6 +167,21 @@ private:
     AudioContext& m_audioContext;
     WallpaperState m_state;
     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;
     // Avoids redundant renderFrame calls when the same wallpaper is shared across viewports (span mode)
     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/gtc/matrix_transform.hpp>
 
@@ -7,7 +10,7 @@ using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
 
 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)
     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::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; }
 
@@ -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::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::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; }
 
 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_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->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
      */
     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& 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::mat4& getProjection () const;
     [[nodiscard]] const glm::mat4& getLookAt () const;
     [[nodiscard]] Wallpapers::CScene& getScene () 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 getHeight () const;
     [[nodiscard]] float getCanvasWidth () const;
@@ -42,14 +53,41 @@ public:
     [[nodiscard]] float getNearZ () 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:
     float m_width;
     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_canvasHeight = 0.0f;
     bool m_isOrthogonal = false;
+    bool m_isPerspective = false;
+    glm::mat4 m_perspective = {};
+    glm::mat4 m_view = {};
     glm::mat4 m_projection = {};
     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;
     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 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

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

@@ -1,10 +1,13 @@
 #include "WaylandOutputViewport.h"
 #include "WallpaperEngine/Logging/Log.h"
 
+#include <unistd.h>
+
 #define class _class
 #define namespace _namespace
 #define static
 extern "C" {
+#include "color-management-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "xdg-output-unstable-v1-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);
 }
 
+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 () {
     surface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
 
@@ -225,6 +349,12 @@ void WaylandOutputViewport::setupLS () {
     }
 #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_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 zxdg_output_v1;
 struct zxdg_output_manager_v1;
+struct wp_color_management_output_v1;
+struct wp_color_management_surface_v1;
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 struct org_kde_plasma_surface;
 #endif
@@ -62,6 +64,25 @@ namespace Output {
 
 	void setupLS ();
 	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 swapOutput () override;

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

@@ -40,6 +40,11 @@ public:
     [[nodiscard]] virtual void* getWaylandDisplay () const { return nullptr; }
     /** The X11 Display, if rendering goes through X11 (mpv needs it for zero-copy hwdec) */
     [[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;
     [[nodiscard]] WallpaperApplication& getApp () const;
     [[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);
     }
 
-    // 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 ()) {
 	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 static
 extern "C" {
+#include "color-management-v1-protocol.h"
 #include "wlr-layer-shell-unstable-v1-protocol.h"
 #include "xdg-output-unstable-v1-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 };
 
+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
 handleGlobal (void* data, struct wl_registry* registry, uint32_t name, const char* interface, uint32_t version) {
     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*> (
 	    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
     } else if (strcmp (interface, org_kde_plasma_shell_interface.name) == 0) {
 	driver->getWaylandContext ()->plasmaShell = static_cast<org_kde_plasma_shell*> (
@@ -211,7 +259,35 @@ void WaylandOpenGLDriver::initEGL () {
 	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 ();
 	sLog.exception ("eglChooseConfig failed!");
     }
@@ -269,6 +345,16 @@ void WaylandOpenGLDriver::onLayerClose (Output::WaylandOutputViewport* viewport)
 	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) {
 	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),
     m_context (context), m_mouseInput (*this) {
     initWaylandRegistry ();
+    initColorManagement ();
     initEGL ();
     setupOutputLayerSurfaces ();
     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 () {
     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_surface_v1;
 struct zxdg_output_manager_v1;
+struct wp_color_manager_v1;
+struct wp_image_description_v1;
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 struct org_kde_plasma_shell;
 #endif
@@ -59,11 +61,20 @@ public:
 	zwlr_layer_shell_v1* layerShell = nullptr;
 	wl_seat* seat = nullptr;
 	zxdg_output_manager_v1* xdgOutputManager = nullptr;
+	/** Only bound with --hdr */
+	wp_color_manager_v1* colorManager = nullptr;
 #ifdef ENABLE_KDE_EXPERIMENTAL_FEATURES
 	org_kde_plasma_shell* plasmaShell = nullptr;
 #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);
     ~WaylandOpenGLDriver ();
 
@@ -79,6 +90,9 @@ public:
     void dispatchEventQueue () override;
     [[nodiscard]] void* getProcAddress (const char* name) 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*);
     Output::WaylandOutputViewport* surfaceToViewport (const wl_surface*) const;
@@ -90,6 +104,9 @@ public:
 
     std::vector<Output::WaylandOutputViewport*> m_screens = {};
 
+    ColorSupport colorSupport = {};
+    bool hdrDescriptionReady = false;
+
 private:
     Output::WaylandOutput m_output;
     SEGLContext m_eglContext = {};
@@ -101,6 +118,9 @@ private:
     void initEGL ();
     void initGLEW ();
     void finishEGL () const;
+    void initColorManagement ();
+
+    wp_image_description_v1* m_hdrDescription = nullptr;
 
     uint32_t m_frameCounter = 0;
     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 ("angles", *image.angles->value);
     this->registerProperty ("visible", *image.visible->value);
+    this->registerProperty ("copybackground", *image.copyBackground->value);
     this->registerProperty ("alpha", *image.alpha->value);
     this->registerProperty ("color", *image.color->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 ();
 
+    // 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 (
-	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 (
-	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,
@@ -1005,8 +1008,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     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);
 
-    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
     this->m_modelViewProjectionScreenInverse = glm::inverse (this->m_modelViewProjectionScreen);
     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)
     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 {
 	    .id = -1,
-	    .combos = colorBlendMode > 0 ? ComboMap { { "BLENDMODE", colorBlendMode } } : ComboMap {},
+	    .combos = combos,
 	    .constants = {},
 	    .textures = {},
 	});
@@ -1855,6 +1874,10 @@ void CImage::setup () {
 	    *this, std::make_shared<FBOProvider> (this), **this->m_materials.colorBlending.material->passes.begin (),
 	    *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 ()) {
@@ -2026,6 +2049,12 @@ void CImage::setupPasses () {
 	    &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->setInput (asInput);
 	pass->setPreviousInput (inTargetEffectSequence ? effectInput : nullptr);
@@ -2150,6 +2179,13 @@ void CImage::render () {
     glPushDebugGroup (GL_DEBUG_SOURCE_APPLICATION, 0, -1, str.c_str ());
 #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 ();
     const auto end = this->m_passes.end ();
 
@@ -2158,6 +2194,11 @@ void CImage::render () {
 	    glColorMask (true, true, true, false);
 	}
 
+	if (*cur == skippedBasePass && std::next (cur) != end) {
+	    (*cur)->clearDestination ();
+	    continue;
+	}
+
 	(*cur)->render ();
 
     }
@@ -2181,11 +2222,16 @@ const float& CImage::getAlpha () const {
     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 {
     // "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;
 }
 
@@ -2358,13 +2404,17 @@ float clampParallaxAxis (float offset, float edgeA, float edgeB, float sceneExte
 void CImage::updateScreenSpacePosition () {
     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;
-    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::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);
     }
 
@@ -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()
     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) {
     const auto width = static_cast<float> (this->getScene ().getWidth ());
     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_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
     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;
+    /** 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
@@ -228,6 +236,7 @@ private:
     std::shared_ptr<const CFBO> m_currentSubFBO = nullptr;
 
     const Image& m_image;
+    mutable glm::vec3 m_colorCache {};
     mutable glm::vec4 m_color4Cache {};
     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

File diff suppressed because it is too large
+ 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/vec4.hpp>
 #include <memory>
+#include <optional>
 #include <random>
 #include <vector>
 
@@ -24,6 +25,8 @@ constexpr uint32_t DEFAULT_MAX_PARTICLES = 1000;
 
 struct ParticleInstance {
     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 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 */
     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 };
 
     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 {
     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 };
     bool linkMouse { 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)>;
@@ -116,6 +140,11 @@ protected:
 
     EmitterFunc createBoxEmitter (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 (
 	int mode, const glm::vec2& bounds, float exponent, int frequencyStart, int frequencyEnd
@@ -131,6 +160,14 @@ protected:
     InitializerFunc createTurbulentVelocityRandomInitializer (const TurbulentVelocityRandomInitializer& init);
     InitializerFunc
     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 createAngularMovementOperator (const AngularMovementOperator& op);
@@ -144,18 +181,73 @@ protected:
     OperatorFunc createOscillateAlphaOperator (const OscillateAlphaOperator& op);
     OperatorFunc createOscillateSizeOperator (const OscillateSizeOperator& 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 renderRope ();
+    void buildRopeTrail (uint32_t& vertexIndex, uint32_t& indexOffset);
+    [[nodiscard]] float ropeUVScale () const;
     void setupPass ();
     void setupGeometryCallbacks ();
     void setupParticleUniforms ();
     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 applyParallaxToModelMatrix ();
     void updateParticleViewProjection ();
     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:
     const Particle& m_particle;
 
@@ -234,6 +326,16 @@ private:
     bool m_ropeUVScrolling { false };
     bool m_ropeUVSmoothing { true }; // rope only
     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 ROPE_FLOATS_PER_VERTEX = 26;
@@ -251,5 +353,82 @@ private:
 
     bool m_initialized { false };
     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

File diff suppressed because it is too large
+ 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/Effects/CPass.h"
+#include "WallpaperEngine/Render/Objects/TextLayout.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.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 {
 class TextureProvider;
 }
@@ -30,15 +25,10 @@ namespace WallpaperEngine::Render::Objects {
 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 {
     friend CObject;
@@ -58,38 +48,63 @@ public:
     [[nodiscard]] const glm::vec3& getCompositeColor () const override;
 
 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 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 ();
 
     const Text& m_text;
     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;
 
-    FT_Library m_ftLibrary = nullptr;
-    FT_Face m_ftFace = nullptr;
-    std::vector<uint8_t> m_fontData;
     /** Backs the scripts' layer.font */
     DynamicValue m_font;
     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_sceneSpacePosition = 0;
-    GLuint m_texcoordCopy = 0;
+    GLuint m_compositePosition = 0;
+    GLuint m_quadTexcoords = 0;
 
     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_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_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;
+    mutable glm::vec3 m_colorCache {};
     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;
 };
 } // 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
-    delete this->m_shader;
     this->m_shader = nullptr;
+    this->m_compiled = nullptr;
 
     glDeleteVertexArrays (1, &m_vao);
     this->m_vao = GL_NONE;
 
+    if (this->releaseSharedProgram ()) {
+	return;
+    }
+
     if (!glIsProgram (this->m_programID)) {
 	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
     // accumulate stale alpha across frames. The shared scene FBO must not be cleared here though,
     // 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] = {};
 	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);
 	glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
     }
@@ -681,6 +689,20 @@ void CPass::render () {
     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; }
 
 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::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::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::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; }
 
 const MaterialPass& CPass::getPass () const { return this->m_pass; }
@@ -832,6 +868,11 @@ void CPass::setupShaders () {
 	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
     if (texture0 != nullptr) {
 	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
     // (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)) {
-	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) {
 	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 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;
     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) {
 	const auto logBuffer = new char[infoLogLength + 1];
 	memset (logBuffer, 0, infoLogLength + 1);
-	glGetProgramInfoLog (this->m_programID, infoLogLength, nullptr, logBuffer);
+	glGetProgramInfoLog (program, infoLogLength, nullptr, logBuffer);
 	const std::string message = logBuffer;
 	delete[] logBuffer;
 	if (result == GL_FALSE) {
@@ -916,36 +1099,19 @@ void CPass::setupShaders () {
     }
 
 #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, fragmentShaderID, -1, (shaderName + ".frag").c_str ());
 #endif /* DEBUG */
 
     // 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 (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) {
@@ -1223,6 +1389,11 @@ void CPass::setupUniforms () {
     this->addUniform ("g_LightSkylightColor", sceneData.colors.skylight->value->getVec3 ());
     this->addUniform ("g_LightsPosition", UniformType::Vector3, scene.getLightsPosition (), 4);
     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_AltNormalModelMatrix", &this->m_lightingNormalMatrix);
     this->addUniform ("g_AltViewProjectionMatrix", &this->m_lightingViewProjectionMatrix);

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

@@ -1,5 +1,7 @@
 #pragma once
 
+#include <unordered_map>
+
 #include <functional>
 #include <glm/gtc/type_ptr.hpp>
 #include <utility>
@@ -32,6 +34,8 @@ public:
     ~CPass ();
 
     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 */
     void updatePlaybackTextures () const;
 
@@ -43,6 +47,8 @@ public:
     void setModelViewProjectionMatrix (const glm::mat4* projection);
     void setModelViewProjectionMatrixInverse (const glm::mat4* projection);
     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);
     /**
      * 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 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);
     [[nodiscard]] BlendingMode getBlendingMode () 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 glm::vec3* 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);
 
 private:
@@ -137,6 +148,32 @@ private:
     };
 
     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 ();
     /** Sets TEX<slot>FORMAT for the shader's formatcombo samplers, true if a combo changed */
     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 double* value, int count = 1);
     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 double** value);
     void addUniform (const std::string& name, const float** value);
@@ -213,12 +249,15 @@ private:
     std::map<std::string, int> m_combos = {};
     std::vector<AttribEntry*> m_attribs = {};
     std::map<std::string, UniformEntry*> m_uniforms = {};
+    const glm::vec4* m_clearColor = nullptr;
+    bool m_keepDestination = false;
     std::map<std::string, ReferenceUniformEntry*> m_referenceUniforms = {};
     BlendingMode m_blendingmode = BlendingMode_Normal;
     const glm::mat4* m_modelViewProjectionMatrix;
     const glm::mat4* m_modelViewProjectionMatrixInverse;
     const glm::mat4* m_modelMatrix;
     const glm::mat4* m_lightingModelMatrix;
+    bool m_fogWorld = false;
     const glm::mat3* m_lightingNormalMatrix;
     const glm::mat4* m_lightingViewProjectionMatrix;
     const glm::mat4* m_viewProjectionMatrix;
@@ -237,6 +276,7 @@ private:
 
     void trackPlayback (const std::shared_ptr<const TextureProvider>& texture);
 
+    std::shared_ptr<CompiledShader> m_compiled = nullptr;
     Render::Shaders::Shader* m_shader = nullptr;
 
     std::shared_ptr<const CFBO> m_drawTo = nullptr;
@@ -245,6 +285,8 @@ private:
     glm::vec4 m_texture0Resolution = {};
 
     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_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 */
 
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
+	ref->second->setOutputHDR (viewport->isHDR ());
 	ref->second->render (
 	    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 ();
 
     if (const auto ref = this->m_wallpapers.find (viewport->name); ref != this->m_wallpapers.end ()) {
+	ref->second->setOutputHDR (viewport->isHDR ());
 	ref->second->render (
 	    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->preprocessBalanceConditionals ();
     this->preprocessSwizzledDeclarations ();
+    this->preprocessScalarSwizzles ();
 
     const std::string from = "gl_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 {
     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*([^;{}]+);)");

+ 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;`),
      *  which the original compiler tolerates and glslang rejects. Drops the swizzle from the declaration. */
     void preprocessSwizzledDeclarations ();
+    void preprocessScalarSwizzles ();
     /** Resolves a #require module name (e.g. "LightingV1") to generated GLSL code, or "" if unknown */
     [[nodiscard]] std::string resolveRequireModule (const std::string& moduleName) const;
     /** Generates the LightingV1 module stub (PerformLighting_V1 function) */

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

@@ -1,6 +1,8 @@
 #pragma once
 
+#include <algorithm>
 #include <cmath>
+#include <cstdint>
 #include <glm/glm.hpp>
 
 namespace WallpaperEngine::Render::Utils {
@@ -234,4 +236,141 @@ inline float simplexNoise3D (float x, float y, float z) {
     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

+ 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);
 
     [[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 ();
 

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

@@ -1,5 +1,8 @@
 #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/CMesh.h"
 #include "WallpaperEngine/Render/Objects/CParticle.h"
 #include "WallpaperEngine/Render/Objects/CSound.h"
 #include "WallpaperEngine/Render/Objects/CText.h"
@@ -34,9 +37,13 @@ CScene::CScene (
 ) : CWallpaper (wallpaper, context, audioContext, scalingMode, clampMode) {
     // caller should check this, if not a std::bad_cast is good to throw
     auto scene = wallpaper.as<Scene> ();
+    this->m_startTime = g_Time;
 
     this->m_scriptEngine = std::make_unique<Scripting::ScriptEngine> (*this, context.getMediaSource ());
     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 height = scene->camera.projection.height;
@@ -54,17 +61,30 @@ CScene::CScene (
     this->m_parallaxBias = { 0, 0 };
     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
-    this->setupFramebuffers ();
+    this->setupFramebuffers (perspective, this->m_hdr ? TextureFormat_RGBA16161616f : TextureFormat_ARGB8888);
 
     const uint32_t sceneWidth = this->m_camera->getCanvasWidth ();
     const uint32_t sceneHeight = this->m_camera->getCanvasHeight ();
@@ -75,6 +95,14 @@ CScene::CScene (
     );
     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 ();
 
     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_bloomObject = this->createObject (*this->m_bloomObjectData);
 
@@ -169,6 +197,19 @@ CScene::~CScene () {
 
     this->m_objectsByRenderOrder.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) {
@@ -243,6 +284,12 @@ Render::CObject* CScene::dispatchObjectType (const Object& object) {
 	renderObject = new Objects::CText (*this, *object.as<Text> ());
     } else if (object.is<Light> ()) {
 	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> ()) {
 	const auto& particleData = *object.as<Particle> ();
 
@@ -352,39 +399,11 @@ void CScene::renderFrame (const glm::ivec4& viewport) {
 void CScene::renderFrameSteps (const glm::ivec4& 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
     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 (); });
     this->updateLights ();
 
@@ -413,6 +432,8 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
     glBindFramebuffer (GL_FRAMEBUFFER, this->getWallpaperFramebuffer ());
     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);
 
     for (const auto& cur : this->m_objectsByRenderOrder) {
@@ -434,8 +455,631 @@ void CScene::renderFrameSteps (const glm::ivec4& viewport) {
 	    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 (); });
     }
+
+    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 () {
@@ -457,55 +1101,84 @@ void CScene::dispatchCursorEvents () {
     auto& engine = this->getScriptEngine ();
     // handlers are free to create layers, which would move things around under a live iteration
     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;
 	}
 
 	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;
 	}
 
-	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) {
@@ -514,6 +1187,12 @@ void CScene::updateMouse (const glm::ivec4& viewport) {
     this->m_mousePositionLast = this->m_mousePosition;
 
     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)
     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::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 {
     const float dt = g_Time - g_TimeLast;
     // 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 ();
     }
 
+    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
     // color rides in the .w of the other three
     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/CWallpaper.h"
+#include "WallpaperEngine/Render/Volumetrics.h"
 #include "WallpaperEngine/Scripting/ScriptEngine.h"
 
 #include <set>
@@ -41,6 +42,8 @@ public:
     [[nodiscard]] float getTime () const;
     [[nodiscard]] float getDeltaTime () 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* getMousePositionLast () const;
@@ -56,6 +59,30 @@ public:
     [[nodiscard]] glm::vec2 getParallaxOffset (const Data::Model::Object& object) 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]] CObject* getObject (int id);
     /** 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) */
     [[nodiscard]] const glm::vec3* getLightsPosition () const { return this->m_lightsPosition; }
     [[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 */
     [[nodiscard]] std::vector<CObject*> getLayers () 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 */
     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;
 
     Render::CObject* createObject (const Object& object);
@@ -118,6 +161,34 @@ private:
     std::set<int> m_objectsInRenderOrderWalk = {};
     std::map<int, bool> m_soundPlayRequests = {};
     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 = {};
     // owns the synthesized model data backing createLayer()'d objects; must outlive the CObject
     // built from it (same pattern as m_bloomObjectData)
@@ -133,8 +204,10 @@ private:
     /** Wallpaper position offset (WallpaperState) riding through the parallax path */
     glm::vec2 m_parallaxBias = {};
     bool m_cursorLeftDown = false;
+    float m_startTime = 0.0f;
     glm::vec3 m_lightsPosition[4] = {};
     glm::vec4 m_lightsColorPremultiplied[3] = {};
+    glm::vec4 m_lightsColorRadius[4] = {};
     glm::vec2 m_cursorLastScenePosition = {};
     // object ids the pointer is over / that the current press started on
     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 WallpaperState::TextureUVsScaling& scalingMode, const uint32_t& 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
 	= this->getVideo ().project.assetLocator->physicalPath (this->getVideo ().filename);
@@ -28,6 +32,10 @@ CVideo::CVideo (
     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->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
     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;
     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 (
     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);
     glm::vec4 target = current;
-    const bool wrap = m_definition->mode == PropertyAnimation::Mode::Loop && binding.data->wrapLoop;
 
     for (int component = 0; component < count; component++) {
 	const auto& keys = binding.data->curves[component];
@@ -170,7 +186,9 @@ void AnimationClock::applyBinding (Binding& binding) const {
 	    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) {
 	    // 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 {
 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
  * 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 ("angles", *object.groupAngles->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) {

+ 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;
 }
 
+void GLPlayer::setLinearOutput () { this->m_linearOutput = true; }
+
 void GLPlayer::clearUntimed () {
     if (this->m_handle) {
 	sLog.exception ("Cannot set untimed mode after playback has started");
@@ -238,7 +240,10 @@ void GLPlayer::render () const {
 	this->m_height = height;
 	this->m_needsRedraw = true;
 	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
@@ -255,7 +260,7 @@ void GLPlayer::render () const {
     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),
-			 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
     int flip_y = 0;
@@ -303,7 +308,10 @@ void GLPlayer::prepareGL () {
     glGenFramebuffers (1, &this->m_fbo);
     glBindFramebuffer (GL_FRAMEBUFFER, this->m_fbo);
     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 };
     glFramebufferTexture2D (GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, this->m_outputTexture, 0);
     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, "cursor-autohide", "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, "profile", "fast");
     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 ();
     /** For videos that are only ever a texture. Must be called before playback starts */
     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 setMuted ();
     void clearMuted ();
@@ -85,6 +90,7 @@ protected:
     bool m_paused = false;
     bool m_loop = 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
     mutable bool m_needsRedraw = true;
     mutable bool m_fileLoaded = false;

+ 28 - 9
tools/headless_render.sh

@@ -1,8 +1,10 @@
 #!/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:
 #   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 \
 #       /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:
 # - 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.
 # - 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).
+# - HEADLESS_RENDER_SIZE=WxH changes the window size (default 1920x1080).
 # - --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,
 #   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
 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_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_PID=$!
     trap 'kill "$XVFB_PID" 2>/dev/null || true' EXIT
@@ -57,11 +76,11 @@ fi
 
 rm -f "$OUTPUT"
 
-DISPLAY="$XVFB_DISPLAY" XDG_SESSION_TYPE=x11 \
+"${SESSION[@]}" \
 LD_LIBRARY_PATH="$BINARY_DIR${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \
 LD_PRELOAD="$SHIM_SO" \
 timeout "${HEADLESS_RENDER_TIMEOUT:-60}" "$BINARY" \
-    --window 0x0x1920x1080 \
+    --window "0x0x${HEADLESS_RENDER_SIZE:-1920x1080}" \
     --screenshot "$OUTPUT" \
     --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
 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
 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,
@@ -168,6 +172,8 @@ def x_sockets ():
 class Display:
     """One Xvfb server per worker so renders can run side by side."""
 
+    process = None
+
     def __init__ (self, first, width, height):
         number = first
 
@@ -196,6 +202,9 @@ class Display:
                 raise RuntimeError ('could not start Xvfb')
 
     def close (self):
+        if self.process is None:
+            return
+
         self.process.terminate ()
         try:
             self.process.wait (5)
@@ -266,8 +275,8 @@ def render (args, preload, display, wallpaper):
     env = dict (os.environ)
     env.pop ('WAYLAND_DISPLAY', None)
     env.pop ('WAYLAND_SOCKET', None)
+    env.pop ('DISPLAY', None)
     env.update ({
-        'DISPLAY': display.name,
         'XDG_SESSION_TYPE': 'x11',
         'LD_LIBRARY_PATH': ':'.join (filter (None, [str (args.binary.parent), os.environ.get ('LD_LIBRARY_PATH')])),
         'LD_PRELOAD': preload,
@@ -275,9 +284,15 @@ def render (args, preload, display, wallpaper):
         'LWE_FIXED_CLOCK': str (args.clock),
         '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)
 
@@ -316,6 +331,10 @@ def render (args, preload, display, wallpaper):
 
 
 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.out = args.out.resolve ()
     args.out.mkdir (parents = True, exist_ok = True)
@@ -335,6 +354,10 @@ def cmd_run (args):
 
     try:
         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)
             opened.append (display)
             displays.put (display)
@@ -353,6 +376,7 @@ def cmd_run (args):
             'clock': args.clock,
             'size': [args.width, args.height],
             'repeat': args.repeat,
+            'renderer': 'gpu' if args.gpu else 'llvmpipe',
             'started': time.strftime ('%Y-%m-%d %H:%M:%S'),
         }, indent = 1))
 
@@ -456,7 +480,18 @@ def load_result (folder):
         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):
+    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 ()
     shutil.rmtree (report, ignore_errors = True)
     (report / 'img').mkdir (parents = True)
@@ -648,7 +683,9 @@ def main ():
     run.add_argument ('--variants', type = Path, default = HERE / 'variants.json',
                       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 ('--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 ('--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')

Some files were not shown because too many files changed in this diff