Przeglądaj źródła

Add image puppet and parallax compatibility (#568)

Co-authored-by: Gaiser147 <Gaiser147@users.noreply.github.com>
Graiser147 4 miesięcy temu
rodzic
commit
8627c1cb1a

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

@@ -26,6 +26,10 @@ struct ObjectData {
     std::optional<int> parent;
     /** The point of origin of the object */
     UserSettingUniquePtr origin;
+    /** Transform fields for generic scene/group objects. Typed objects keep their own transform fields. */
+    UserSettingUniquePtr groupScale;
+    UserSettingUniquePtr groupAngles;
+    UserSettingUniquePtr groupVisible;
 };
 
 /**

+ 16 - 6
src/WallpaperEngine/Data/Parsers/ObjectParser.cpp

@@ -103,10 +103,13 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 	basedata = ObjectData {
 	    .id = it.require<int> ("id", "Object must have an id"),
 	    .name = it.require<std::string> ("name", "Object must have a name"),
-	    .dependencies = parseDependencies (it),
-	    .parent = it.optional<int> ("parent"),
-	    .origin = it.user ("origin", project.properties, glm::vec3 (0.0f)),
-	};
+		.dependencies = parseDependencies (it),
+		.parent = it.optional<int> ("parent"),
+		.origin = it.user ("origin", project.properties, glm::vec3 (0.0f)),
+		.groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
+		.groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
+		.groupVisible = it.user ("visible", project.properties, true),
+	    };
     } catch (const std::exception& e) {
 	sLog.error ("Error parsing object base data: ", e.what ());
 	const auto idIt = it.find ("id");
@@ -120,8 +123,15 @@ ObjectUniquePtr ObjectParser::parse (const JSON& it, const Project& project) {
 		name = std::to_string (nameIt->get<int> ());
 	    }
 	}
-	basedata = ObjectData { .id = id, .name = name, .dependencies = {} };
-    }
+	    basedata = ObjectData {
+		.id = id,
+		.name = name,
+		.dependencies = {},
+		.groupScale = it.user ("scale", project.properties, glm::vec3 (1.0f)),
+		.groupAngles = it.user ("angles", project.properties, glm::vec3 (0.0f)),
+		.groupVisible = it.user ("visible", project.properties, true),
+	    };
+	}
 
     if (imageIt != it.end () && imageIt->is_string ()) {
 	return parseImage (it, project, std::move (basedata), *imageIt);

+ 118 - 3
src/WallpaperEngine/Input/Drivers/WaylandMouseInput.cpp

@@ -1,13 +1,61 @@
 #include "WaylandMouseInput.h"
 #include "WallpaperEngine/Render/Drivers/WaylandOpenGLDriver.h"
 #include <glm/common.hpp>
+#include <chrono>
+#include <cstdlib>
+#include <cstring>
+#include <regex>
+#include <string>
+#include <sys/socket.h>
+#include <sys/time.h>
+#include <sys/un.h>
+#include <unistd.h>
 
 using namespace WallpaperEngine::Input::Drivers;
 
 WaylandMouseInput::WaylandMouseInput (const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& driver) :
     m_waylandDriver (driver) { }
 
-void WaylandMouseInput::update () { }
+void WaylandMouseInput::update () {
+    if (!this->m_waylandDriver.getApp ().getContext ().settings.mouse.enabled) {
+	this->m_pos = { 0, 0 };
+	return;
+    }
+
+    if (m_waylandDriver.viewportInFocus && m_waylandDriver.viewportInFocus->rendering) {
+	this->m_pos = m_waylandDriver.viewportInFocus->mousePos;
+	return;
+    }
+
+    const auto now = std::chrono::steady_clock::now ();
+    if (now - this->m_lastHyprlandQuery < std::chrono::milliseconds (16)) {
+	return;
+    }
+    this->m_lastHyprlandQuery = now;
+
+    const auto globalCursor = this->queryHyprlandCursorPosition ();
+    if (!globalCursor.has_value ()) {
+	this->m_pos = { 0, 0 };
+	return;
+    }
+
+    for (const auto* viewport : this->m_waylandDriver.m_screens) {
+	if (!viewport || viewport->size.x <= 0 || viewport->size.y <= 0) {
+	    continue;
+	}
+
+	const double localX = globalCursor->x - viewport->position.x;
+	const double localY = globalCursor->y - viewport->position.y;
+	if (localX < 0.0 || localY < 0.0 || localX > viewport->size.x || localY > viewport->size.y) {
+	    continue;
+	}
+
+	this->m_pos = { localX * viewport->scale, (viewport->size.y - localY) * viewport->scale };
+	return;
+    }
+
+    this->m_pos = { 0, 0 };
+}
 
 glm::dvec2 WaylandMouseInput::position () const {
     if (!this->m_waylandDriver.getApp ().getContext ().settings.mouse.enabled) {
@@ -18,7 +66,7 @@ glm::dvec2 WaylandMouseInput::position () const {
 	return m_waylandDriver.viewportInFocus->mousePos;
     }
 
-    return { 0, 0 };
+    return this->m_pos;
 }
 
 WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::leftClick () const {
@@ -29,10 +77,77 @@ WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::leftClick () const {
     return MouseClickStatus::Released;
 }
 
+std::optional<glm::dvec2> WaylandMouseInput::queryHyprlandCursorPosition () const {
+    const char* signature = std::getenv ("HYPRLAND_INSTANCE_SIGNATURE");
+    const char* runtime = std::getenv ("XDG_RUNTIME_DIR");
+    if (!signature || !runtime) {
+	return std::nullopt;
+    }
+
+    const std::string socketPath = std::string (runtime) + "/hypr/" + signature + "/.socket.sock";
+
+    int fd = socket (AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
+    if (fd < 0) {
+	return std::nullopt;
+    }
+
+    timeval timeout {};
+    timeout.tv_usec = 50000;
+    if (
+	setsockopt (fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof (timeout)) != 0
+	|| setsockopt (fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof (timeout)) != 0
+    ) {
+	close (fd);
+	return std::nullopt;
+    }
+
+    sockaddr_un addr {};
+    addr.sun_family = AF_UNIX;
+    if (socketPath.size () >= sizeof (addr.sun_path)) {
+	close (fd);
+	return std::nullopt;
+    }
+    std::strncpy (addr.sun_path, socketPath.c_str (), sizeof (addr.sun_path) - 1);
+
+    if (connect (fd, reinterpret_cast<sockaddr*> (&addr), sizeof (addr)) != 0) {
+	close (fd);
+	return std::nullopt;
+    }
+
+    constexpr const char* request = "j/cursorpos";
+    if (send (fd, request, std::strlen (request), 0) < 0) {
+	close (fd);
+	return std::nullopt;
+    }
+    shutdown (fd, SHUT_WR);
+
+    std::string response;
+    char buffer[256];
+    ssize_t readBytes = 0;
+    while ((readBytes = recv (fd, buffer, sizeof (buffer), 0)) > 0) {
+	response.append (buffer, static_cast<std::size_t> (readBytes));
+    }
+    close (fd);
+
+    static const std::regex xRegex (R"("x"\s*:\s*(-?\d+(?:\.\d+)?))");
+    static const std::regex yRegex (R"("y"\s*:\s*(-?\d+(?:\.\d+)?))");
+    std::smatch xMatch;
+    std::smatch yMatch;
+    if (!std::regex_search (response, xMatch, xRegex) || !std::regex_search (response, yMatch, yRegex)) {
+	return std::nullopt;
+    }
+
+    try {
+	return glm::dvec2 { std::stod (xMatch[1].str ()), std::stod (yMatch[1].str ()) };
+    } catch (const std::exception&) {
+	return std::nullopt;
+    }
+}
+
 WallpaperEngine::Input::MouseClickStatus WaylandMouseInput::rightClick () const {
     if (m_waylandDriver.viewportInFocus && m_waylandDriver.viewportInFocus->rendering) {
 	return m_waylandDriver.viewportInFocus->rightClick;
     }
 
     return MouseClickStatus::Released;
-}
+}

+ 6 - 1
src/WallpaperEngine/Input/Drivers/WaylandMouseInput.h

@@ -5,6 +5,8 @@
 #include "WallpaperEngine/Input/MouseInput.h"
 
 #include <glm/vec2.hpp>
+#include <chrono>
+#include <optional>
 
 namespace WallpaperEngine::Render::Drivers {
 class WaylandOpenGLDriver;
@@ -39,13 +41,16 @@ public:
     [[nodiscard]] MouseClickStatus rightClick () const override;
 
 private:
+    [[nodiscard]] std::optional<glm::dvec2> queryHyprlandCursorPosition () const;
+
     /**
      * Wayland: Driver
      */
     const WallpaperEngine::Render::Drivers::WaylandOpenGLDriver& m_waylandDriver;
 
     glm::dvec2 m_pos = {};
+    std::chrono::steady_clock::time_point m_lastHyprlandQuery = {};
 };
 } // namespace WallpaperEngine::Input::Drivers
 
-#endif /* ENABLE_WAYLAND */
+#endif /* ENABLE_WAYLAND */

+ 5 - 3
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.cpp

@@ -34,7 +34,8 @@ static void geometry (
     void* data, wl_output* output, int32_t x, int32_t y, int32_t width_mm, int32_t height_mm, int32_t subpixel,
     const char* make, const char* model, int32_t transform
 ) {
-    // ignored
+    const auto viewport = static_cast<WaylandOutputViewport*> (data);
+    viewport->position = { x, y };
 }
 
 static void mode (void* data, wl_output* output, uint32_t flags, int32_t width, int32_t height, int32_t refresh) {
@@ -116,7 +117,8 @@ WaylandOutputViewport::WaylandOutputViewport (
 void WaylandOutputViewport::setupLS () {
     surface = wl_compositor_create_surface (m_driver->getWaylandContext ()->compositor);
     layerSurface = zwlr_layer_shell_v1_get_layer_surface (
-	m_driver->getWaylandContext ()->layerShell, surface, output, ZWLR_LAYER_SHELL_V1_LAYER_BOTTOM,
+	// Background layer lets desktop shells keep icons and panels above the wallpaper surface.
+	m_driver->getWaylandContext ()->layerShell, surface, output, ZWLR_LAYER_SHELL_V1_LAYER_BACKGROUND,
 	"linux-wallpaperengine"
     );
 
@@ -208,4 +210,4 @@ void WaylandOutputViewport::resize () {
     wl_egl_window_resize (this->eglWindow, this->size.x * this->scale, this->size.y * this->scale, 0, 0);
 
     this->getDriver ()->getOutput ().reset ();
-}
+}

+ 2 - 1
src/WallpaperEngine/Render/Drivers/Output/WaylandOutputViewport.h

@@ -34,6 +34,7 @@ namespace Output {
 
 	wl_output* output = nullptr;
 	glm::ivec2 size = {};
+	glm::ivec2 position = {};
 	uint32_t waylandName;
 	int scale = 1;
 	bool initialized = false;
@@ -73,4 +74,4 @@ namespace Output {
     };
 } // namespace Output
 } // namespace WallpaperEngine::Render::Drivers
-#endif /* ENABLE_WAYLAND */
+#endif /* ENABLE_WAYLAND */

+ 581 - 42
src/WallpaperEngine/Render/Objects/CImage.cpp

@@ -2,13 +2,21 @@
 
 #include "CRenderable.h"
 
+#include <algorithm>
+#include <cmath>
+#include <cstring>
+#include <iterator>
+#include <optional>
 #include <sstream>
 
 #include <glm/gtc/matrix_transform.hpp>
 
+#include "WallpaperEngine/Data/Model/DynamicValue.h"
 #include "WallpaperEngine/Data/Model/Material.h"
 #include "WallpaperEngine/Data/Model/Object.h"
+#include "WallpaperEngine/Data/Model/UserSetting.h"
 #include "WallpaperEngine/Data/Parsers/MaterialParser.h"
+#include "WallpaperEngine/Logging/Log.h"
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render::Objects;
@@ -16,6 +24,152 @@ using namespace WallpaperEngine::Render::Objects::Effects;
 using namespace WallpaperEngine::Data::Parsers;
 using namespace WallpaperEngine::Data::Builders;
 
+namespace {
+glm::vec2 rotateVec2 (const glm::vec2& value, float angle) {
+    const float cosAngle = std::cos (angle);
+    const float sinAngle = std::sin (angle);
+    return { value.x * cosAngle - value.y * sinAngle, value.x * sinAngle + value.y * cosAngle };
+}
+
+UserSettingUniquePtr makeStaticSetting (const glm::vec3& value) {
+    auto dynamicValue = std::make_unique<DynamicValue> (value);
+    return std::make_unique<UserSetting> (
+	UserSetting { .value = std::move (dynamicValue), .property = nullptr, .condition = std::nullopt }
+    );
+}
+
+UserSettingUniquePtr makeStaticSetting (float value) {
+    auto dynamicValue = std::make_unique<DynamicValue> (value);
+    return std::make_unique<UserSetting> (
+	UserSetting { .value = std::move (dynamicValue), .property = nullptr, .condition = std::nullopt }
+    );
+}
+
+bool isMagentaNeonTint (const glm::vec3& color) {
+    return color.r > 0.55f && color.g < 0.25f && color.b > 0.45f;
+}
+
+std::optional<glm::vec3> findMagentaCompositeTint (const Image& image, const std::vector<int>& skippedEffectIds) {
+    for (const auto& effect : image.effects) {
+	if (std::find (skippedEffectIds.begin (), skippedEffectIds.end (), static_cast<int> (effect->id))
+	    != skippedEffectIds.end ()) {
+	    continue;
+	}
+	if (!effect->visible->value->getBool ()) {
+	    continue;
+	}
+
+	for (const auto& passOverride : effect->passOverrides) {
+	    const auto compositeCombo = passOverride->combos.find ("COMPOSITE");
+	    if (compositeCombo == passOverride->combos.end () || compositeCombo->second != 2) {
+		continue;
+	    }
+
+	    const auto compositeColor = passOverride->constants.find ("compositecolor");
+	    if (compositeColor == passOverride->constants.end () || compositeColor->second == nullptr
+		|| compositeColor->second->value == nullptr) {
+		continue;
+	    }
+
+	    const auto tint = compositeColor->second->value->getVec3 ();
+	    if (isMagentaNeonTint (tint)) {
+		return tint;
+	    }
+	}
+    }
+
+    return std::nullopt;
+}
+
+uint32_t readU32 (const std::vector<char>& data, const size_t offset) {
+    uint32_t value = 0;
+    std::memcpy (&value, data.data () + offset, sizeof (value));
+    return value;
+}
+
+float readFloat (const std::vector<char>& data, const size_t offset) {
+    float value = 0.0f;
+    std::memcpy (&value, data.data () + offset, sizeof (value));
+    return value;
+}
+
+struct PuppetMeshBlock {
+    size_t headerOffset = 0;
+    uint32_t vertexBytes = 0;
+    uint32_t indexBytes = 0;
+};
+
+std::optional<PuppetMeshBlock> findPuppetMeshBlock (
+    const std::vector<char>& data, size_t markerSize, size_t mdlsOffset, size_t meshHeaderSize, size_t vertexStride
+) {
+    for (size_t offset = markerSize; offset + meshHeaderSize + sizeof (uint32_t) < mdlsOffset; offset++) {
+	const uint32_t candidateVertexBytes = readU32 (data, offset + sizeof (uint32_t));
+	const size_t verticesOffset = offset + meshHeaderSize;
+	const size_t indexLengthOffset = verticesOffset + candidateVertexBytes;
+
+	if (candidateVertexBytes == 0 || candidateVertexBytes % vertexStride != 0
+	    || indexLengthOffset + sizeof (uint32_t) > mdlsOffset) {
+	    continue;
+	}
+
+	const uint32_t candidateIndexBytes = readU32 (data, indexLengthOffset);
+	const size_t indicesOffset = indexLengthOffset + sizeof (uint32_t);
+	if (candidateIndexBytes == 0 || candidateIndexBytes % (sizeof (uint16_t) * 3) != 0
+	    || indicesOffset + candidateIndexBytes > mdlsOffset) {
+	    continue;
+	}
+
+	return PuppetMeshBlock { .headerOffset = offset, .vertexBytes = candidateVertexBytes, .indexBytes = candidateIndexBytes };
+    }
+
+    return std::nullopt;
+}
+}
+
+CImage::ResolvedTransform CImage::resolveTransform (const Object& object, const int depth) const {
+    constexpr int kMaxParentDepth = 32;
+    glm::vec3 origin = object.origin->value->getVec3 ();
+    glm::vec3 scale = glm::vec3 (1.0f);
+    float angle = 0.0f;
+
+    if (object.is<Image> ()) {
+	const auto* image = object.as<Image> ();
+	scale = image->scale->value->getVec3 ();
+	angle = image->angles->value->getVec3 ().z;
+    } else if (object.is<Text> ()) {
+	const auto* text = object.as<Text> ();
+	scale = text->scale->value->getVec3 ();
+    } else {
+	scale = object.groupScale->value->getVec3 ();
+	angle = object.groupAngles->value->getVec3 ().z;
+    }
+
+    if (!object.parent.has_value ()) {
+	return { origin, scale, angle };
+    }
+
+    if (depth >= kMaxParentDepth) {
+	sLog.error ("Parent transform chain is too deep; possible cycle at object id=", object.id);
+	return { origin, scale, angle };
+    }
+
+    const auto* parentObject = this->getScene ().getObject (object.parent.value ());
+    if (parentObject == nullptr) {
+	return { origin, scale, angle };
+    }
+
+    const auto& parent = parentObject->getObject ();
+    const auto parentTransform = this->resolveTransform (parent, depth + 1);
+    const glm::vec2 local = rotateVec2 ({ origin.x * parentTransform.scale.x, origin.y * parentTransform.scale.y }, parentTransform.angle);
+    origin.x = parentTransform.origin.x + local.x;
+    origin.y = parentTransform.origin.y + local.y;
+    origin.z = parentTransform.origin.z + origin.z * parentTransform.scale.z;
+    scale *= parentTransform.scale;
+    angle += parentTransform.angle;
+
+    return { origin, scale, angle };
+}
+
 CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     CRenderable (scene, image, *image.model->material), m_sceneSpacePosition (GL_NONE), m_copySpacePosition (GL_NONE),
     m_passSpacePosition (GL_NONE), m_texcoordCopy (GL_NONE), m_texcoordPass (GL_NONE), m_modelViewProjectionScreen (),
@@ -27,15 +181,10 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     auto scene_width = static_cast<float> (scene.getWidth ());
     auto scene_height = static_cast<float> (scene.getHeight ());
 
-    // TODO: MAKE USE OF THE USER PROPERTIES POINTER HERE TOO! SO EVERYTHING IS UPDATED ACCORDINGLY
-    glm::vec3 origin = this->getImage ().origin->value->getVec3 ();
+    const auto transform = this->resolveTransform (this->getImage ());
+    glm::vec3 origin = transform.origin;
     glm::vec2 size = this->getSize ();
-    glm::vec3 scale = this->getImage ().scale->value->getVec3 ();
-
-    // TODO: PROPERLY SUPPORT PARENTS, FOR NOW THIS SHOULD BE ENOUGH
-    if (this->m_image.parent.has_value ()) {
-	origin += this->getScene ().getObject (this->m_image.parent.value ())->getObject ().origin->value->getVec3 ();
-    }
+    glm::vec3 scale = transform.scale;
 
     this->detectTexture ();
 
@@ -72,6 +221,7 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
 
 	// TODO: CHANGE ALIGNMENT TOO?
     }
+    this->m_size = size;
 
     glm::vec2 scaledSize = size * glm::vec2 (scale);
 
@@ -161,10 +311,16 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     GLfloat realY = 0.0;
 
     if (this->getImage ().model->passthrough) {
-	x = -((this->m_pos.x + (scene_width / 2)) / size.x);
-	y = -((this->m_pos.w + (scene_height / 2)) / size.y);
-	height = (this->m_pos.y + (scene_height / 2)) / size.y;
-	width = (this->m_pos.z + (scene_width / 2)) / size.x;
+	// Passthrough shaders fill the destination FBO from texcoords and sample the scene using positions.
+	// Keep the destination quad full-screen in local FBO space, but pass scene-space positions through.
+	x = 0.0f;
+	y = 0.0f;
+	width = 1.0f;
+	height = 1.0f;
+	realX = this->m_pos.x;
+	realY = this->m_pos.w;
+	realWidth = this->m_pos.z;
+	realHeight = this->m_pos.y;
 
 	if (this->getImage ().model->fullscreen) {
 	    realX = -1.0;
@@ -206,6 +362,8 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     glBindBuffer (GL_ARRAY_BUFFER, this->m_texcoordPass);
     glBufferData (GL_ARRAY_BUFFER, sizeof (texcoordPass), texcoordPass, GL_STATIC_DRAW);
 
+    this->m_hasPuppetMesh = this->loadPuppetMesh (size);
+
     // compute the center of the image in scene space for rotation
     this->m_sceneCenter = glm::vec3 (
 	(this->m_pos.x + this->m_pos.z) / 2.0f,
@@ -216,7 +374,11 @@ CImage::CImage (Wallpapers::CScene& scene, const Image& image) :
     this->m_modelViewProjectionScreen
 	= this->getScene ().getCamera ().getProjection () * this->getScene ().getCamera ().getLookAt ();
 
-    this->m_modelViewProjectionCopy = glm::ortho<float> (0.0, size.x, 0.0, size.y);
+    if (this->getImage ().model->passthrough) {
+	this->m_modelViewProjectionCopy = this->m_modelViewProjectionScreen;
+    } else {
+	this->m_modelViewProjectionCopy = glm::ortho<float> (0.0, size.x, 0.0, size.y);
+    }
     this->m_modelViewProjectionCopyInverse = glm::inverse (this->m_modelViewProjectionCopy);
     this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
     this->m_viewProjectionMatrix = glm::mat4 (1.0);
@@ -242,6 +404,173 @@ CImage::~CImage () {
     glDeleteBuffers (1, &this->m_passSpacePosition);
     glDeleteBuffers (1, &this->m_texcoordCopy);
     glDeleteBuffers (1, &this->m_texcoordPass);
+    if (this->m_puppetSpacePosition != GL_NONE) {
+	glDeleteBuffers (1, &this->m_puppetSpacePosition);
+    }
+    if (this->m_puppetTexCoord != GL_NONE) {
+	glDeleteBuffers (1, &this->m_puppetTexCoord);
+    }
+    if (this->m_puppetIndices != GL_NONE) {
+	glDeleteBuffers (1, &this->m_puppetIndices);
+    }
+}
+
+bool CImage::loadPuppetMesh (const glm::vec2& size) {
+    if (!this->getImage ().model->puppet.has_value ()) {
+	return false;
+    }
+
+    try {
+	const auto stream = this->getScene ().getScene ().project.assetLocator->read (*this->getImage ().model->puppet);
+	std::vector<char> data { std::istreambuf_iterator<char> (*stream), std::istreambuf_iterator<char> () };
+
+	constexpr size_t markerSize = 9;
+	constexpr size_t meshHeaderSize = sizeof (uint32_t) * 2;
+	constexpr size_t vertexStride = 80;
+	constexpr size_t positionOffset = 0;
+	constexpr size_t uvOffset = 72;
+
+	const std::string puppetVersion = data.size () >= markerSize ? std::string (data.data (), strlen ("MDLV0021")) : "";
+	if (puppetVersion != "MDLV0021" && puppetVersion != "MDLV0023") {
+	    sLog.error ("Unsupported puppet model header ", puppetVersion, " in ", *this->getImage ().model->puppet);
+	    return false;
+	}
+
+	const size_t mdlsOffset = [&data] () -> size_t {
+	    for (size_t offset = markerSize; offset + strlen ("MDLS") < data.size (); offset++) {
+		if (std::memcmp (data.data () + offset, "MDLS", strlen ("MDLS")) == 0) {
+		    return offset;
+		}
+	    }
+	    return data.size ();
+	} ();
+
+	const auto meshBlock = findPuppetMeshBlock (data, markerSize, mdlsOffset, meshHeaderSize, vertexStride);
+	if (!meshBlock.has_value ()) {
+	    sLog.error ("Could not find a usable MDLV mesh block in ", *this->getImage ().model->puppet);
+	    return false;
+	}
+
+	const size_t vertexCount = meshBlock->vertexBytes / vertexStride;
+	const size_t verticesOffset = meshBlock->headerOffset + meshHeaderSize;
+	const size_t indicesOffset = verticesOffset + meshBlock->vertexBytes + sizeof (uint32_t);
+	const size_t indexCount = meshBlock->indexBytes / sizeof (uint16_t);
+	std::vector<GLfloat> texcoords;
+	std::vector<GLushort> indices;
+
+	this->m_puppetRawPositions.clear ();
+	this->m_puppetRawPositions.reserve (vertexCount * 3);
+	texcoords.reserve (vertexCount * 2);
+	indices.reserve (indexCount);
+
+	for (size_t index = 0; index < vertexCount; index++) {
+	    const size_t vertexOffset = verticesOffset + index * vertexStride;
+	    const float x = readFloat (data, vertexOffset + positionOffset);
+	    const float y = readFloat (data, vertexOffset + positionOffset + sizeof (float));
+	    const float z = readFloat (data, vertexOffset + positionOffset + sizeof (float) * 2);
+	    const float u = readFloat (data, vertexOffset + uvOffset);
+	    const float v = readFloat (data, vertexOffset + uvOffset + sizeof (float));
+
+	    this->m_puppetRawPositions.push_back (x);
+	    this->m_puppetRawPositions.push_back (y);
+	    this->m_puppetRawPositions.push_back (z);
+	    texcoords.push_back (u);
+	    texcoords.push_back (v);
+	}
+
+	for (size_t index = 0; index < indexCount; index++) {
+	    uint16_t value = 0;
+	    std::memcpy (&value, data.data () + indicesOffset + index * sizeof (uint16_t), sizeof (value));
+	    if (value >= vertexCount) {
+		sLog.error ("Invalid puppet mesh index ", value, " in ", *this->getImage ().model->puppet);
+		return false;
+	    }
+	    indices.push_back (value);
+	}
+
+	this->updatePuppetPositionBuffer (size);
+
+	glGenBuffers (1, &this->m_puppetTexCoord);
+	glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetTexCoord);
+	glBufferData (GL_ARRAY_BUFFER, texcoords.size () * sizeof (GLfloat), texcoords.data (), GL_STATIC_DRAW);
+
+	glGenBuffers (1, &this->m_puppetIndices);
+	glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
+	glBufferData (GL_ELEMENT_ARRAY_BUFFER, indices.size () * sizeof (GLushort), indices.data (), GL_STATIC_DRAW);
+
+	this->m_puppetIndexCount = static_cast<GLsizei> (indices.size ());
+	sLog.out (
+	    "Loaded puppet mesh ", *this->getImage ().model->puppet, " version=", puppetVersion, " vertices=", vertexCount,
+	    " indices=", this->m_puppetIndexCount
+	);
+
+	return true;
+    } catch (const std::exception& ex) {
+	sLog.error ("Could not load puppet mesh ", *this->getImage ().model->puppet, ": ", ex.what ());
+	return false;
+    }
+}
+
+void CImage::updatePuppetPositionBuffer (const glm::vec2& size) {
+    if (this->m_puppetRawPositions.empty ()) {
+	return;
+    }
+
+    std::vector<GLfloat> positions;
+    positions.reserve (this->m_puppetRawPositions.size ());
+    for (size_t index = 0; index + 2 < this->m_puppetRawPositions.size (); index += 3) {
+	positions.push_back (size.x / 2.0f + this->m_puppetRawPositions[index]);
+	positions.push_back (size.y / 2.0f - this->m_puppetRawPositions[index + 1]);
+	positions.push_back (this->m_puppetRawPositions[index + 2]);
+    }
+
+    if (this->m_puppetSpacePosition == GL_NONE) {
+	glGenBuffers (1, &this->m_puppetSpacePosition);
+    }
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
+    glBufferData (GL_ARRAY_BUFFER, positions.size () * sizeof (GLfloat), positions.data (), GL_DYNAMIC_DRAW);
+}
+
+void CImage::setupPuppetGeometryCallback (Effects::CPass* pass) const {
+    pass->setGeometryCallback (
+	[this, pass] () {
+	    const GLint position = glGetAttribLocation (pass->getProgramID (), "a_Position");
+	    const GLint texCoord = glGetAttribLocation (pass->getProgramID (), "a_TexCoord");
+
+	    if (position >= 0) {
+		glEnableVertexAttribArray (position);
+		glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetSpacePosition);
+		glVertexAttribPointer (position, 3, GL_FLOAT, GL_FALSE, 0, nullptr);
+	    }
+
+	    if (texCoord >= 0) {
+		glEnableVertexAttribArray (texCoord);
+		glBindBuffer (GL_ARRAY_BUFFER, this->m_puppetTexCoord);
+		glVertexAttribPointer (texCoord, 2, GL_FLOAT, GL_FALSE, 0, nullptr);
+	    }
+	},
+	[this] () {
+	    GLfloat previousClearColor[4] = {};
+	    glGetFloatv (GL_COLOR_CLEAR_VALUE, previousClearColor);
+	    glClearColor (0.0f, 0.0f, 0.0f, 0.0f);
+	    glClear (GL_COLOR_BUFFER_BIT);
+	    glClearColor (previousClearColor[0], previousClearColor[1], previousClearColor[2], previousClearColor[3]);
+	    glBindBuffer (GL_ELEMENT_ARRAY_BUFFER, this->m_puppetIndices);
+	    glDrawElements (GL_TRIANGLES, this->m_puppetIndexCount, GL_UNSIGNED_SHORT, nullptr);
+	},
+	[pass] () {
+	    const GLint position = glGetAttribLocation (pass->getProgramID (), "a_Position");
+	    const GLint texCoord = glGetAttribLocation (pass->getProgramID (), "a_TexCoord");
+
+	    if (position >= 0) {
+		glDisableVertexAttribArray (position);
+	    }
+
+	    if (texCoord >= 0) {
+		glDisableVertexAttribArray (texCoord);
+	    }
+	}
+    );
 }
 
 void CImage::setup () {
@@ -273,6 +602,8 @@ void CImage::setup () {
 		}
     }
 
+    const auto& debug = this->getScene ().getContext ().getApp ().getContext ().settings.render.debug;
+
     // copy pass to the composite layer
     for (const auto& cur : this->getImage ().model->material->passes) {
 	this->m_passes.push_back (
@@ -281,9 +612,14 @@ void CImage::setup () {
     }
 
     // prepare the passes list
-    if (!this->getImage ().effects.empty ()) {
+    if (!debug.baseOnly && !this->getImage ().effects.empty ()) {
 	// generate the effects used by this material
 	for (const auto& cur : this->m_image.effects) {
+	    if (std::find (debug.skipEffects.begin (), debug.skipEffects.end (), static_cast<int> (cur->id))
+		!= debug.skipEffects.end ()) {
+		continue;
+	    }
+
 	    // do not add non-visible effects, this might need some adjustements tho as some effects might not be
 	    // visible but affect the output of the image...
 	    if (!cur->visible->value->getBool ()) {
@@ -364,8 +700,35 @@ void CImage::setup () {
 	}
     }
 
+    if (!debug.baseOnly) {
+	const auto magentaCompositeTint = findMagentaCompositeTint (this->m_image, debug.skipEffects);
+	if (magentaCompositeTint.has_value ()) {
+	    auto tintOverride = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
+		.id = -1,
+		.combos = {
+		    { "BLENDMODE", 30 },
+		},
+		.constants = {},
+		.textures = {},
+	    });
+	    tintOverride->constants.emplace ("color", makeStaticSetting (magentaCompositeTint.value ()));
+	    tintOverride->constants.emplace ("alpha", makeStaticSetting (1.0f));
+
+	    this->m_materials.compatibilityMaterials.emplace_back (
+		MaterialParser::load (this->getScene ().getScene ().project, "materials/effects/tint.json")
+	    );
+	    this->m_materials.compatibilityOverrides.emplace_back (std::move (tintOverride));
+
+	    this->m_passes.push_back (new CPass (
+		*this, std::make_shared<FBOProvider> (this),
+		**this->m_materials.compatibilityMaterials.back ()->passes.begin (),
+		*this->m_materials.compatibilityOverrides.back (), std::nullopt, std::nullopt
+	    ));
+	}
+    }
+
     // extra render pass if there's any blending to be done
-    if (this->m_image.colorBlendMode->value->getInt() > 0) {
+    if (!debug.baseOnly && this->m_image.colorBlendMode->value->getInt() > 0) {
 	this->m_materials.colorBlending.material
 	    = MaterialParser::load (this->getScene ().getScene ().project, "materials/util/effectpassthrough.json");
 	this->m_materials.colorBlending.override = std::make_unique<ImageEffectPassOverride> (ImageEffectPassOverride {
@@ -407,6 +770,8 @@ void CImage::setupPasses () {
     auto cur = this->m_passes.begin ();
     auto end = this->m_passes.end ();
     bool first = true;
+    bool inTargetEffectSequence = false;
+    std::shared_ptr<const TextureProvider> effectInput = nullptr;
 
     for (; cur != end; ++cur) {
 	// TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
@@ -414,31 +779,28 @@ void CImage::setupPasses () {
 	// TODO: WHICH ONE IS THE LAST + A FEW OTHER THINGS
 	Effects::CPass* pass = *cur;
 	std::shared_ptr<const CFBO> prevDrawTo = drawTo;
-	GLuint spacePosition = (first) ? this->getCopySpacePosition () : this->getPassSpacePosition ();
-	const glm::mat4* projection = (first) ? &this->m_modelViewProjectionCopy : &this->m_modelViewProjectionPass;
+	bool writesToTarget = false;
+	const bool isFirstPass = first;
+	GLuint spacePosition = (isFirstPass)
+	    ? (this->m_hasPuppetMesh ? this->m_puppetSpacePosition : this->getCopySpacePosition ())
+	    : this->getPassSpacePosition ();
+	const glm::mat4* projection = (isFirstPass) ? &this->m_modelViewProjectionCopy : &this->m_modelViewProjectionPass;
 	const glm::mat4* inverseProjection
-	    = (first) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
+	    = (isFirstPass) ? &this->m_modelViewProjectionCopyInverse : &this->m_modelViewProjectionPassInverse;
 	first = false;
 
+	if (isFirstPass && this->m_hasPuppetMesh) {
+	    pass->setBlendingMode (BlendingMode_Translucent);
+	    this->setupPuppetGeometryCallback (pass);
+	}
+
 	pass->setModelMatrix (&this->m_modelMatrix);
 	pass->setViewProjectionMatrix (&this->m_viewProjectionMatrix);
 
-	// set viewport and target texture if needed
-	if (pass->getTarget ().has_value ()) {
-	    // setup target texture
-	    std::string target = pass->getTarget ().value ();
-	    drawTo = pass->getFBOProvider ()->find (target);
-	    // spacePosition = this->getPassSpacePosition ();
-
-	    // not a local fbo, try to find a scene fbo with the same name
-	    if (drawTo == nullptr) {
-		// this one throws if no fbo was found
-		drawTo = this->getScene ().findFBO (target);
-	    }
-	}
+	writesToTarget = this->configurePassTarget (pass, drawTo, asInput, effectInput, inTargetEffectSequence);
 	// determine if it's the last element in the list as this is a screen-copy-like process
 	// TODO: PROPERLY CHECK IF THIS IS ALL THAT'S NEEDED
-	else if (std::next (cur) == end && this->getImage ().visible->value->getBool ()) {
+	if (!writesToTarget && this->shouldRenderFinalPass (std::next (cur) == end)) {
 	    // TODO: PROPERLY CHECK EFFECT'S VISIBILITY AND TAKE IT INTO ACCOUNT
 	    spacePosition = this->getSceneSpacePosition ();
 	    drawTo = this->getScene ().getFBO ();
@@ -448,20 +810,67 @@ void CImage::setupPasses () {
 
 	pass->setDestination (drawTo);
 	pass->setInput (asInput);
+	pass->setPreviousInput (inTargetEffectSequence ? effectInput : nullptr);
 	pass->setPosition (spacePosition);
 	pass->setTexCoord (texcoord);
 	pass->setModelViewProjectionMatrix (projection);
 	pass->setModelViewProjectionMatrixInverse (inverseProjection);
 
 	texcoord = this->getTexCoordPass ();
-	drawTo = prevDrawTo;
 
-	if (!pass->getTarget ().has_value ()) {
+	if (writesToTarget) {
+	    asInput = drawTo;
+	    drawTo = prevDrawTo;
+	} else {
+	    drawTo = prevDrawTo;
 	    this->pinpongFramebuffer (&drawTo, &asInput);
+	    inTargetEffectSequence = false;
+	    effectInput = nullptr;
 	}
     }
 }
 
+bool CImage::shouldRenderFinalPass (bool isLastPass) const {
+    if (!isLastPass || !this->getImage ().visible->value->getBool ()) {
+	return false;
+    }
+
+    const auto& debug = this->getScene ().getContext ().getApp ().getContext ().settings.render.debug;
+    return !(debug.noSolidFinal && this->getImage ().model->solidlayer);
+}
+
+bool CImage::configurePassTarget (
+    Effects::CPass* pass,
+    std::shared_ptr<const CFBO>& drawTo,
+    const std::shared_ptr<const TextureProvider>& asInput,
+    std::shared_ptr<const TextureProvider>& effectInput,
+    bool& inTargetEffectSequence
+) {
+    if (!pass->getTarget ().has_value ()) {
+	return false;
+    }
+
+    const std::string target = pass->getTarget ().value ();
+    std::shared_ptr<const CFBO> resolved = pass->getFBOProvider ()->find (target);
+    if (resolved == nullptr) {
+	resolved = this->getScene ().findFBO (target);
+    }
+    if (resolved == nullptr) {
+	sLog.error (
+	    "Pass target FBO '", target, "' could not be resolved for object ", pass->getRenderable ().getId (),
+	    " shader=", pass->getPass ().shader
+	);
+	return false;
+    }
+
+    if (!inTargetEffectSequence) {
+	effectInput = asInput;
+	inTargetEffectSequence = true;
+    }
+    drawTo = resolved;
+    return true;
+}
+
 void CImage::pinpongFramebuffer (std::shared_ptr<const CFBO>* drawTo, std::shared_ptr<const TextureProvider>* asInput) {
     // temporarily store FBOs used
     std::shared_ptr<const CFBO> currentMainFBO = this->m_currentMainFBO;
@@ -533,16 +942,146 @@ const glm::vec4& CImage::getColor4 () const { return this->m_image.color->value-
 
 const glm::vec3& CImage::getCompositeColor () const { return this->m_image.color->value->getVec3 (); }
 
+glm::vec2 CImage::resolveGeometrySize (float sceneWidth, float sceneHeight, glm::vec3& origin) const {
+    glm::vec2 size = this->getSize ();
+
+    if ((size.x == 0.0f || size.y == 0.0f) && this->m_texture != nullptr) {
+	size.x = static_cast<float> (this->m_texture->getRealWidth ());
+	size.y = static_cast<float> (this->m_texture->getRealHeight ());
+    } else if ((size.x == 0.0f || size.y == 0.0f) && this->getImage ().model->width.has_value ()
+	       && this->getImage ().model->height.has_value ()) {
+	size.x = static_cast<float> (this->getImage ().model->width.value ());
+	size.y = static_cast<float> (this->getImage ().model->height.value ());
+    }
+
+    if (this->getImage ().model->fullscreen) {
+	size = { sceneWidth, sceneHeight };
+	origin = { sceneWidth / 2.0f, sceneHeight / 2.0f, 0.0f };
+    }
+
+    return size;
+}
+
+void CImage::updateScenePosition (
+    const glm::vec3& origin, const glm::vec2& size, const glm::vec3& scale, float sceneWidth, float sceneHeight
+) {
+    const glm::vec2 scaledSize = size * glm::vec2 (scale);
+    this->m_pos.x = origin.x - (scaledSize.x / 2.0f);
+    this->m_pos.w = origin.y + (scaledSize.y / 2.0f);
+    this->m_pos.z = origin.x + (scaledSize.x / 2.0f);
+    this->m_pos.y = origin.y - (scaledSize.y / 2.0f);
+
+    if (this->getImage ().alignment.find ("top") != std::string::npos) {
+	this->m_pos.y -= scaledSize.y / 2.0f;
+	this->m_pos.w -= scaledSize.y / 2.0f;
+    } else if (this->getImage ().alignment.find ("bottom") != std::string::npos) {
+	this->m_pos.y += scaledSize.y / 2.0f;
+	this->m_pos.w += scaledSize.y / 2.0f;
+    }
+
+    if (this->getImage ().alignment.find ("left") != std::string::npos) {
+	this->m_pos.x += scaledSize.x / 2.0f;
+	this->m_pos.z += scaledSize.x / 2.0f;
+    } else if (this->getImage ().alignment.find ("right") != std::string::npos) {
+	this->m_pos.x -= scaledSize.x / 2.0f;
+	this->m_pos.z -= scaledSize.x / 2.0f;
+    }
+
+    this->m_pos.x -= sceneWidth / 2.0f;
+    this->m_pos.y = sceneHeight / 2.0f - this->m_pos.y;
+    this->m_pos.z -= sceneWidth / 2.0f;
+    this->m_pos.w = sceneHeight / 2.0f - this->m_pos.w;
+}
+
+void CImage::uploadGeometryBuffers (const glm::vec2& size) {
+    GLfloat sceneSpacePosition[] = { this->m_pos.x, this->m_pos.y, 0.0f, this->m_pos.x, this->m_pos.w, 0.0f,
+				     this->m_pos.z, this->m_pos.y, 0.0f, this->m_pos.z, this->m_pos.y, 0.0f,
+				     this->m_pos.x, this->m_pos.w, 0.0f, this->m_pos.z, this->m_pos.w, 0.0f };
+
+    float width = 1.0f;
+    float height = 1.0f;
+    if (
+	this->getTexture () != nullptr && !this->getTexture ()->isAnimated ()
+	&& (
+	    this->getTexture ()->getTextureWidth (0) != this->getTexture ()->getRealWidth ()
+	    || this->getTexture ()->getTextureHeight (0) != this->getTexture ()->getRealHeight ()
+	)
+    ) {
+	width = static_cast<float> (this->getTexture ()->getRealWidth ())
+	    / static_cast<float> (this->getTexture ()->getTextureWidth (0));
+	height = static_cast<float> (this->getTexture ()->getRealHeight ())
+	    / static_cast<float> (this->getTexture ()->getTextureHeight (0));
+    }
+
+    float x = 0.0f;
+    float y = 0.0f;
+    GLfloat realWidth = size.x;
+    GLfloat realHeight = size.y;
+    GLfloat realX = 0.0f;
+    GLfloat realY = 0.0f;
+
+    if (this->getImage ().model->passthrough) {
+	width = 1.0f;
+	height = 1.0f;
+	realX = this->m_pos.x;
+	realY = this->m_pos.w;
+	realWidth = this->m_pos.z;
+	realHeight = this->m_pos.y;
+
+	if (this->getImage ().model->fullscreen) {
+	    realX = -1.0f;
+	    realY = -1.0f;
+	    realWidth = 1.0f;
+	    realHeight = 1.0f;
+	}
+    }
+
+    GLfloat texcoordCopy[] = { x, height, x, y, width, height, width, height, x, y, width, y };
+    GLfloat copySpacePosition[] = { realX,     realHeight, 0.0f, realX, realY, 0.0f, realWidth, realHeight, 0.0f,
+				    realWidth, realHeight, 0.0f, realX, realY, 0.0f, realWidth, realY,      0.0f };
+
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_sceneSpacePosition);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (sceneSpacePosition), sceneSpacePosition, GL_DYNAMIC_DRAW);
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_copySpacePosition);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (copySpacePosition), copySpacePosition, GL_DYNAMIC_DRAW);
+    glBindBuffer (GL_ARRAY_BUFFER, this->m_texcoordCopy);
+    glBufferData (GL_ARRAY_BUFFER, sizeof (texcoordCopy), texcoordCopy, GL_DYNAMIC_DRAW);
+
+    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_modelViewProjectionCopy = this->getImage ().model->passthrough
+	? this->m_modelViewProjectionScreen
+	: glm::ortho<float> (0.0, size.x, 0.0, size.y);
+    this->m_modelViewProjectionCopyInverse = glm::inverse (this->m_modelViewProjectionCopy);
+    this->m_modelMatrix = glm::ortho<float> (0.0, size.x, 0.0, size.y);
+}
+
+void CImage::updateGeometryBuffers () {
+    auto sceneWidth = static_cast<float> (this->getScene ().getWidth ());
+    auto sceneHeight = static_cast<float> (this->getScene ().getHeight ());
+    const auto transform = this->resolveTransform (this->getImage ());
+    glm::vec3 origin = transform.origin;
+    const glm::vec3 scale = transform.scale;
+    const glm::vec2 size = this->resolveGeometrySize (sceneWidth, sceneHeight, origin);
+    const glm::vec2 previousSize = this->m_size;
+    this->m_size = size;
+    if (this->m_hasPuppetMesh && size != previousSize) {
+	this->updatePuppetPositionBuffer (size);
+    }
+
+    this->updateScenePosition (origin, size, scale, sceneWidth, sceneHeight);
+    this->uploadGeometryBuffers (size);
+}
+
 void CImage::updateScreenSpacePosition () {
+    this->updateGeometryBuffers ();
+
     // Build rotation from angles (already in radians from scene.json — see CParticle.cpp:2119)
     // Negate X and Z rotations to account for Y-flipped coordinate system (CParticle.cpp:2120)
-    glm::vec3 angles = this->getImage ().angles->value->getVec3 ();
+    const float angle = this->resolveTransform (this->getImage ()).angle;
     glm::mat4 rotModel = glm::mat4 (1.0f);
-    if (angles.x != 0.0f || angles.y != 0.0f || angles.z != 0.0f) {
+    if (angle != 0.0f) {
 	rotModel = glm::translate (rotModel, this->m_sceneCenter);
-	rotModel = glm::rotate (rotModel, -angles.z, glm::vec3 (0.0f, 0.0f, 1.0f));
-	rotModel = glm::rotate (rotModel, angles.y, glm::vec3 (0.0f, 1.0f, 0.0f));
-	rotModel = glm::rotate (rotModel, -angles.x, glm::vec3 (1.0f, 0.0f, 0.0f));
+	rotModel = glm::rotate (rotModel, -angle, glm::vec3 (0.0f, 0.0f, 1.0f));
 	rotModel = glm::translate (rotModel, -this->m_sceneCenter);
     }
 
@@ -552,13 +1091,13 @@ void CImage::updateScreenSpacePosition () {
 
     // Apply parallax displacement if enabled
     if (this->getScene ().getScene ().camera.parallax.enabled
-	&& !this->getImage ().model->fullscreen
 	&& !this->getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
 	const double parallaxAmount = this->getScene ().getScene ().camera.parallax.amount->value->getFloat ();
 	const glm::vec2 depth = this->getImage ().parallaxDepth->value->getVec2 ();
 	const glm::vec2* displacement = this->getScene ().getParallaxDisplacement ();
-	float x = (depth.x + parallaxAmount) * displacement->x * this->getSize ().x;
-	float y = (depth.y + parallaxAmount) * displacement->y * this->getSize ().x;
+	const float referenceSize = static_cast<float> (this->getScene ().getWidth ());
+	float x = (depth.x + parallaxAmount) * displacement->x * referenceSize;
+	float y = (depth.y + parallaxAmount) * displacement->y * referenceSize;
 	mvp = glm::translate (mvp, { x, y, 0.0f });
     }
 

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

@@ -10,6 +10,7 @@
 #include "../TextureProvider.h"
 
 #include <glm/vec3.hpp>
+#include <vector>
 
 using namespace WallpaperEngine;
 using namespace WallpaperEngine::Render;
@@ -63,12 +64,42 @@ protected:
 
     void updateScreenSpacePosition ();
 
+    struct ResolvedTransform {
+	glm::vec3 origin;
+	glm::vec3 scale;
+	float angle;
+    };
+
+    [[nodiscard]] ResolvedTransform resolveTransform (const WallpaperEngine::Data::Model::Object& object, int depth = 0) const;
+
 private:
+    bool loadPuppetMesh (const glm::vec2& size);
+    void updatePuppetPositionBuffer (const glm::vec2& size);
+    void setupPuppetGeometryCallback (Effects::CPass* pass) const;
+    void updateGeometryBuffers ();
+    [[nodiscard]] glm::vec2 resolveGeometrySize (float sceneWidth, float sceneHeight, glm::vec3& origin) const;
+    void updateScenePosition (const glm::vec3& origin, const glm::vec2& size, const glm::vec3& scale, float sceneWidth, float sceneHeight);
+    void uploadGeometryBuffers (const glm::vec2& size);
+    [[nodiscard]] bool shouldRenderFinalPass (bool isLastPass) const;
+    bool configurePassTarget (
+	Effects::CPass* pass,
+	std::shared_ptr<const CFBO>& drawTo,
+	const std::shared_ptr<const TextureProvider>& asInput,
+	std::shared_ptr<const TextureProvider>& effectInput,
+	bool& inTargetEffectSequence
+    );
+
     GLuint m_sceneSpacePosition;
     GLuint m_copySpacePosition;
     GLuint m_passSpacePosition;
     GLuint m_texcoordCopy;
     GLuint m_texcoordPass;
+    GLuint m_puppetSpacePosition = GL_NONE;
+    GLuint m_puppetTexCoord = GL_NONE;
+    GLuint m_puppetIndices = GL_NONE;
+    GLsizei m_puppetIndexCount = 0;
+    bool m_hasPuppetMesh = false;
+    std::vector<GLfloat> m_puppetRawPositions = {};
 
     glm::mat4 m_modelViewProjectionScreen = {};
     glm::mat4 m_modelViewProjectionPass = {};
@@ -95,6 +126,7 @@ private:
 
     glm::vec4 m_pos = {};
     glm::vec3 m_sceneCenter = {};
+    glm::vec2 m_size = {};
 
     bool m_initialized = false;
 
@@ -103,6 +135,8 @@ private:
 	    MaterialUniquePtr material;
 	    ImageEffectPassOverrideUniquePtr override;
 	} colorBlending;
+	std::vector<MaterialUniquePtr> compatibilityMaterials = {};
+	std::vector<ImageEffectPassOverrideUniquePtr> compatibilityOverrides = {};
     } m_materials;
 };
 } // namespace WallpaperEngine::Render::Objects

+ 44 - 15
src/WallpaperEngine/Render/Objects/CParticle.cpp

@@ -1840,12 +1840,13 @@ void CParticle::setupParticleUniforms () {
 }
 
 void CParticle::updateMatrices () {
-    // Build model matrix from particle object transform
     glm::vec3 scale = m_particle.scale->value->getVec3 ();
     glm::vec3 angles = m_particle.angles->value->getVec3 ();
 
     m_modelMatrix = glm::mat4 (1.0f);
     m_modelMatrix = glm::translate (m_modelMatrix, m_transformedOrigin);
+    this->applyParallaxToModelMatrix ();
+
     // Negate X and Z rotations to account for Y-flipped coordinate system
     m_modelMatrix = glm::rotate (m_modelMatrix, -angles.z, glm::vec3 (0, 0, 1));
     m_modelMatrix = glm::rotate (m_modelMatrix, angles.y, glm::vec3 (0, 1, 0));
@@ -1853,7 +1854,46 @@ void CParticle::updateMatrices () {
     m_modelMatrix = glm::scale (m_modelMatrix, scale);
     m_modelMatrixInverse = glm::inverse (m_modelMatrix);
 
-    // Build model-view-projection matrix
+    this->updateParticleViewProjection ();
+    m_mvpMatrix = m_viewProjectionMatrix * m_modelMatrix;
+    m_mvpMatrixInverse = glm::inverse (m_mvpMatrix);
+
+    m_orientationUp = glm::vec3 (0.0f, 1.0f, 0.0f);
+    m_orientationRight = glm::vec3 (1.0f, 0.0f, 0.0f);
+    m_orientationForward = glm::vec3 (0.0f, 0.0f, 1.0f);
+    m_viewUp = glm::vec3 (0.0f, 1.0f, 0.0f);
+    m_viewRight = glm::vec3 (1.0f, 0.0f, 0.0f);
+
+    this->updateParticleRenderVars ();
+}
+
+void CParticle::applyParallaxToModelMatrix () {
+    if (!getScene ().getScene ().camera.parallax.enabled
+	|| getScene ().getContext ().getApp ().getContext ().settings.mouse.disableparallax) {
+	return;
+    }
+
+    const float parallaxAmount = getScene ().getScene ().camera.parallax.amount->value->getFloat ();
+    glm::vec2 depth = m_particle.parallaxDepth->value->getVec2 ();
+    constexpr float minimumParticleDepth = 0.65f;
+    if (std::abs (depth.x) < minimumParticleDepth) {
+	depth.x = depth.x < 0.0f ? -minimumParticleDepth : minimumParticleDepth;
+    }
+    if (std::abs (depth.y) < minimumParticleDepth) {
+	depth.y = depth.y < 0.0f ? -minimumParticleDepth : minimumParticleDepth;
+    }
+
+    const glm::vec2* displacement = getScene ().getParallaxDisplacement ();
+    const float referenceSize = static_cast<float> (getScene ().getWidth ());
+    const glm::vec3 parallaxOffset {
+	(depth.x + parallaxAmount) * displacement->x * referenceSize,
+	(depth.y + parallaxAmount) * displacement->y * referenceSize,
+	0.0f,
+    };
+    m_modelMatrix = glm::translate (m_modelMatrix, parallaxOffset);
+}
+
+void CParticle::updateParticleViewProjection () {
     if ((m_particle.flags & 4) != 0) {
 	// Perspective particles use a dedicated perspective projection
 	float width = getScene ().getCamera ().getWidth ();
@@ -1880,22 +1920,11 @@ void CParticle::updateMatrices () {
 	// so the cross product produces a visible XY perpendicular direction.
 	m_eyePosition = glm::vec3 (0.0f, 0.0f, 1000.0f);
     }
+}
 
-    m_mvpMatrix = m_viewProjectionMatrix * m_modelMatrix;
-    m_mvpMatrixInverse = glm::inverse (m_mvpMatrix);
-
-    // Orientation vectors for billboard computation (screen-aligned)
-    // These define the coordinate frame that common_particles.h uses for billboard orientation
-    m_orientationUp = glm::vec3 (0.0f, 1.0f, 0.0f);
-    m_orientationRight = glm::vec3 (1.0f, 0.0f, 0.0f);
-    m_orientationForward = glm::vec3 (0.0f, 0.0f, 1.0f);
-    m_viewUp = glm::vec3 (0.0f, 1.0f, 0.0f);
-    m_viewRight = glm::vec3 (1.0f, 0.0f, 0.0f);
-
-    // Update g_RenderVar0: trail parameters (.x=length, .y=maxLength, .z=minLength)
+void CParticle::updateParticleRenderVars () {
     m_renderVar0 = glm::vec4 (m_trailLength, m_trailMaxLength, m_trailMinLength, 0.0f);
 
-    // Update g_RenderVar1: spritesheet params (.x=frameWidth, .y=frameHeight, .z=numFrames, .w=textureRatio)
     if (m_spritesheetFrames > 0 && m_spritesheetCols > 0 && m_spritesheetRows > 0) {
 	float frameWidth = 1.0f / static_cast<float> (m_spritesheetCols);
 	float frameHeight = 1.0f / static_cast<float> (m_spritesheetRows);

+ 3 - 0
src/WallpaperEngine/Render/Objects/CParticle.h

@@ -166,6 +166,9 @@ protected:
     void setupGeometryCallbacks ();
     void setupParticleUniforms ();
     void updateMatrices ();
+    void applyParallaxToModelMatrix ();
+    void updateParticleViewProjection ();
+    void updateParticleRenderVars ();
 
 private:
     const Particle& m_particle;