diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 19a752b..91e2b0c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,7 +16,9 @@ jobs:
         include:
           - qt_version: '6.6'
             qt_arch: 'gcc_64'
-          - qt_version: '6.7.2'
+          - qt_version: '6.7'
+            qt_arch: 'linux_gcc_64'
+          - qt_version: '6.8'
             qt_arch: 'linux_gcc_64'
 
     runs-on: ubuntu-latest
diff --git a/.github/workflows/utests.yml b/.github/workflows/utests.yml
index 616004e..7c12a1a 100644
--- a/.github/workflows/utests.yml
+++ b/.github/workflows/utests.yml
@@ -16,7 +16,9 @@ jobs:
         include:
           - qt_version: '6.6'
             qt_arch: 'gcc_64'
-          - qt_version: '6.7.2'
+          - qt_version: '6.7'
+            qt_arch: 'linux_gcc_64'
+          - qt_version: '6.8'
             qt_arch: 'linux_gcc_64'
 
     runs-on: ubuntu-latest
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 892ae06..c675f56 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -1,6 +1,6 @@
 cmake_minimum_required(VERSION 3.14)
 
-project(scratchcpp-render VERSION 0.7.0 LANGUAGES CXX)
+project(scratchcpp-render VERSION 0.8.0 LANGUAGES CXX)
 
 set(CMAKE_INCLUDE_CURRENT_DIR ON)
 set(CMAKE_AUTOUIC ON)
@@ -11,6 +11,11 @@ set(CMAKE_CXX_STANDARD_REQUIRED ON)
 
 option(SCRATCHCPPRENDER_BUILD_UNIT_TESTS "Build unit tests" ON)
 
+if(EMSCRIPTEN)
+    set(CMAKE_C_FLAGS "-pthread")
+    set(CMAKE_CXX_FLAGS "-pthread")
+endif()
+
 include(build/FindQt.cmake)
 
 qt_standard_project_setup(REQUIRES 6.6)
diff --git a/README.md b/README.md
index d38feeb..ce57e57 100644
--- a/README.md
+++ b/README.md
@@ -148,7 +148,7 @@ int main(int argc, char **argv) {
 - [x] Touching color blocks
 - [x] Pen blocks
 - [x] Monitors
-- [ ] Graphics effects (color, brightness and ghost are implemented)
+- [x] Graphics effects
 - [x] Speech and thought bubbles
 - [x] Question text box ("ask and wait" block)
 
diff --git a/libscratchcpp b/libscratchcpp
index bc5e5a8..d9e0e11 160000
--- a/libscratchcpp
+++ b/libscratchcpp
@@ -1 +1 @@
-Subproject commit bc5e5a8d7cb25148affaf008096dc71167e6328b
+Subproject commit d9e0e1124174a23d9f2783e756027da2f57d67b5
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index b681546..e812296 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -74,10 +74,15 @@ qt_add_qml_module(scratchcpp-render
     cputexturemanager.h
     effecttransform.cpp
     effecttransform.h
-	blocks/penblocks.cpp
-	blocks/penblocks.h
 )
 
+if (NOT LIBSCRATCHCPP_USE_LLVM)
+    target_sources(scratchcpp-render
+      PRIVATE
+        blocks/penblocks.cpp
+        blocks/penblocks.h)
+endif()
+
 list(APPEND QML_IMPORT_PATH ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR})
 list(REMOVE_DUPLICATES QML_IMPORT_PATH)
 set(QML_IMPORT_PATH ${QML_IMPORT_PATH} CACHE STRING "" FORCE)
diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml
index 86fa183..1653eb2 100644
--- a/src/ProjectPlayer.qml
+++ b/src/ProjectPlayer.qml
@@ -12,6 +12,7 @@ ProjectScene {
     property int stageWidth: 480
     property int stageHeight: 360
     readonly property bool running: loader.running
+    readonly property int renderFps: loader.renderFps
     readonly property list<string> unsupportedBlocks: loader.unsupportedBlocks
     property alias fps: loader.fps
     property alias turboMode: loader.turboMode
@@ -153,7 +154,7 @@ ProjectScene {
 
         Loader {
             readonly property alias modelData: stageTarget.stageModel
-            active: model ? model.bubbleText !== "" : false
+            active: modelData ? modelData.bubbleText !== "" : false
             sourceComponent: renderedTextBubble
         }
 
diff --git a/src/bitmapskin.cpp b/src/bitmapskin.cpp
index e499f8a..6acdad0 100644
--- a/src/bitmapskin.cpp
+++ b/src/bitmapskin.cpp
@@ -36,11 +36,6 @@ BitmapSkin::BitmapSkin(libscratchcpp::Costume *costume) :
         qWarning() << "invalid bitmap texture (costume name: " + costume->name() + ")";
 }
 
-BitmapSkin::~BitmapSkin()
-{
-    m_texture.release();
-}
-
 Texture BitmapSkin::getTexture(double scale) const
 {
     return m_texture;
diff --git a/src/bitmapskin.h b/src/bitmapskin.h
index 0c9e033..a800975 100644
--- a/src/bitmapskin.h
+++ b/src/bitmapskin.h
@@ -19,7 +19,6 @@ class BitmapSkin : public Skin
 {
     public:
         BitmapSkin(libscratchcpp::Costume *costume);
-        ~BitmapSkin();
 
         Texture getTexture(double scale) const override;
         double getTextureScale(const Texture &texture) const override;
diff --git a/src/cputexturemanager.cpp b/src/cputexturemanager.cpp
index 2174cf4..3193358 100644
--- a/src/cputexturemanager.cpp
+++ b/src/cputexturemanager.cpp
@@ -33,35 +33,52 @@ GLubyte *CpuTextureManager::getTextureData(const Texture &texture)
         return it->second;
 }
 
-const std::vector<QPoint> &CpuTextureManager::getTextureConvexHullPoints(const Texture &texture)
+void CpuTextureManager::getTextureConvexHullPoints(
+    const Texture &texture,
+    const QSize &skinSize,
+    ShaderManager::Effect effectMask,
+    const std::unordered_map<ShaderManager::Effect, double> &effects,
+    std::vector<QPoint> &dst)
 {
-    static const std::vector<QPoint> empty;
+    dst.clear();
 
     if (!texture.isValid())
-        return empty;
+        return;
 
-    const GLuint handle = texture.handle();
-    auto it = m_convexHullPoints.find(handle);
+    // Remove effects that don't change shape
+    if (effectMask != 0) {
+        const auto &allEffects = ShaderManager::effects();
 
-    if (it == m_convexHullPoints.cend()) {
-        if (addTexture(texture))
-            return m_convexHullPoints[handle];
-        else
-            return empty;
+        for (ShaderManager::Effect effect : allEffects) {
+            if ((effectMask & effect) != 0 && !ShaderManager::effectShapeChanges(effect))
+                effectMask &= ~effect;
+        }
+    }
+
+    // If there are no shape-changing effects, use cached hull points
+    if (effectMask == 0) {
+        const GLuint handle = texture.handle();
+        auto it = m_convexHullPoints.find(handle);
+
+        if (it == m_convexHullPoints.cend()) {
+            if (addTexture(texture))
+                dst = m_convexHullPoints[handle];
+        } else
+            dst = it->second;
     } else
-        return it->second;
+        readTexture(texture, skinSize, effectMask, effects, nullptr, dst);
 }
 
-QRgb CpuTextureManager::getPointColor(const Texture &texture, int x, int y, const std::unordered_map<ShaderManager::Effect, double> &effects)
+QRgb CpuTextureManager::getPointColor(const Texture &texture, int x, int y, ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effects)
 {
     const int width = texture.width();
     const int height = texture.height();
 
-    if (!effects.empty()) {
+    if (effectMask != 0) {
         // Get local position with effect transform
         QVector2D transformedCoords;
         const QVector2D localCoords(x / static_cast<float>(width), y / static_cast<float>(height));
-        EffectTransform::transformPoint(effects, localCoords, transformedCoords);
+        EffectTransform::transformPoint(effectMask, effects, texture.size(), localCoords, transformedCoords);
         x = transformedCoords.x() * width;
         y = transformedCoords.y() * height;
     }
@@ -72,13 +89,13 @@ QRgb CpuTextureManager::getPointColor(const Texture &texture, int x, int y, cons
     GLubyte *pixels = getTextureData(texture);
     QRgb color = qRgba(pixels[(y * width + x) * 4], pixels[(y * width + x) * 4 + 1], pixels[(y * width + x) * 4 + 2], pixels[(y * width + x) * 4 + 3]);
 
-    if (effects.empty())
+    if (effectMask == 0)
         return color;
     else
-        return EffectTransform::transformColor(effects, color);
+        return EffectTransform::transformColor(effectMask, effects, color);
 }
 
-bool CpuTextureManager::textureContainsPoint(const Texture &texture, const QPointF &localPoint, const std::unordered_map<ShaderManager::Effect, double> &effects)
+bool CpuTextureManager::textureContainsPoint(const Texture &texture, const QPointF &localPoint, ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effects)
 {
     // https://github.com/scratchfoundation/scratch-render/blob/7b823985bc6fe92f572cc3276a8915e550f7c5e6/src/Silhouette.js#L219-L226
     const int width = texture.width();
@@ -86,11 +103,11 @@ bool CpuTextureManager::textureContainsPoint(const Texture &texture, const QPoin
     int x = localPoint.x();
     int y = localPoint.y();
 
-    if (!effects.empty()) {
+    if (effectMask != 0) {
         // Get local position with effect transform
         QVector2D transformedCoords;
         const QVector2D localCoords(x / static_cast<float>(width), y / static_cast<float>(height));
-        EffectTransform::transformPoint(effects, localCoords, transformedCoords);
+        EffectTransform::transformPoint(effectMask, effects, texture.size(), localCoords, transformedCoords);
         x = transformedCoords.x() * width;
         y = transformedCoords.y() * height;
     }
@@ -99,8 +116,13 @@ bool CpuTextureManager::textureContainsPoint(const Texture &texture, const QPoin
         return false;
 
     GLubyte *pixels = getTextureData(texture);
-    QRgb color = qRgba(pixels[(y * width + x) * 4], pixels[(y * width + x) * 4 + 1], pixels[(y * width + x) * 4 + 2], pixels[(y * width + x) * 4 + 3]);
-    return qAlpha(color) > 0;
+
+    if (pixels) {
+        QRgb color = qRgba(pixels[(y * width + x) * 4], pixels[(y * width + x) * 4 + 1], pixels[(y * width + x) * 4 + 2], pixels[(y * width + x) * 4 + 3]);
+        return qAlpha(color) > 0;
+    }
+
+    return false;
 }
 
 void CpuTextureManager::removeTexture(const Texture &texture)
@@ -118,7 +140,24 @@ void CpuTextureManager::removeTexture(const Texture &texture)
     }
 }
 
-bool CpuTextureManager::addTexture(const Texture &texture)
+bool CpuTextureManager::addTexture(const Texture &tex)
+{
+    if (!tex.isValid())
+        return false;
+
+    const GLuint handle = tex.handle();
+    m_textureData[handle] = nullptr;
+    m_convexHullPoints[handle] = {};
+    return readTexture(tex, QSize(), ShaderManager::Effect::NoEffect, {}, &m_textureData[handle], m_convexHullPoints[handle]);
+}
+
+bool CpuTextureManager::readTexture(
+    const Texture &texture,
+    const QSize &skinSize,
+    ShaderManager::Effect effectMask,
+    const std::unordered_map<ShaderManager::Effect, double> &effects,
+    GLubyte **data,
+    std::vector<QPoint> &points) const
 {
     if (!texture.isValid())
         return false;
@@ -130,15 +169,27 @@ bool CpuTextureManager::addTexture(const Texture &texture)
     QOpenGLFunctions glF;
     glF.initializeOpenGLFunctions();
 
-    // Create a FBO for the texture
-    unsigned int fbo;
-    glF.glGenFramebuffers(1, &fbo);
-    glF.glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    // Create global FBO
+    if (m_fbo == 0) {
+        glF.glGenFramebuffers(1, &m_fbo);
+
+        QObject::connect(QOpenGLContext::currentContext(), &QOpenGLContext::aboutToBeDestroyed, []() {
+            if (QOpenGLContext::currentContext()) {
+                QOpenGLFunctions glF;
+                glF.initializeOpenGLFunctions();
+                glF.glDeleteFramebuffers(1, &m_fbo);
+                m_fbo = 0;
+            }
+        });
+    }
+
+    // Bind the texture to the global FBO
+    glF.glBindFramebuffer(GL_FRAMEBUFFER, m_fbo);
     glF.glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, handle, 0);
 
     if (glF.glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
         qWarning() << "error: framebuffer incomplete (CpuTextureManager)";
-        glF.glDeleteFramebuffers(1, &fbo);
+        glF.glBindFramebuffer(GL_FRAMEBUFFER, 0);
         return false;
     }
 
@@ -146,40 +197,136 @@ bool CpuTextureManager::addTexture(const Texture &texture)
     GLubyte *pixels = new GLubyte[width * height * 4]; // 4 channels (RGBA)
     glF.glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
 
-    // Flip vertically
-    int rowSize = width * 4;
-    GLubyte *tempRow = new GLubyte[rowSize];
+    std::vector<QPoint> leftHull;
+    std::vector<QPoint> rightHull;
+    leftHull.reserve(height);
+    rightHull.reserve(height);
 
-    for (size_t i = 0; i < height / 2; ++i) {
-        size_t topRowIndex = i * rowSize;
-        size_t bottomRowIndex = (height - 1 - i) * rowSize;
-
-        // Swap rows
-        memcpy(tempRow, &pixels[topRowIndex], rowSize);
-        memcpy(&pixels[topRowIndex], &pixels[bottomRowIndex], rowSize);
-        memcpy(&pixels[bottomRowIndex], tempRow, rowSize);
+    for (int x = 0; x < height; x++) {
+        leftHull.push_back(QPoint(-1, -1));
+        rightHull.push_back(QPoint(-1, -1));
     }
 
-    delete[] tempRow;
+    int leftEndPointIndex = -1;
+    int rightEndPointIndex = -1;
 
-    m_textureData[handle] = pixels;
-    m_convexHullPoints[handle] = {};
-    std::vector<QPoint> &hullPoints = m_convexHullPoints[handle];
+    auto determinant = [](const QPoint &A, const QPoint &B, const QPoint &C) { return (B.x() - A.x()) * (C.y() - A.y()) - (B.y() - A.y()) * (C.x() - A.x()); };
 
-    // Get convex hull points
+    // Get convex hull points (flipped vertically)
+    // https://github.com/scratchfoundation/scratch-render/blob/0f6663f3148b4f994d58e19590e14c152f1cc2f8/src/RenderWebGL.js#L1829-L1955
     for (int y = 0; y < height; y++) {
-        for (int x = 0; x < width; x++) {
-            int index = (y * width + x) * 4; // 4 channels (RGBA)
+        QPoint currentPoint;
+        int x;
+        const int flippedY = height - 1 - y;
+
+        for (x = 0; x < width; x++) {
+            int transformedX = x;
+            int transformedY = flippedY;
+
+            if (effectMask != 0) {
+                // Get local position with effect transform
+                QVector2D transformedCoords;
+                const QVector2D localCoords(transformedX / static_cast<float>(width), transformedY / static_cast<float>(height));
+                EffectTransform::transformPoint(effectMask, effects, skinSize, localCoords, transformedCoords);
+                transformedX = transformedCoords.x() * width;
+                transformedY = transformedCoords.y() * height;
+            }
+
+            if ((transformedX >= 0 && transformedX < width) && (transformedY >= 0 && transformedY < height)) {
+                int index = (transformedY * width + transformedX) * 4;
+
+                if (pixels[index + 3] > 0) {
+                    currentPoint.setX(x);
+                    currentPoint.setY(y);
+                    break;
+                }
+            }
+        }
+
+        if (x >= width)
+            continue;
+
+        while (leftEndPointIndex > 0) {
+            if (determinant(leftHull[leftEndPointIndex], leftHull[leftEndPointIndex - 1], currentPoint) > 0)
+                break;
+            else {
+                leftEndPointIndex--;
+            }
+        }
 
-            // Check alpha channel
-            if (pixels[index + 3] > 0)
-                hullPoints.push_back(QPoint(x, y));
+        leftHull[++leftEndPointIndex] = currentPoint;
+
+        for (x = width - 1; x >= 0; x--) {
+            int transformedX = x;
+            int transformedY = flippedY;
+
+            if (effectMask != 0) {
+                // Get local position with effect transform
+                QVector2D transformedCoords;
+                const QVector2D localCoords(transformedX / static_cast<float>(width), transformedY / static_cast<float>(height));
+                EffectTransform::transformPoint(effectMask, effects, skinSize, localCoords, transformedCoords);
+                transformedX = transformedCoords.x() * width;
+                transformedY = transformedCoords.y() * height;
+            }
+
+            if ((transformedX >= 0 && transformedX < width) && (transformedY >= 0 && transformedY < height)) {
+                int index = (transformedY * width + transformedX) * 4;
+
+                if (pixels[index + 3] > 0) {
+                    currentPoint.setX(x);
+                    currentPoint.setY(y);
+                    break;
+                }
+            }
         }
+
+        while (rightEndPointIndex > 0) {
+            if (determinant(rightHull[rightEndPointIndex], rightHull[rightEndPointIndex - 1], currentPoint) < 0)
+                break;
+            else
+                rightEndPointIndex--;
+        }
+
+        rightHull[++rightEndPointIndex] = currentPoint;
     }
 
+    points.clear();
+    points.reserve((leftEndPointIndex + 1) + (rightEndPointIndex + 1));
+
+    long i;
+
+    for (i = 0; i < leftHull.size(); i++) {
+        if (leftHull[i].x() >= 0)
+            points.push_back(leftHull[i]);
+    }
+
+    for (i = rightEndPointIndex; i >= 0; --i)
+        if (rightHull[i].x() >= 0)
+            points.push_back(rightHull[i]);
+
+    if (data) {
+        // Flip vertically
+        int rowSize = width * 4;
+        GLubyte *tempRow = new GLubyte[rowSize];
+
+        for (size_t i = 0; i < height / 2; ++i) {
+            size_t topRowIndex = i * rowSize;
+            size_t bottomRowIndex = (height - 1 - i) * rowSize;
+
+            // Swap rows
+            memcpy(tempRow, &pixels[topRowIndex], rowSize);
+            memcpy(&pixels[topRowIndex], &pixels[bottomRowIndex], rowSize);
+            memcpy(&pixels[bottomRowIndex], tempRow, rowSize);
+        }
+
+        delete[] tempRow;
+
+        *data = pixels;
+    } else
+        delete[] pixels;
+
     // Cleanup
     glF.glBindFramebuffer(GL_FRAMEBUFFER, 0);
-    glF.glDeleteFramebuffers(1, &fbo);
 
     return true;
 }
diff --git a/src/cputexturemanager.h b/src/cputexturemanager.h
index 4abdb5a..ebe047f 100644
--- a/src/cputexturemanager.h
+++ b/src/cputexturemanager.h
@@ -20,16 +20,29 @@ class CpuTextureManager
         ~CpuTextureManager();
 
         GLubyte *getTextureData(const Texture &texture);
-        const std::vector<QPoint> &getTextureConvexHullPoints(const Texture &texture);
+        void getTextureConvexHullPoints(
+            const Texture &texture,
+            const QSize &skinSize,
+            ShaderManager::Effect effectMask,
+            const std::unordered_map<ShaderManager::Effect, double> &effects,
+            std::vector<QPoint> &dst);
 
-        QRgb getPointColor(const Texture &texture, int x, int y, const std::unordered_map<ShaderManager::Effect, double> &effects);
-        bool textureContainsPoint(const Texture &texture, const QPointF &localPoint, const std::unordered_map<ShaderManager::Effect, double> &effects);
+        QRgb getPointColor(const Texture &texture, int x, int y, ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effects);
+        bool textureContainsPoint(const Texture &texture, const QPointF &localPoint, ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effects);
 
         void removeTexture(const Texture &texture);
 
     private:
-        bool addTexture(const Texture &texture);
-
+        bool addTexture(const Texture &tex);
+        bool readTexture(
+            const Texture &texture,
+            const QSize &skinSize,
+            ShaderManager::Effect effectMask,
+            const std::unordered_map<ShaderManager::Effect, double> &effects,
+            GLubyte **data,
+            std::vector<QPoint> &points) const;
+
+        static inline GLuint m_fbo = 0;          // single FBO for all texture managers
         std::unordered_map<GLuint, GLubyte *> m_textureData;
         std::unordered_map<GLuint, std::vector<QPoint>> m_convexHullPoints;
 };
diff --git a/src/effecttransform.cpp b/src/effecttransform.cpp
index 4ea1b70..8be1e8c 100644
--- a/src/effecttransform.cpp
+++ b/src/effecttransform.cpp
@@ -6,7 +6,17 @@
 
 using namespace scratchcpprender;
 
-QRgb EffectTransform::transformColor(const std::unordered_map<ShaderManager::Effect, double> &effectValues, QRgb color)
+// A texture coordinate is between 0 and 1, 0.5 is the center position
+static const float CENTER_X = 0.5f;
+static const float CENTER_Y = 0.5f;
+
+inline float fract(float x)
+{
+    // https://registry.khronos.org/OpenGL-Refpages/gl4/html/fract.xhtml
+    return x - std::floor(x);
+}
+
+QRgb EffectTransform::transformColor(ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effectValues, QRgb color)
 {
     // https://github.com/scratchfoundation/scratch-render/blob/e075e5f5ebc95dec4a2718551624ad587c56f0a6/src/EffectTransform.js#L40-L119
     // If the color is fully transparent, don't bother attempting any transformations.
@@ -18,8 +28,8 @@ QRgb EffectTransform::transformColor(const std::unordered_map<ShaderManager::Eff
     std::unordered_map<ShaderManager::Effect, float> uniforms;
     ShaderManager::getUniformValuesForEffects(effectValues, uniforms);
 
-    const bool enableColor = uniforms[ShaderManager::Effect::Color] != 0;
-    const bool enableBrightness = uniforms[ShaderManager::Effect::Brightness] != 0;
+    const bool enableColor = (effectMask & ShaderManager::Effect::Color) != 0;
+    const bool enableBrightness = (effectMask & ShaderManager::Effect::Brightness) != 0;
 
     if (enableColor || enableBrightness) {
         // gl_FragColor.rgb /= gl_FragColor.a + epsilon;
@@ -99,8 +109,77 @@ QRgb EffectTransform::transformColor(const std::unordered_map<ShaderManager::Eff
     return inOutColor.rgba();
 }
 
-void EffectTransform::transformPoint(const std::unordered_map<ShaderManager::Effect, double> &effectValues, const QVector2D &vec, QVector2D &dst)
+void EffectTransform::transformPoint(ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effectValues, const QSize &size, const QVector2D &vec, QVector2D &dst)
 {
-    // TODO: Implement remaining effects
+    // https://github.com/scratchfoundation/scratch-render/blob/e075e5f5ebc95dec4a2718551624ad587c56f0a6/src/EffectTransform.js#L128-L194
     dst = vec;
+
+    std::unordered_map<ShaderManager::Effect, float> uniforms;
+    ShaderManager::getUniformValuesForEffects(effectValues, uniforms);
+
+    if ((effectMask & ShaderManager::Effect::Mosaic) != 0) {
+        // texcoord0 = fract(u_mosaic * texcoord0);
+        const float mosaic = uniforms[ShaderManager::Effect::Mosaic];
+        dst.setX(fract(mosaic * dst.x()));
+        dst.setY(fract(mosaic * dst.y()));
+    }
+
+    if ((effectMask & ShaderManager::Effect::Pixelate) != 0) {
+        // vec2 pixelTexelSize = u_skinSize / u_pixelate;
+        const float pixelate = uniforms[ShaderManager::Effect::Pixelate];
+        const float texelX = size.width() / pixelate;
+        const float texelY = size.height() / pixelate;
+        // texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) /
+        //   pixelTexelSize;
+        dst.setX((std::floor(dst.x() * texelX) + CENTER_X) / texelX);
+        dst.setY((std::floor(dst.y() * texelY) + CENTER_Y) / texelY);
+    }
+
+    if ((effectMask & ShaderManager::Effect::Whirl) != 0) {
+        const float whirl = uniforms[ShaderManager::Effect::Whirl];
+        // const float kRadius = 0.5;
+        const float RADIUS = 0.5f;
+        // vec2 offset = texcoord0 - kCenter;
+        const float offsetX = dst.x() - CENTER_X;
+        const float offsetY = dst.y() - CENTER_Y;
+        // float offsetMagnitude = length(offset);
+        const float offsetMagnitude = std::sqrt(std::pow(offsetX, 2.0f) + std::pow(offsetY, 2.0f));
+        // float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0);
+        const float whirlFactor = std::max(1.0f - (offsetMagnitude / RADIUS), 0.0f);
+        // float whirlActual = u_whirl * whirlFactor * whirlFactor;
+        const float whirlActual = whirl * whirlFactor * whirlFactor;
+        // float sinWhirl = sin(whirlActual);
+        const float sinWhirl = std::sin(whirlActual);
+        // float cosWhirl = cos(whirlActual);
+        const float cosWhirl = std::cos(whirlActual);
+        // mat2 rotationMatrix = mat2(
+        //     cosWhirl, -sinWhirl,
+        //     sinWhirl, cosWhirl
+        // );
+        const float rot1 = cosWhirl;
+        const float rot2 = -sinWhirl;
+        const float rot3 = sinWhirl;
+        const float rot4 = cosWhirl;
+
+        // texcoord0 = rotationMatrix * offset + kCenter;
+        dst.setX((rot1 * offsetX) + (rot3 * offsetY) + CENTER_X);
+        dst.setY((rot2 * offsetX) + (rot4 * offsetY) + CENTER_Y);
+    }
+
+    if ((effectMask & ShaderManager::Effect::Fisheye) != 0) {
+        const float fisheye = uniforms[ShaderManager::Effect::Fisheye];
+        // vec2 vec = (texcoord0 - kCenter) / kCenter;
+        const float vX = (dst.x() - CENTER_X) / CENTER_X;
+        const float vY = (dst.y() - CENTER_Y) / CENTER_Y;
+        // float vecLength = length(vec);
+        const float vLength = std::sqrt((vX * vX) + (vY * vY));
+        // float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength);
+        const float r = std::pow(std::min(vLength, 1.0f), fisheye) * std::max(1.0f, vLength);
+        // vec2 unit = vec / vecLength;
+        const float unitX = vX / vLength;
+        const float unitY = vY / vLength;
+        // texcoord0 = kCenter + r * unit * kCenter;
+        dst.setX(CENTER_X + (r * unitX * CENTER_X));
+        dst.setY(CENTER_Y + (r * unitY * CENTER_Y));
+    }
 }
diff --git a/src/effecttransform.h b/src/effecttransform.h
index 6992f25..0a4e74a 100644
--- a/src/effecttransform.h
+++ b/src/effecttransform.h
@@ -14,8 +14,8 @@ class EffectTransform
     public:
         EffectTransform() = delete;
 
-        static QRgb transformColor(const std::unordered_map<ShaderManager::Effect, double> &effectValues, QRgb color);
-        static void transformPoint(const std::unordered_map<ShaderManager::Effect, double> &effectValues, const QVector2D &vec, QVector2D &dst);
+        static QRgb transformColor(ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effectValues, QRgb color);
+        static void transformPoint(ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effectValues, const QSize &size, const QVector2D &vec, QVector2D &dst);
 };
 
 } // namespace scratchcpprender
diff --git a/src/global_functions.cpp b/src/global_functions.cpp
index 3dc93a1..de18977 100644
--- a/src/global_functions.cpp
+++ b/src/global_functions.cpp
@@ -7,6 +7,10 @@ void scratchcpprender::init()
 {
     qputenv("QSG_RENDER_LOOP", "basic");
     QQuickWindow::setGraphicsApi(QSGRendererInterface::OpenGL);
+
+    QSurfaceFormat format = QSurfaceFormat::defaultFormat();
+    format.setSwapInterval(0);
+    QSurfaceFormat::setDefaultFormat(format);
 }
 
 const std::string &scratchcpprender::version()
diff --git a/src/internal/ListMonitor.qml b/src/internal/ListMonitor.qml
index 5195e7e..8fe6875 100644
--- a/src/internal/ListMonitor.qml
+++ b/src/internal/ListMonitor.qml
@@ -77,6 +77,7 @@ Rectangle {
     ListView {
         property real oldContentY
         readonly property int scrollBarWidth: 15
+        property int itemHeight: 24 // NOTE: Hard-coded value
 
         id: listView
         anchors.left: parent.left
@@ -87,6 +88,25 @@ Rectangle {
         clip: true
         model: root.model ? root.model.listModel : null
         boundsBehavior: Flickable.StopAtBounds
+        onContentXChanged: updateVisibleIndexRange()
+        onContentYChanged: updateVisibleIndexRange()
+        onCountChanged: updateVisibleIndexRange()
+
+        function getVisibleIndexRange() {
+            if(count === 0)
+                return [0, 0];
+
+            let y1 = listView.contentY - itemHeight;
+            let y2 = listView.contentY + listView.height;
+            return [Math.max(0, Math.ceil(y1 / itemHeight)),
+                    Math.min(Math.floor(y2 / itemHeight), count - 1)];
+        }
+
+        function updateVisibleIndexRange() {
+            let range = getVisibleIndexRange();
+            root.model.minIndex = range[0];
+            root.model.maxIndex = range[1];
+        }
 
         ScrollBar.vertical: ScrollBar {
             id: scrollBar
diff --git a/src/listmonitorlistmodel.cpp b/src/listmonitorlistmodel.cpp
index a055bcc..e7410eb 100644
--- a/src/listmonitorlistmodel.cpp
+++ b/src/listmonitorlistmodel.cpp
@@ -11,11 +11,14 @@ ListMonitorListModel::ListMonitorListModel(QObject *parent) :
 {
 }
 
-void ListMonitorListModel::setList(libscratchcpp::List *list)
+void ListMonitorListModel::setList(libscratchcpp::List *list, size_t minVisibleIndex, size_t maxVisibleIndex)
 {
     if (!list)
         return;
 
+    m_minIndex = minVisibleIndex;
+    m_maxIndex = maxVisibleIndex;
+
     // Initial load
     if (m_list != list) {
         beginResetModel();
@@ -25,10 +28,8 @@ void ListMonitorListModel::setList(libscratchcpp::List *list)
         return;
     }
 
-    // Notify about changed items
-    int count = std::min(m_oldRowCount, static_cast<int>(m_list->size()));
-
-    for (int i = 0; i < count; i++)
+    // Update visible items
+    for (size_t i = minVisibleIndex; i <= maxVisibleIndex; i++)
         emit dataChanged(index(i), index(i));
 
     // Notify about new items (at the end of the list)
@@ -54,8 +55,8 @@ int ListMonitorListModel::rowCount(const QModelIndex &parent) const
 
 QVariant ListMonitorListModel::data(const QModelIndex &index, int role) const
 {
-    if (!m_list || index.row() < 0 || index.row() >= m_list->size())
-        return QVariant();
+    if (!m_list || index.row() < m_minIndex || index.row() > m_maxIndex)
+        return "";
 
     return QString::fromStdString(libscratchcpp::Value((*m_list)[index.row()]).toString());
 }
diff --git a/src/listmonitorlistmodel.h b/src/listmonitorlistmodel.h
index cb0a8ce..26359a5 100644
--- a/src/listmonitorlistmodel.h
+++ b/src/listmonitorlistmodel.h
@@ -19,7 +19,7 @@ class ListMonitorListModel : public QAbstractListModel
     public:
         explicit ListMonitorListModel(QObject *parent = nullptr);
 
-        void setList(libscratchcpp::List *list);
+        void setList(libscratchcpp::List *list, size_t minVisibleIndex, size_t maxVisibleIndex);
 
         int rowCount(const QModelIndex &parent) const override;
         QVariant data(const QModelIndex &index, int role) const override;
@@ -27,7 +27,9 @@ class ListMonitorListModel : public QAbstractListModel
 
     private:
         libscratchcpp::List *m_list = nullptr;
-        int m_oldRowCount = 0;
+        size_t m_oldRowCount = 0;
+        size_t m_minIndex = 0;
+        size_t m_maxIndex = 0;
 };
 
 } // namespace scratchcpprender
diff --git a/src/listmonitormodel.cpp b/src/listmonitormodel.cpp
index 2182303..b88b3e9 100644
--- a/src/listmonitormodel.cpp
+++ b/src/listmonitormodel.cpp
@@ -23,7 +23,7 @@ void ListMonitorModel::onValueChanged(const libscratchcpp::VirtualMachine *vm)
     if (vm->registerCount() == 1) {
         long index = vm->getInput(0, 1)->toLong();
         libscratchcpp::List *list = vm->lists()[index];
-        m_listModel->setList(list);
+        m_listModel->setList(list, m_minIndex, m_maxIndex);
     }
 }
 
@@ -36,3 +36,31 @@ ListMonitorListModel *ListMonitorModel::listModel() const
 {
     return m_listModel;
 }
+
+size_t ListMonitorModel::minIndex() const
+{
+    return m_minIndex;
+}
+
+void ListMonitorModel::setMinIndex(size_t newMinIndex)
+{
+    if (m_minIndex == newMinIndex)
+        return;
+
+    m_minIndex = newMinIndex;
+    emit minIndexChanged();
+}
+
+size_t ListMonitorModel::maxIndex() const
+{
+    return m_maxIndex;
+}
+
+void ListMonitorModel::setMaxIndex(size_t newMaxIndex)
+{
+    if (m_maxIndex == newMaxIndex)
+        return;
+
+    m_maxIndex = newMaxIndex;
+    emit maxIndexChanged();
+}
diff --git a/src/listmonitormodel.h b/src/listmonitormodel.h
index 4034e03..acda897 100644
--- a/src/listmonitormodel.h
+++ b/src/listmonitormodel.h
@@ -18,6 +18,8 @@ class ListMonitorModel : public MonitorModel
         Q_OBJECT
         QML_ELEMENT
         Q_PROPERTY(ListMonitorListModel *listModel READ listModel NOTIFY listModelChanged)
+        Q_PROPERTY(size_t minIndex READ minIndex WRITE setMinIndex NOTIFY minIndexChanged FINAL);
+        Q_PROPERTY(size_t maxIndex READ maxIndex WRITE setMaxIndex NOTIFY maxIndexChanged FINAL)
 
     public:
         ListMonitorModel(QObject *parent = nullptr);
@@ -29,12 +31,22 @@ class ListMonitorModel : public MonitorModel
 
         ListMonitorListModel *listModel() const;
 
+        size_t minIndex() const;
+        void setMinIndex(size_t newMinIndex);
+
+        size_t maxIndex() const;
+        void setMaxIndex(size_t newMaxIndex);
+
     signals:
         void colorChanged();
         void listModelChanged();
+        void minIndexChanged();
+        void maxIndexChanged();
 
     private:
         ListMonitorListModel *m_listModel = nullptr;
+        size_t m_minIndex = 0;
+        size_t m_maxIndex = 0;
 };
 
 } // namespace scratchcpprender
diff --git a/src/penlayer.cpp b/src/penlayer.cpp
index 031d504..0c95759 100644
--- a/src/penlayer.cpp
+++ b/src/penlayer.cpp
@@ -11,8 +11,17 @@
 
 using namespace scratchcpprender;
 
+static const double pi = std::acos(-1); // TODO: Use std::numbers::pi in C++20
+
 std::unordered_map<libscratchcpp::IEngine *, IPenLayer *> PenLayer::m_projectPenLayers;
 
+// TODO: Move this to a separate class
+template<typename T>
+short sgn(T x)
+{
+    return (T(0) < x) - (x < T(0));
+}
+
 PenLayer::PenLayer(QNanoQuickItem *parent) :
     IPenLayer(parent)
 {
@@ -24,10 +33,13 @@ PenLayer::~PenLayer()
     if (m_engine)
         m_projectPenLayers.erase(m_engine);
 
-    if (m_blitter.isCreated()) {
+    if (m_vao != 0) {
         // Delete vertex array and buffer
         m_glF->glDeleteVertexArrays(1, &m_vao);
         m_glF->glDeleteBuffers(1, &m_vbo);
+
+        // Delete stamp FBO
+        m_glF->glDeleteFramebuffers(1, &m_stampFbo);
     }
 }
 
@@ -68,13 +80,9 @@ void PenLayer::setEngine(libscratchcpp::IEngine *newEngine)
             m_glF->initializeOpenGLFunctions();
         }
 
-        if (!m_blitter.isCreated()) {
-            m_blitter.create();
-
+        if (m_vao == 0) {
             // Set up VBO and VAO
-            float vertices[] = {
-                -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
-            };
+            float vertices[] = { -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f };
 
             m_glF->glGenVertexArrays(1, &m_vao);
             m_glF->glGenBuffers(1, &m_vbo);
@@ -94,6 +102,9 @@ void PenLayer::setEngine(libscratchcpp::IEngine *newEngine)
 
             m_glF->glBindVertexArray(0);
             m_glF->glBindBuffer(GL_ARRAY_BUFFER, 0);
+
+            // Create stamp FBO
+            m_glF->glGenFramebuffers(1, &m_stampFbo);
         }
 
         clear();
@@ -195,98 +206,87 @@ void scratchcpprender::PenLayer::drawLine(const PenAttributes &penAttributes, do
     update();
 }
 
-/*
- * A brief description of how stamping is implemented:
- * 1. Get rotation, size and coordinates and translate them.
- * 2. Draw the texture onto a temporary texture using shaders.
- * 3. Blit the resulting texture to a FBO with a square texture (required for rotation).
- * 4. Blit the resulting texture to the pen layer using QOpenGLTextureBlitter with transform.
- *
- * If you think this is too complicated, contributions are welcome!
- */
 void PenLayer::stamp(IRenderedTarget *target)
 {
-    if (!target || !m_fbo || !m_texture.isValid() || !m_blitter.isCreated())
+    if (!target || !m_fbo || !m_texture.isValid() || m_vao == 0 || m_vbo == 0)
         return;
 
-    double x = 0;
-    double y = 0;
-    double angle = 0;
-    double scale = 1;
-    bool mirror = false;
-    std::shared_ptr<libscratchcpp::Costume> costume;
+    const float stageWidth = m_engine->stageWidth() * m_scale;
+    const float stageHeight = m_engine->stageHeight() * m_scale;
+
+    libscratchcpp::Rect bounds = target->getFastBounds();
+    bounds.snapToInt();
+
+    if (!bounds.intersects(libscratchcpp::Rect(-stageWidth / 2, stageHeight / 2, stageWidth / 2, -stageHeight / 2)))
+        return;
+
+    float x = 0;
+    float y = 0;
+    float angle = 180;
+    float scaleX = 1;
+    float scaleY = 1;
 
     SpriteModel *spriteModel = target->spriteModel();
 
     if (spriteModel) {
         libscratchcpp::Sprite *sprite = spriteModel->sprite();
-        x = sprite->x();
-        y = sprite->y();
 
         switch (sprite->rotationStyle()) {
             case libscratchcpp::Sprite::RotationStyle::AllAround:
-                angle = 90 - sprite->direction();
+                angle = 270 - sprite->direction();
                 break;
 
             case libscratchcpp::Sprite::RotationStyle::LeftRight:
-                mirror = (sprite->direction() < 0);
+                scaleX = sgn(sprite->direction());
                 break;
 
             default:
                 break;
         }
 
-        scale = sprite->size() / 100;
-        costume = sprite->currentCostume();
-    } else
-        costume = target->stageModel()->stage()->currentCostume();
-
-    // Apply scale (HQ pen)
-    scale *= m_scale;
+        scaleY = sprite->size() / 100;
+        scaleX *= scaleY;
+    }
 
-    const double bitmapRes = costume->bitmapResolution();
-    const double centerX = costume->rotationCenterX() / bitmapRes;
-    const double centerY = costume->rotationCenterY() / bitmapRes;
+    scaleX *= m_scale;
+    scaleY *= m_scale;
 
     const Texture &texture = target->cpuTexture();
 
     if (!texture.isValid())
         return;
 
-    const double textureScale = texture.width() / static_cast<double>(target->costumeWidth());
-
-    // Apply scale (HQ pen)
-    x *= m_scale;
-    y *= m_scale;
-
-    // Translate the coordinates
-    x = std::floor(x + m_texture.width() / 2.0);
-    y = std::floor(-y + m_texture.height() / 2.0);
-
+    const float textureScale = texture.width() / static_cast<float>(target->costumeWidth());
+    const float skinWidth = texture.width();
+    const float skinHeight = texture.height();
+
+    // Projection matrix
+    QMatrix4x4 projectionMatrix;
+    const float aspectRatio = skinHeight / skinWidth;
+    projectionMatrix.ortho(1.0f, -1.0f, aspectRatio, -aspectRatio, 0.1f, 0.0f);
+    projectionMatrix.scale(skinWidth / bounds.width() / m_scale, skinHeight / bounds.height() / m_scale);
+
+    // Model matrix
+    // TODO: This should be calculated and cached by targets
+    QMatrix4x4 modelMatrix;
+    modelMatrix.rotate(angle, 0, 0, 1);
+    modelMatrix.scale(scaleX / textureScale, aspectRatio * scaleY / textureScale);
     m_glF->glDisable(GL_SCISSOR_TEST);
-
-    // For some reason nothing is rendered without this
-    // TODO: Find out why this is happening
-    m_painter->beginFrame(m_fbo->width(), m_fbo->height());
-    m_painter->stroke();
-    m_painter->endFrame();
-
-    // Create a temporary FBO for graphic effects
-    QOpenGLFramebufferObject tmpFbo(texture.size());
-    m_painter->beginFrame(tmpFbo.width(), tmpFbo.height());
+    m_glF->glDisable(GL_DEPTH_TEST);
 
     // Create a FBO for the current texture
-    unsigned int fbo;
-    m_glF->glGenFramebuffers(1, &fbo);
-    m_glF->glBindFramebuffer(GL_FRAMEBUFFER, fbo);
+    m_glF->glBindFramebuffer(GL_FRAMEBUFFER, m_stampFbo);
     m_glF->glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture.handle(), 0);
 
     if (m_glF->glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
         qWarning() << "error: framebuffer incomplete (stamp " + target->scratchTarget()->name() + ")";
-        m_glF->glDeleteFramebuffers(1, &fbo);
+        m_glF->glBindFramebuffer(GL_FRAMEBUFFER, 0);
         return;
     }
 
+    // Set viewport
+    m_glF->glViewport((stageWidth / 2) + bounds.left() * m_scale, (stageHeight / 2) + bounds.bottom() * m_scale, bounds.width() * m_scale, bounds.height() * m_scale);
+
     // Get the shader program for the current set of effects
     ShaderManager *shaderManager = ShaderManager::instance();
 
@@ -298,62 +298,24 @@ void PenLayer::stamp(IRenderedTarget *target)
     m_glF->glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
 
     // Render to the target framebuffer
-    m_glF->glBindFramebuffer(GL_FRAMEBUFFER, tmpFbo.handle());
+    m_glF->glBindFramebuffer(GL_FRAMEBUFFER, m_fbo->handle());
     shaderProgram->bind();
     m_glF->glBindVertexArray(m_vao);
     m_glF->glActiveTexture(GL_TEXTURE0);
     m_glF->glBindTexture(GL_TEXTURE_2D, texture.handle());
-    shaderManager->setUniforms(shaderProgram, 0, effects); // set texture and effect uniforms
-    m_glF->glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
-
-    m_painter->endFrame();
-
-    // Resize to square (for rotation)
-    const double dim = std::max(tmpFbo.width(), tmpFbo.height());
-    QOpenGLFramebufferObject resizeFbo(dim, dim);
-    resizeFbo.bind();
-    m_painter->beginFrame(dim, dim);
-
-    const QRect resizeRect(QPoint(0, 0), tmpFbo.size());
-    const QMatrix4x4 matrix = QOpenGLTextureBlitter::targetTransform(resizeRect, QRect(QPoint(0, 0), resizeFbo.size()));
-    m_glF->glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
-    m_glF->glClear(GL_COLOR_BUFFER_BIT);
-    m_blitter.bind();
-    m_blitter.blit(tmpFbo.texture(), matrix, QOpenGLTextureBlitter::OriginBottomLeft);
-    m_blitter.release();
-
-    m_painter->endFrame();
-    resizeFbo.release();
+    shaderManager->setUniforms(shaderProgram, 0, texture.size(), effects); // set texture and effect uniforms
+    shaderProgram->setUniformValue("u_projectionMatrix", projectionMatrix);
+    shaderProgram->setUniformValue("u_modelMatrix", modelMatrix);
+    m_glF->glDrawArrays(GL_TRIANGLES, 0, 6);
 
     // Cleanup
     shaderProgram->release();
     m_glF->glBindVertexArray(0);
     m_glF->glBindBuffer(GL_ARRAY_BUFFER, 0);
     m_glF->glBindFramebuffer(GL_FRAMEBUFFER, 0);
-    m_glF->glDeleteFramebuffers(1, &fbo);
-
-    // Transform
-    const double width = resizeFbo.width() / textureScale;
-    const double height = resizeFbo.height() / textureScale;
-    QRectF targetRect(QPoint(x, y), QSizeF(width, height));
-    QTransform transform = QOpenGLTextureBlitter::targetTransform(targetRect, QRect(QPoint(centerX, centerY), m_fbo->size())).toTransform();
-    const double dx = 2 * (centerX - width / 2.0) / width;
-    const double dy = -2 * (centerY - height / 2.0) / height;
-    transform.translate(dx, dy);
-    transform.rotate(angle);
-    transform.scale(scale * (mirror ? -1 : 1), scale);
-    transform.translate(-dx, -dy);
-
-    // Blit
-    m_fbo->bind();
-    m_painter->beginFrame(m_fbo->width(), m_fbo->height());
-    m_blitter.bind();
-    m_blitter.blit(resizeFbo.texture(), transform, QOpenGLTextureBlitter::OriginBottomLeft);
-    m_blitter.release();
-    m_painter->endFrame();
-    m_fbo->release();
 
     m_glF->glEnable(GL_SCISSOR_TEST);
+    m_glF->glEnable(GL_DEPTH_TEST);
 
     m_textureDirty = true;
     m_boundsDirty = true;
@@ -412,7 +374,8 @@ const libscratchcpp::Rect &PenLayer::getBounds() const
         double bottom = std::numeric_limits<double>::infinity();
         const double width = m_texture.width();
         const double height = m_texture.height();
-        const std::vector<QPoint> &points = m_textureManager.getTextureConvexHullPoints(m_texture);
+        std::vector<QPoint> points;
+        m_textureManager.getTextureConvexHullPoints(m_texture, QSize(), ShaderManager::Effect::NoEffect, {}, points);
 
         if (points.empty()) {
             m_bounds = libscratchcpp::Rect();
diff --git a/src/penlayer.h b/src/penlayer.h
index 2f93a9c..d17eadc 100644
--- a/src/penlayer.h
+++ b/src/penlayer.h
@@ -60,6 +60,7 @@ class PenLayer : public IPenLayer
         void updateTexture();
 
         static std::unordered_map<libscratchcpp::IEngine *, IPenLayer *> m_projectPenLayers;
+        static inline GLuint m_stampFbo = 0;
         bool m_antialiasingEnabled = true;
         libscratchcpp::IEngine *m_engine = nullptr;
         bool m_hqPen = false;
@@ -72,7 +73,6 @@ class PenLayer : public IPenLayer
         mutable CpuTextureManager m_textureManager;
         mutable bool m_boundsDirty = true;
         mutable libscratchcpp::Rect m_bounds;
-        QOpenGLTextureBlitter m_blitter;
         GLuint m_vbo = 0;
         GLuint m_vao = 0;
 };
diff --git a/src/projectloader.cpp b/src/projectloader.cpp
index d8c6fea..d18f450 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -32,10 +32,15 @@ ProjectLoader::ProjectLoader(QObject *parent) :
         }
     });
 
+    connect(qApp, &QCoreApplication::aboutToQuit, this, &ProjectLoader::clear);
+
     initTimer();
+    m_renderTimer.start();
 
+#ifndef USE_LLVM
     // Register pen blocks
     ScratchConfiguration::registerExtension(std::make_shared<PenBlocks>());
+#endif
 }
 
 ProjectLoader::~ProjectLoader()
@@ -64,45 +69,7 @@ void ProjectLoader::setFileName(const QString &newFileName)
 
     m_fileName = newFileName;
 
-    // Stop the project
-    if (m_engine)
-        m_engine->stop();
-
-    // Reset stage model
-    m_stage.init(nullptr);
-
-    if (m_stage.renderedTarget())
-        m_stage.renderedTarget()->update();
-
-    // Delete old sprites
-    for (SpriteModel *sprite : m_sprites)
-        sprite->deleteLater();
-
-    m_sprites.clear();
-    emit spritesChanged();
-
-    // Delete old clones
-    for (SpriteModel *clone : m_clones)
-        deleteCloneObject(clone);
-
-    m_clones.clear();
-    emit clonesChanged();
-
-    // Delete old monitors
-    for (MonitorModel *monitor : m_monitors) {
-        emit monitorRemoved(monitor);
-        monitor->deleteLater();
-    }
-
-    m_monitors.clear();
-    emit monitorsChanged();
-
-    // Clear the engine
-    if (m_engine)
-        m_engine->clear();
-
-    m_engine = nullptr;
-    emit engineChanged();
+    clear();
 
     m_project.setFileName(m_fileName.toStdString());
     m_loadStatus = false;
@@ -133,6 +100,11 @@ bool ProjectLoader::running() const
     return m_running;
 }
 
+int ProjectLoader::renderFps() const
+{
+    return m_renderFps;
+}
+
 IEngine *ProjectLoader::engine() const
 {
     if (m_loadThread.isRunning())
@@ -238,6 +210,15 @@ void ProjectLoader::timerEvent(QTimerEvent *event)
             m_running = !m_running;
             emit runningChanged();
         }
+
+        // FPS counter
+        if (m_renderTimer.elapsed() >= 1000) {
+            m_renderFps = m_renderFpsCounter;
+            m_renderFpsCounter = 0;
+            emit renderFpsChanged();
+            m_renderTimer.restart();
+        } else
+            m_renderFpsCounter++;
     }
 
     event->accept();
@@ -248,14 +229,53 @@ void ProjectLoader::callLoad(ProjectLoader *loader)
     loader->load();
 }
 
-void ProjectLoader::load()
+void ProjectLoader::clear()
 {
-    m_unpositionedMonitors.clear();
+    // Stop the project
+    if (m_engine)
+        m_engine->stop();
+
+    // Reset stage model
+    m_stage.init(nullptr);
+
+    if (m_stage.renderedTarget())
+        m_stage.renderedTarget()->update();
+
+    // Delete old sprites
+    for (SpriteModel *sprite : m_sprites)
+        sprite->deleteLater();
+
     m_sprites.clear();
-    m_clones.clear();
     emit spritesChanged();
+
+    // Delete old clones
+    for (SpriteModel *clone : m_clones)
+        deleteCloneObject(clone);
+
+    m_clones.clear();
     emit clonesChanged();
 
+    // Delete old monitors
+    for (MonitorModel *monitor : m_monitors) {
+        emit monitorRemoved(monitor);
+        monitor->deleteLater();
+    }
+
+    m_monitors.clear();
+    emit monitorsChanged();
+
+    // Clear the engine
+    if (m_engine)
+        m_engine->clear();
+
+    m_oldEngine = m_engine;
+    m_engine = nullptr;
+    emit engineChanged();
+}
+
+void ProjectLoader::load()
+{
+    m_unpositionedMonitors.clear();
     m_loadStatus = m_project.load();
     m_engineMutex.lock();
     m_engine = m_project.engine().get();
@@ -267,7 +287,6 @@ void ProjectLoader::load()
         emit loadingFinished();
         emit engineChanged();
         emit spritesChanged();
-        emit clonesChanged();
         return;
     }
 
@@ -279,12 +298,14 @@ void ProjectLoader::load()
     m_engine->setSpriteFencingEnabled(m_spriteFencing);
     m_engine->setGlobalVolume(m_mute ? 0 : 100);
 
-    m_engine->aboutToRender().connect(&ProjectLoader::redraw, this);
-    m_engine->monitorAdded().connect(&ProjectLoader::addMonitor, this);
-    m_engine->monitorRemoved().connect(&ProjectLoader::removeMonitor, this);
+    if (m_engine != m_oldEngine) {
+        m_engine->aboutToRender().connect(&ProjectLoader::redraw, this);
+        m_engine->monitorAdded().connect(&ProjectLoader::addMonitor, this);
+        m_engine->monitorRemoved().connect(&ProjectLoader::removeMonitor, this);
 
-    m_engine->questionAsked().connect([this](const std::string &question) { emit questionAsked(QString::fromStdString(question)); });
-    m_engine->questionAborted().connect([this]() { emit questionAborted(); });
+        m_engine->questionAsked().connect([this](const std::string &question) { emit questionAsked(QString::fromStdString(question)); });
+        m_engine->questionAborted().connect([this]() { emit questionAborted(); });
+    }
 
     // Load targets
     const auto &targets = m_engine->targets();
@@ -314,7 +335,6 @@ void ProjectLoader::load()
         emit loadingFinished();
         emit engineChanged();
         emit spritesChanged();
-        emit clonesChanged();
         return;
     }
 
@@ -334,7 +354,6 @@ void ProjectLoader::load()
     emit engineChanged();
     emit stageChanged();
     emit spritesChanged();
-    emit clonesChanged();
 }
 
 void ProjectLoader::initTimer()
diff --git a/src/projectloader.h b/src/projectloader.h
index 21f95e6..6065277 100644
--- a/src/projectloader.h
+++ b/src/projectloader.h
@@ -25,6 +25,7 @@ class ProjectLoader : public QObject
         Q_PROPERTY(QString fileName READ fileName WRITE setFileName NOTIFY fileNameChanged)
         Q_PROPERTY(bool loadStatus READ loadStatus NOTIFY loadStatusChanged)
         Q_PROPERTY(bool running READ running NOTIFY runningChanged)
+        Q_PROPERTY(int renderFps READ renderFps NOTIFY renderFpsChanged FINAL)
         Q_PROPERTY(libscratchcpp::IEngine *engine READ engine NOTIFY engineChanged)
         Q_PROPERTY(StageModel *stage READ stage NOTIFY stageChanged)
         Q_PROPERTY(QQmlListProperty<SpriteModel> sprites READ sprites NOTIFY spritesChanged)
@@ -98,11 +99,14 @@ class ProjectLoader : public QObject
 
         unsigned int assetCount() const;
 
+        int renderFps() const;
+
     signals:
         void fileNameChanged();
         void loadStatusChanged();
         void loadingFinished();
         void runningChanged();
+        void renderFpsChanged();
         void engineChanged();
         void stageChanged();
         void spritesChanged();
@@ -130,6 +134,7 @@ class ProjectLoader : public QObject
 
     private:
         static void callLoad(ProjectLoader *loader);
+        void clear();
         void load();
         void initTimer();
         void redraw();
@@ -144,7 +149,11 @@ class ProjectLoader : public QObject
         QFuture<void> m_loadThread;
         libscratchcpp::Project m_project;
         bool m_running = false;
+        QElapsedTimer m_renderTimer;
+        int m_renderFps = 0;
+        int m_renderFpsCounter = 0;
         libscratchcpp::IEngine *m_engine = nullptr;
+        libscratchcpp::IEngine *m_oldEngine = nullptr;
         QMutex m_engineMutex;
         bool m_loadStatus = false;
         StageModel m_stage;
diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp
index 348aabf..101e236 100644
--- a/src/renderedtarget.cpp
+++ b/src/renderedtarget.cpp
@@ -228,6 +228,7 @@ void RenderedTarget::setEngine(IEngine *newEngine)
     m_cpuTexture = Texture();
     m_penLayer = PenLayer::getProjectPenLayer(m_engine);
     m_convexHullDirty = true;
+    m_transformedHullDirty = true;
     clearGraphicEffects();
     m_hullPoints.clear();
 
@@ -540,26 +541,32 @@ const std::unordered_map<ShaderManager::Effect, double> &RenderedTarget::graphic
 void RenderedTarget::setGraphicEffect(ShaderManager::Effect effect, double value)
 {
     bool changed = false;
-    auto it = m_graphicEffects.find(effect);
 
     if (value == 0) {
-        if (it != m_graphicEffects.cend()) {
+        if ((m_graphicEffectMask & effect) != 0) {
             changed = true;
             m_graphicEffects.erase(effect);
+            m_graphicEffectMask &= ~effect;
         }
     } else {
-        if (it != m_graphicEffects.cend())
-            changed = it->second != value;
-        else
+        if ((m_graphicEffectMask & effect) != 0)
+            changed = m_graphicEffects[effect] != value;
+        else {
             changed = true;
+            m_graphicEffectMask |= effect;
+        }
 
         m_graphicEffects[effect] = value;
     }
 
-    if (changed)
+    if (changed) {
         update();
 
-    // TODO: Set m_convexHullDirty to true if the effect changes shape
+        if (ShaderManager::effectShapeChanges(effect)) {
+            m_convexHullDirty = true;
+            m_transformedHullDirty = true;
+        }
+    }
 }
 
 void RenderedTarget::clearGraphicEffects()
@@ -567,8 +574,16 @@ void RenderedTarget::clearGraphicEffects()
     if (!m_graphicEffects.empty())
         update();
 
-    // TODO: Set m_convexHullDirty to true if any of the previous effects changed shape
+    for (const auto &[effect, value] : m_graphicEffects) {
+        if (ShaderManager::effectShapeChanges(effect)) {
+            m_convexHullDirty = true;
+            m_transformedHullDirty = true;
+            break;
+        }
+    }
+
     m_graphicEffects.clear();
+    m_graphicEffectMask = ShaderManager::Effect::NoEffect;
 }
 
 const std::vector<QPoint> &RenderedTarget::hullPoints() const
@@ -618,7 +633,7 @@ QRgb RenderedTarget::colorAtScratchPoint(double x, double y) const
     if ((x < 0 || x >= width) || (y < 0 || y >= height))
         return qRgba(0, 0, 0, 0);
 
-    return textureManager()->getPointColor(m_cpuTexture, x, y, m_graphicEffects);
+    return textureManager()->getPointColor(m_cpuTexture, x, y, m_graphicEffectMask, m_graphicEffects);
 }
 
 bool RenderedTarget::touchingClones(const std::vector<libscratchcpp::Sprite *> &clones) const
@@ -668,56 +683,52 @@ void RenderedTarget::calculatePos()
     if (!m_skin || !m_costume || !m_engine)
         return;
 
-    if (isVisible() || m_stageModel) {
-        double stageWidth = m_engine->stageWidth();
-        double stageHeight = m_engine->stageHeight();
-        setX(m_stageScale * (stageWidth / 2 + m_x - m_costume->rotationCenterX() * m_size / scale() / m_costume->bitmapResolution() * (m_mirrorHorizontally ? -1 : 1)));
-        setY(m_stageScale * (stageHeight / 2 - m_y - m_costume->rotationCenterY() * m_size / scale() / m_costume->bitmapResolution()));
-        qreal originX = m_costume->rotationCenterX() * m_stageScale * m_size / scale() / m_costume->bitmapResolution();
-        qreal originY = m_costume->rotationCenterY() * m_stageScale * m_size / scale() / m_costume->bitmapResolution();
-        setTransformOriginPoint(QPointF(originX, originY));
-
-        // Qt ignores the transform origin point if it's (0, 0),
-        // so set the transform origin to top left in this case.
-        if (originX == 0 && originY == 0)
-            setTransformOrigin(QQuickItem::TopLeft);
-        else
-            setTransformOrigin(QQuickItem::Center);
-    }
+    double stageWidth = m_engine->stageWidth();
+    double stageHeight = m_engine->stageHeight();
+    setX(m_stageScale * (stageWidth / 2 + m_x - m_costume->rotationCenterX() * m_size / scale() / m_costume->bitmapResolution() * (m_mirrorHorizontally ? -1 : 1)));
+    setY(m_stageScale * (stageHeight / 2 - m_y - m_costume->rotationCenterY() * m_size / scale() / m_costume->bitmapResolution()));
+    qreal originX = m_costume->rotationCenterX() * m_stageScale * m_size / scale() / m_costume->bitmapResolution();
+    qreal originY = m_costume->rotationCenterY() * m_stageScale * m_size / scale() / m_costume->bitmapResolution();
+    setTransformOriginPoint(QPointF(originX, originY));
+
+    // Qt ignores the transform origin point if it's (0, 0),
+    // so set the transform origin to top left in this case.
+    if (originX == 0 && originY == 0)
+        setTransformOrigin(QQuickItem::TopLeft);
+    else
+        setTransformOrigin(QQuickItem::Center);
 
     m_transformedHullDirty = true;
 }
 
 void RenderedTarget::calculateRotation()
 {
-    if (isVisible()) {
-        // Direction
-        bool oldMirrorHorizontally = m_mirrorHorizontally;
+    // Direction
+    bool oldMirrorHorizontally = m_mirrorHorizontally;
 
-        switch (m_rotationStyle) {
-            case Sprite::RotationStyle::AllAround:
-                setRotation(m_direction - 90);
-                m_mirrorHorizontally = (false);
+    switch (m_rotationStyle) {
+        case Sprite::RotationStyle::AllAround:
+            setRotation(m_direction - 90);
+            m_mirrorHorizontally = (false);
 
-                break;
-
-            case Sprite::RotationStyle::LeftRight: {
-                setRotation(0);
-                m_mirrorHorizontally = (m_direction < 0);
+            break;
 
-                break;
-            }
+        case Sprite::RotationStyle::LeftRight: {
+            setRotation(0);
+            m_mirrorHorizontally = (m_direction < 0);
 
-            case Sprite::RotationStyle::DoNotRotate:
-                setRotation(0);
-                m_mirrorHorizontally = false;
-                break;
+            break;
         }
 
-        if (m_mirrorHorizontally != oldMirrorHorizontally)
-            emit mirrorHorizontallyChanged();
+        case Sprite::RotationStyle::DoNotRotate:
+            setRotation(0);
+            m_mirrorHorizontally = false;
+            break;
     }
 
+    if (m_mirrorHorizontally != oldMirrorHorizontally)
+        emit mirrorHorizontallyChanged();
+
     m_transformedHullDirty = true;
 }
 
@@ -764,8 +775,7 @@ void RenderedTarget::updateHullPoints()
         return;
     }
 
-    m_hullPoints = textureManager()->getTextureConvexHullPoints(m_cpuTexture);
-    // TODO: Apply graphic effects (#117)
+    textureManager()->getTextureConvexHullPoints(m_cpuTexture, m_skin->getTexture(1).size(), m_graphicEffectMask, m_graphicEffects, m_hullPoints);
 }
 
 const std::vector<QPointF> &RenderedTarget::transformedHullPoints() const
@@ -801,12 +811,13 @@ const std::vector<QPointF> &RenderedTarget::transformedHullPoints() const
         m_transformedHullPoints.push_back(QPointF(x, y));
     }
 
+    m_transformedHullDirty = false;
     return m_transformedHullPoints;
 }
 
 bool RenderedTarget::containsLocalPoint(const QPointF &point) const
 {
-    return textureManager()->textureContainsPoint(m_cpuTexture, point, m_graphicEffects);
+    return textureManager()->textureContainsPoint(m_cpuTexture, point, m_graphicEffectMask, m_graphicEffects);
 }
 
 QPointF RenderedTarget::transformPoint(double scratchX, double scratchY, double originX, double originY, double rot) const
@@ -874,11 +885,10 @@ bool RenderedTarget::touchingColor(const libscratchcpp::Value &color, bool hasMa
 
     if (hasMask) {
         // Ignore ghost effect when checking mask
-        auto it = m_graphicEffects.find(ShaderManager::Effect::Ghost);
-
-        if (it != m_graphicEffects.cend()) {
-            ghostValue = it->second;
+        if ((m_graphicEffectMask & ShaderManager::Effect::Ghost) != 0) {
+            ghostValue = m_graphicEffects[ShaderManager::Effect::Ghost];
             m_graphicEffects.erase(ShaderManager::Effect::Ghost);
+            m_graphicEffectMask &= ~ShaderManager::Effect::Ghost;
         }
 
         mask3b = convertColor(mask);
@@ -910,8 +920,10 @@ bool RenderedTarget::touchingColor(const libscratchcpp::Value &color, bool hasMa
 
                 if (colorMatches(rgb, pixelColor)) {
                     // Restore ghost effect value
-                    if (hasMask && ghostValue != 0)
+                    if (hasMask && ghostValue != 0) {
                         m_graphicEffects[ShaderManager::Effect::Ghost] = ghostValue;
+                        m_graphicEffectMask |= ShaderManager::Effect::Ghost;
+                    }
 
                     return true;
                 }
@@ -920,8 +932,10 @@ bool RenderedTarget::touchingColor(const libscratchcpp::Value &color, bool hasMa
     }
 
     // Restore ghost effect value
-    if (hasMask && ghostValue != 0)
+    if (hasMask && ghostValue != 0) {
         m_graphicEffects[ShaderManager::Effect::Ghost] = ghostValue;
+        m_graphicEffectMask |= ShaderManager::Effect::Ghost;
+    }
 
     return false;
 }
diff --git a/src/renderedtarget.h b/src/renderedtarget.h
index bcbfae3..9550762 100644
--- a/src/renderedtarget.h
+++ b/src/renderedtarget.h
@@ -160,6 +160,7 @@ class RenderedTarget : public IRenderedTarget
         mutable std::shared_ptr<CpuTextureManager> m_textureManager; // NOTE: Use textureManager()!
         std::unique_ptr<QOpenGLFunctions> m_glF;
         mutable std::unordered_map<ShaderManager::Effect, double> m_graphicEffects;
+        mutable ShaderManager::Effect m_graphicEffectMask = ShaderManager::Effect::NoEffect;
         double m_size = 1;
         double m_x = 0;
         double m_y = 0;
@@ -173,7 +174,7 @@ class RenderedTarget : public IRenderedTarget
         qreal m_maximumHeight = std::numeric_limits<double>::infinity();
         bool m_convexHullDirty = true;
         std::vector<QPoint> m_hullPoints;
-        bool m_transformedHullDirty = true;
+        mutable bool m_transformedHullDirty = true;
         mutable std::vector<QPointF> m_transformedHullPoints; // NOTE: Use transformedHullPoints();
         bool m_clicked = false;                               // left mouse button only!
         double m_dragX = 0;
diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index 928e32f..7d96640 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -12,6 +12,8 @@ using namespace scratchcpprender;
 
 using ConverterFunc = float (*)(float);
 
+static const double pi = std::acos(-1); // TODO: Use std::numbers::pi in C++20
+
 static float wrapClamp(float n, float min, float max)
 {
     // TODO: Move this to a separate class
@@ -22,22 +24,43 @@ static float wrapClamp(float n, float min, float max)
 static const QString VERTEX_SHADER_SRC = ":/qt/qml/ScratchCPP/Render/shaders/sprite.vert";
 static const QString FRAGMENT_SHADER_SRC = ":/qt/qml/ScratchCPP/Render/shaders/sprite.frag";
 
+#if defined(Q_OS_WASM)
+static const QString SHADER_PREFIX = ""; // compiles, but doesn't work?
+#elif defined(Q_OS_ANDROID)
+static const QString SHADER_PREFIX = "#version 300 es\n";
+#else
+static const QString SHADER_PREFIX = "#version 140\n";
+#endif
+
 static const char *TEXTURE_UNIT_UNIFORM = "u_skin";
+static const char *SKIN_SIZE_UNIFORM = "u_skinSize";
 
-static const std::unordered_map<ShaderManager::Effect, const char *>
-    EFFECT_TO_NAME = { { ShaderManager::Effect::Color, "color" }, { ShaderManager::Effect::Brightness, "brightness" }, { ShaderManager::Effect::Ghost, "ghost" } };
+static const std::unordered_map<ShaderManager::Effect, const char *> EFFECT_TO_NAME = {
+    { ShaderManager::Effect::Color, "color" }, { ShaderManager::Effect::Brightness, "brightness" }, { ShaderManager::Effect::Ghost, "ghost" },  { ShaderManager::Effect::Fisheye, "fisheye" },
+    { ShaderManager::Effect::Whirl, "whirl" }, { ShaderManager::Effect::Pixelate, "pixelate" },     { ShaderManager::Effect::Mosaic, "mosaic" }
+};
 
-static const std::unordered_map<ShaderManager::Effect, const char *>
-    EFFECT_UNIFORM_NAME = { { ShaderManager::Effect::Color, "u_color" }, { ShaderManager::Effect::Brightness, "u_brightness" }, { ShaderManager::Effect::Ghost, "u_ghost" } };
+static const std::unordered_map<ShaderManager::Effect, const char *> EFFECT_UNIFORM_NAME = {
+    { ShaderManager::Effect::Color, "u_color" }, { ShaderManager::Effect::Brightness, "u_brightness" }, { ShaderManager::Effect::Ghost, "u_ghost" },  { ShaderManager::Effect::Fisheye, "u_fisheye" },
+    { ShaderManager::Effect::Whirl, "u_whirl" }, { ShaderManager::Effect::Pixelate, "u_pixelate" },     { ShaderManager::Effect::Mosaic, "u_mosaic" }
+};
 
 static const std::unordered_map<ShaderManager::Effect, ConverterFunc> EFFECT_CONVERTER = {
     { ShaderManager::Effect::Color, [](float x) { return wrapClamp(x / 200.0f, 0.0f, 1.0f); } },
     { ShaderManager::Effect::Brightness, [](float x) { return std::clamp(x, -100.0f, 100.0f) / 100.0f; } },
-    { ShaderManager::Effect::Ghost, [](float x) { return 1 - std::clamp(x, 0.0f, 100.0f) / 100.0f; } }
+    { ShaderManager::Effect::Ghost, [](float x) { return 1 - std::clamp(x, 0.0f, 100.0f) / 100.0f; } },
+    { ShaderManager::Effect::Fisheye, [](float x) { return std::max(0.0f, (x + 100.0f) / 100.0f); } },
+    { ShaderManager::Effect::Whirl, [](float x) { return x * (float)pi / 180.0f; } },
+    { ShaderManager::Effect::Pixelate, [](float x) { return std::abs(x) / 10.0f; } },
+    { ShaderManager::Effect::Mosaic, [](float x) { return std::max(1.0f, std::min(std::round((std::abs(x) + 10.0f) / 10.0f), 512.0f)); } }
 };
 
-static const std::unordered_map<ShaderManager::Effect, bool>
-    EFFECT_SHAPE_CHANGES = { { ShaderManager::Effect::Color, false }, { ShaderManager::Effect::Brightness, false }, { ShaderManager::Effect::Ghost, false } };
+static const std::unordered_map<ShaderManager::Effect, bool> EFFECT_SHAPE_CHANGES = {
+    { ShaderManager::Effect::Color, false }, { ShaderManager::Effect::Brightness, false }, { ShaderManager::Effect::Ghost, false }, { ShaderManager::Effect::Fisheye, true },
+    { ShaderManager::Effect::Whirl, true },  { ShaderManager::Effect::Pixelate, true },    { ShaderManager::Effect::Mosaic, true }
+};
+
+std::unordered_set<ShaderManager::Effect> ShaderManager::m_effects; // populated by effects()
 
 Q_GLOBAL_STATIC(ShaderManager, globalInstance)
 
@@ -58,7 +81,7 @@ ShaderManager::ShaderManager(QObject *parent) :
     QByteArray vertexShaderSource;
     QFile vertSource(VERTEX_SHADER_SRC);
     vertSource.open(QFile::ReadOnly);
-    vertexShaderSource = "#version 330 core\n" + vertSource.readAll();
+    vertexShaderSource = SHADER_PREFIX.toUtf8() + vertSource.readAll();
 
     m_vertexShader = new QOpenGLShader(QOpenGLShader::Vertex, this);
     m_vertexShader->compileSourceCode(vertexShaderSource);
@@ -119,11 +142,14 @@ void ShaderManager::getUniformValuesForEffects(const std::unordered_map<Effect,
     }
 }
 
-void ShaderManager::setUniforms(QOpenGLShaderProgram *program, int textureUnit, const std::unordered_map<Effect, double> &effectValues)
+void ShaderManager::setUniforms(QOpenGLShaderProgram *program, int textureUnit, const QSize skinSize, const std::unordered_map<Effect, double> &effectValues)
 {
     // Set the texture unit
     program->setUniformValue(TEXTURE_UNIT_UNIFORM, textureUnit);
 
+    // Set skin size
+    program->setUniformValue(SKIN_SIZE_UNIFORM, QVector2D(skinSize.width(), skinSize.height()));
+
     // Set uniform values
     std::unordered_map<Effect, float> values;
     getUniformValuesForEffects(effectValues, values);
@@ -132,6 +158,22 @@ void ShaderManager::setUniforms(QOpenGLShaderProgram *program, int textureUnit,
         program->setUniformValue(EFFECT_UNIFORM_NAME.at(effect), value);
 }
 
+const std::unordered_set<ShaderManager::Effect> &ShaderManager::effects()
+{
+    if (m_effects.empty()) {
+        for (const auto &[effect, name] : EFFECT_TO_NAME)
+            m_effects.insert(effect);
+    }
+
+    return m_effects;
+}
+
+bool ShaderManager::effectShapeChanges(Effect effect)
+{
+    Q_ASSERT(EFFECT_SHAPE_CHANGES.find(effect) != EFFECT_SHAPE_CHANGES.cend());
+    return EFFECT_SHAPE_CHANGES.at(effect);
+}
+
 void ShaderManager::registerEffects()
 {
     // Register graphic effects in libscratchcpp
@@ -150,7 +192,7 @@ QOpenGLShaderProgram *ShaderManager::createShaderProgram(const std::unordered_ma
         return nullptr;
 
     // Version must be defined in the first line
-    QByteArray fragSource = "#version 330\n";
+    QByteArray fragSource = SHADER_PREFIX.toUtf8();
 
     // Add defines for the effects
     for (const auto &[effect, value] : effectValues) {
diff --git a/src/shadermanager.h b/src/shadermanager.h
index edcb614..210ee98 100644
--- a/src/shadermanager.h
+++ b/src/shadermanager.h
@@ -4,6 +4,7 @@
 
 #include <QObject>
 #include <memory>
+#include <unordered_set>
 
 class QOpenGLShaderProgram;
 class QOpenGLShader;
@@ -16,9 +17,14 @@ class ShaderManager : public QObject
     public:
         enum class Effect
         {
+            NoEffect = 0,
             Color = 1 << 0,
             Brightness = 1 << 1,
-            Ghost = 1 << 2
+            Ghost = 1 << 2,
+            Fisheye = 1 << 3,
+            Whirl = 1 << 4,
+            Pixelate = 1 << 5,
+            Mosaic = 1 << 6
         };
 
         explicit ShaderManager(QObject *parent = nullptr);
@@ -27,7 +33,10 @@ class ShaderManager : public QObject
 
         QOpenGLShaderProgram *getShaderProgram(const std::unordered_map<Effect, double> &effectValues);
         static void getUniformValuesForEffects(const std::unordered_map<Effect, double> &effectValues, std::unordered_map<Effect, float> &dst);
-        void setUniforms(QOpenGLShaderProgram *program, int textureUnit, const std::unordered_map<Effect, double> &effectValues);
+        void setUniforms(QOpenGLShaderProgram *program, int textureUnit, const QSize skinSize, const std::unordered_map<Effect, double> &effectValues);
+
+        static const std::unordered_set<Effect> &effects();
+        static bool effectShapeChanges(Effect effect);
 
     private:
         struct Registrar
@@ -40,10 +49,56 @@ class ShaderManager : public QObject
         QOpenGLShaderProgram *createShaderProgram(const std::unordered_map<Effect, double> &effectValues);
 
         static Registrar m_registrar;
+        static std::unordered_set<Effect> m_effects;
 
         QOpenGLShader *m_vertexShader = nullptr;
         std::unordered_map<int, QOpenGLShaderProgram *> m_shaderPrograms;
         QByteArray m_fragmentShaderSource;
 };
 
+inline ShaderManager::Effect operator|(ShaderManager::Effect a, ShaderManager::Effect b)
+{
+    return static_cast<ShaderManager::Effect>(static_cast<int>(a) | static_cast<int>(b));
+}
+
+inline ShaderManager::Effect operator|=(ShaderManager::Effect &a, ShaderManager::Effect b)
+{
+    return static_cast<ShaderManager::Effect>((int &)a |= static_cast<int>(b));
+}
+
+inline ShaderManager::Effect operator&(ShaderManager::Effect a, ShaderManager::Effect b)
+{
+    return static_cast<ShaderManager::Effect>(static_cast<int>(a) & static_cast<int>(b));
+}
+
+inline ShaderManager::Effect operator&=(ShaderManager::Effect &a, ShaderManager::Effect b)
+{
+    return static_cast<ShaderManager::Effect>((int &)a &= static_cast<int>(b));
+}
+
+inline ShaderManager::Effect operator~(ShaderManager::Effect a)
+{
+    return static_cast<ShaderManager::Effect>(~static_cast<int>(a));
+}
+
+inline bool operator==(ShaderManager::Effect a, int b)
+{
+    return static_cast<int>(a) == b;
+}
+
+inline bool operator==(int a, ShaderManager::Effect b)
+{
+    return a == static_cast<int>(b);
+}
+
+inline bool operator!=(ShaderManager::Effect a, int b)
+{
+    return static_cast<int>(a) != b;
+}
+
+inline bool operator!=(int a, ShaderManager::Effect b)
+{
+    return a != static_cast<int>(b);
+}
+
 } // namespace scratchcpprender
diff --git a/src/shaders/sprite.frag b/src/shaders/sprite.frag
index 9568bf5..3209cf8 100644
--- a/src/shaders/sprite.frag
+++ b/src/shaders/sprite.frag
@@ -1,5 +1,11 @@
 // Ported from https://github.com/scratchfoundation/scratch-render/blob/4090e62e8abf427e55c83448da9b0df26120d2fb/src/shaders/sprite.frag
 
+#undef lowp
+#undef mediump
+#undef highp
+
+precision mediump float;
+
 #ifdef ENABLE_color
 uniform float u_color;
 #endif // ENABLE_color
@@ -12,8 +18,24 @@ uniform float u_brightness;
 uniform float u_ghost;
 #endif // ENABLE_ghost
 
-in vec2 v_texCoord;
-//out vec4 FragColor;
+#ifdef ENABLE_fisheye
+uniform float u_fisheye;
+#endif // ENABLE_fisheye
+
+#ifdef ENABLE_whirl
+uniform float u_whirl;
+#endif // ENABLE_whirl
+
+#ifdef ENABLE_pixelate
+uniform float u_pixelate;
+uniform vec2 u_skinSize;
+#endif // ENABLE_pixelate
+
+#ifdef ENABLE_mosaic
+uniform float u_mosaic;
+#endif // ENABLE_mosaic
+
+varying vec2 v_texCoord;
 uniform sampler2D u_skin;
 
 // Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations.
@@ -82,11 +104,59 @@ const vec2 kCenter = vec2(0.5, 0.5);
 
 void main()
 {
-    vec4 texColor = texture2D(u_skin, v_texCoord);
+    vec2 texcoord0 = v_texCoord;
+
+    #ifdef ENABLE_mosaic
+    texcoord0 = fract(u_mosaic * texcoord0);
+    #endif // ENABLE_mosaic
+
+    #ifdef ENABLE_pixelate
+    {
+        // TODO: clean up "pixel" edges
+        vec2 pixelTexelSize = u_skinSize / u_pixelate;
+        texcoord0 = (floor(texcoord0 * pixelTexelSize) + kCenter) / pixelTexelSize;
+    }
+    #endif // ENABLE_pixelate
+
+    #ifdef ENABLE_whirl
+    {
+        const float kRadius = 0.5;
+        vec2 offset = texcoord0 - kCenter;
+        float offsetMagnitude = length(offset);
+        float whirlFactor = max(1.0 - (offsetMagnitude / kRadius), 0.0);
+        float whirlActual = u_whirl * whirlFactor * whirlFactor;
+        float sinWhirl = sin(whirlActual);
+        float cosWhirl = cos(whirlActual);
+        mat2 rotationMatrix = mat2(
+            cosWhirl, -sinWhirl,
+            sinWhirl, cosWhirl
+        );
+
+        texcoord0 = rotationMatrix * offset + kCenter;
+    }
+    #endif // ENABLE_whirl
+
+    #ifdef ENABLE_fisheye
+    {
+        vec2 vec = (texcoord0 - kCenter) / kCenter;
+        float vecLength = length(vec);
+        float r = pow(min(vecLength, 1.0), u_fisheye) * max(1.0, vecLength);
+        vec2 unit = vec / vecLength;
+
+        texcoord0 = kCenter + r * unit * kCenter;
+    }
+    #endif // ENABLE_fisheye
+
+    gl_FragColor = texture2D(u_skin, texcoord0);
+
+    #if defined(ENABLE_color) || defined(ENABLE_brightness)
+    // Divide premultiplied alpha values for proper color processing
+    // Add epsilon to avoid dividing by 0 for fully transparent pixels
+    gl_FragColor.rgb = clamp(gl_FragColor.rgb / (gl_FragColor.a + epsilon), 0.0, 1.0);
 
     #ifdef ENABLE_color
     {
-        vec3 hsv = convertRGB2HSV(texColor.rgb);
+        vec3 hsv = convertRGB2HSV(gl_FragColor.rgb);
 
         // Force grayscale values to be slightly saturated
         const float minLightness = 0.11 / 2.0;
@@ -97,22 +167,20 @@ void main()
         hsv.x = mod(hsv.x + u_color, 1.0);
         if (hsv.x < 0.0) hsv.x += 1.0;
 
-        texColor.rgb = convertHSV2RGB(hsv);
+        gl_FragColor.rgb = convertHSV2RGB(hsv);
     }
     #endif // ENABLE_color
 
     #ifdef ENABLE_brightness
-    texColor.rgb = clamp(texColor.rgb + vec3(u_brightness), vec3(0), vec3(1));
+    gl_FragColor.rgb = clamp(gl_FragColor.rgb + vec3(u_brightness), vec3(0), vec3(1));
     #endif // ENABLE_brightness
 
+    // Re-multiply color values
+    gl_FragColor.rgb *= gl_FragColor.a + epsilon;
+
+    #endif // defined(ENABLE_color) || defined(ENABLE_brightness)
+
     #ifdef ENABLE_ghost
-    texColor *= u_ghost;
+    gl_FragColor *= u_ghost;
     #endif // ENABLE_ghost
-
-    // Set RGB components to zero if the color is fully transparent
-    // This is a workaround for rendering issues when alpha is zero
-    if(texColor.a == 0.0)
-        gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
-    else
-        gl_FragColor = texColor;
 }
diff --git a/src/shaders/sprite.vert b/src/shaders/sprite.vert
index f7d09b0..ef79415 100644
--- a/src/shaders/sprite.vert
+++ b/src/shaders/sprite.vert
@@ -1,9 +1,11 @@
-layout (location = 0) in vec2 a_position;
-layout (location = 1) in vec2 a_texCoord;
+uniform mat4 u_projectionMatrix;
+uniform mat4 u_modelMatrix;
+attribute vec2 a_position;
+attribute vec2 a_texCoord;
 
-out vec2 v_texCoord;
+varying vec2 v_texCoord;
 
 void main() {
-    gl_Position = vec4(a_position, 0.0, 1.0);
+    gl_Position = u_projectionMatrix * u_modelMatrix * vec4(a_position, 0, 1);
     v_texCoord = a_texCoord;
 }
diff --git a/src/skin.cpp b/src/skin.cpp
index 5d25b48..6ac4d6b 100644
--- a/src/skin.cpp
+++ b/src/skin.cpp
@@ -12,11 +12,13 @@ Skin::Skin()
     QOpenGLContext *context = QOpenGLContext::currentContext();
     Q_ASSERT(context);
 
-    if (context) {
-        QObject::connect(context, &QOpenGLContext::aboutToBeDestroyed, &m_signalHandler, [this]() {
+    if (!m_connectedCtx || (context && context != m_connectedCtx)) {
+        QObject::connect(context, &QOpenGLContext::aboutToBeDestroyed, []() {
             // Destroy textures
             m_textures.clear();
         });
+
+        m_connectedCtx = context;
     }
 }
 
@@ -56,8 +58,12 @@ Texture Skin::createAndPaintTexture(int width, int height)
     // Create final texture from the image
     auto texture = std::make_shared<QOpenGLTexture>(image);
     m_textures.push_back(texture);
-    texture->setMinificationFilter(QOpenGLTexture::LinearMipMapLinear);
-    texture->setMagnificationFilter(QOpenGLTexture::Linear);
+    texture->setMinificationFilter(QOpenGLTexture::Nearest);
+    texture->setMagnificationFilter(QOpenGLTexture::Nearest);
+    texture->bind();
+    glF.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
+    glF.glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
+    texture->release();
 
     return Texture(texture->textureId(), width, height);
 }
diff --git a/src/skin.h b/src/skin.h
index 855ef65..4113024 100644
--- a/src/skin.h
+++ b/src/skin.h
@@ -26,8 +26,8 @@ class Skin
         virtual void paint(QPainter *painter) = 0;
 
     private:
-        std::vector<std::shared_ptr<QOpenGLTexture>> m_textures;
-        QObject m_signalHandler; // for disconnecting signals after destroyed
+        static inline std::vector<std::shared_ptr<QOpenGLTexture>> m_textures;
+        static inline QOpenGLContext *m_connectedCtx = nullptr;
 };
 
 } // namespace scratchcpprender
diff --git a/src/spritemodel.cpp b/src/spritemodel.cpp
index 9aca0db..4383022 100644
--- a/src/spritemodel.cpp
+++ b/src/spritemodel.cpp
@@ -20,35 +20,38 @@ SpriteModel::SpriteModel(QObject *parent) :
 
 void SpriteModel::init(libscratchcpp::Sprite *sprite)
 {
-    if (!sprite)
-        return;
-
     m_sprite = sprite;
 
-    m_sprite->bubble()->typeChanged().connect([this](libscratchcpp::TextBubble::Type type) {
-        if (type == libscratchcpp::TextBubble::Type::Say) {
-            if (m_bubbleType == TextBubbleShape::Type::Say)
-                return;
+    if (m_sprite) {
+        libscratchcpp::TextBubble *bubble = m_sprite->bubble();
+
+        bubble->typeChanged().connect([this](libscratchcpp::TextBubble::Type type) {
+            if (type == libscratchcpp::TextBubble::Type::Say) {
+                if (m_bubbleType == TextBubbleShape::Type::Say)
+                    return;
 
-            m_bubbleType = TextBubbleShape::Type::Say;
-        } else {
-            if (m_bubbleType == TextBubbleShape::Type::Think)
-                return;
+                m_bubbleType = TextBubbleShape::Type::Say;
+            } else {
+                if (m_bubbleType == TextBubbleShape::Type::Think)
+                    return;
 
-            m_bubbleType = TextBubbleShape::Type::Think;
-        }
+                m_bubbleType = TextBubbleShape::Type::Think;
+            }
 
-        emit bubbleTypeChanged();
-    });
+            emit bubbleTypeChanged();
+        });
 
-    m_sprite->bubble()->textChanged().connect([this](const std::string &text) {
-        QString newText = QString::fromStdString(text);
+        bubble->textChanged().connect([this](const std::string &text) {
+            QString newText = QString::fromStdString(text);
 
-        if (m_bubbleText != newText) {
-            m_bubbleText = newText;
-            emit bubbleTextChanged();
-        }
-    });
+            if (m_bubbleText != newText) {
+                m_bubbleText = newText;
+                emit bubbleTextChanged();
+            }
+        });
+
+        bubble->layerOrderChanged().connect([this](int) { emit bubbleLayerChanged(); });
+    }
 }
 
 void SpriteModel::deinitClone()
diff --git a/src/stagemodel.cpp b/src/stagemodel.cpp
index 31f8f58..26c7dff 100644
--- a/src/stagemodel.cpp
+++ b/src/stagemodel.cpp
@@ -16,35 +16,38 @@ StageModel::StageModel(QObject *parent) :
 
 void StageModel::init(libscratchcpp::Stage *stage)
 {
-    if (!stage)
-        return;
-
     m_stage = stage;
 
-    m_stage->bubble()->typeChanged().connect([this](libscratchcpp::TextBubble::Type type) {
-        if (type == libscratchcpp::TextBubble::Type::Say) {
-            if (m_bubbleType == TextBubbleShape::Type::Say)
-                return;
+    if (m_stage) {
+        libscratchcpp::TextBubble *bubble = m_stage->bubble();
+
+        bubble->typeChanged().connect([this](libscratchcpp::TextBubble::Type type) {
+            if (type == libscratchcpp::TextBubble::Type::Say) {
+                if (m_bubbleType == TextBubbleShape::Type::Say)
+                    return;
+
+                m_bubbleType = TextBubbleShape::Type::Say;
+            } else {
+                if (m_bubbleType == TextBubbleShape::Type::Think)
+                    return;
 
-            m_bubbleType = TextBubbleShape::Type::Say;
-        } else {
-            if (m_bubbleType == TextBubbleShape::Type::Think)
-                return;
+                m_bubbleType = TextBubbleShape::Type::Think;
+            }
 
-            m_bubbleType = TextBubbleShape::Type::Think;
-        }
+            emit bubbleTypeChanged();
+        });
 
-        emit bubbleTypeChanged();
-    });
+        bubble->textChanged().connect([this](const std::string &text) {
+            QString newText = QString::fromStdString(text);
 
-    m_stage->bubble()->textChanged().connect([this](const std::string &text) {
-        QString newText = QString::fromStdString(text);
+            if (m_bubbleText != newText) {
+                m_bubbleText = newText;
+                emit bubbleTextChanged();
+            }
+        });
 
-        if (m_bubbleText != newText) {
-            m_bubbleText = newText;
-            emit bubbleTextChanged();
-        }
-    });
+        bubble->layerOrderChanged().connect([this](int) { emit bubbleLayerChanged(); });
+    }
 }
 
 void StageModel::onCostumeChanged(libscratchcpp::Costume *costume)
diff --git a/src/svgskin.cpp b/src/svgskin.cpp
index d2c35a6..55e131e 100644
--- a/src/svgskin.cpp
+++ b/src/svgskin.cpp
@@ -29,12 +29,6 @@ SVGSkin::SVGSkin(libscratchcpp::Costume *costume) :
     m_maxIndex = std::min(i1, i2);
 }
 
-SVGSkin::~SVGSkin()
-{
-    for (const auto &[index, texture] : m_textures)
-        m_textureObjects[texture].release();
-}
-
 Texture SVGSkin::getTexture(double scale) const
 {
     // https://github.com/scratchfoundation/scratch-render/blob/423bb700c36b8c1c0baae1e2413878a4f778849a/src/SVGSkin.js#L158-L176
diff --git a/src/svgskin.h b/src/svgskin.h
index f722e63..2662857 100644
--- a/src/svgskin.h
+++ b/src/svgskin.h
@@ -21,7 +21,6 @@ class SVGSkin : public Skin
 {
     public:
         SVGSkin(libscratchcpp::Costume *costume);
-        ~SVGSkin();
 
         Texture getTexture(double scale) const override;
         double getTextureScale(const Texture &texture) const override;
diff --git a/src/targetpainter.cpp b/src/targetpainter.cpp
index bd1cc40..7eb2dd7 100644
--- a/src/targetpainter.cpp
+++ b/src/targetpainter.cpp
@@ -67,9 +67,7 @@ void TargetPainter::paint(QNanoPainter *painter)
     Q_ASSERT(shaderProgram->isLinked());
 
     // Set up vertex data and buffers for a quad
-    float vertices[] = {
-        -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
-    };
+    float vertices[] = { -1.0f, -1.0f, 0.0f, 0.0f, 1.0f, -1.0f, 1.0f, 0.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, -1.0f, 1.0f, 0.0f, 1.0f };
 
     GLuint VBO, VAO;
     glF.glGenVertexArrays(1, &VAO);
@@ -94,8 +92,10 @@ void TargetPainter::paint(QNanoPainter *painter)
     glF.glBindVertexArray(VAO);
     glF.glActiveTexture(GL_TEXTURE0);
     glF.glBindTexture(GL_TEXTURE_2D, texture.handle());
-    shaderManager->setUniforms(shaderProgram, 0, effects); // set texture and effect uniforms
-    glF.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+    shaderManager->setUniforms(shaderProgram, 0, QSize(m_target->costumeWidth(), m_target->costumeHeight()), effects); // set texture and effect uniforms
+    shaderProgram->setUniformValue("u_projectionMatrix", QMatrix4x4());
+    shaderProgram->setUniformValue("u_modelMatrix", QMatrix4x4());
+    glF.glDrawArrays(GL_TRIANGLES, 0, 6);
 
     // Process the resulting texture
     // NOTE: This must happen now, not later, because the alpha channel can be used here
diff --git a/test/color_effects.png b/test/color_effects.png
index c18e4a3..5a34441 100644
Binary files a/test/color_effects.png and b/test/color_effects.png differ
diff --git a/test/common.h b/test/common.h
index 847aa84..36f354b 100644
--- a/test/common.h
+++ b/test/common.h
@@ -28,7 +28,7 @@ double fuzzyCompareImages(const QImage &a, const QImage &b)
 
     for (y = 0; y < a.height(); y++) {
         for (x = 0; x < a.width(); x++) {
-            if (a.pixel(x, y) != b.pixel(x, y))
+            if (a.pixelColor(x, y).rgba() != b.pixelColor(x, y).rgba())
                 c++;
         }
     }
diff --git a/test/effecttransform/effecttransform_test.cpp b/test/effecttransform/effecttransform_test.cpp
index d5f92fa..d9ee128 100644
--- a/test/effecttransform/effecttransform_test.cpp
+++ b/test/effecttransform/effecttransform_test.cpp
@@ -16,19 +16,20 @@ class EffectTransformTest : public testing::Test
 TEST_F(EffectTransformTest, NoEffect)
 {
     QRgb color = qRgba(0, 0, 0, 0);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), color);
+    auto mask = ShaderManager::Effect::NoEffect;
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), color);
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     color = qRgba(0, 255, 255, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), color);
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     color = qRgba(255, 255, 255, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), color);
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     QVector2D dst;
-    EffectTransform::transformPoint(m_effects, QVector2D(0.5, -0.3), dst);
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.5, -0.3), dst);
     ASSERT_EQ(dst, QVector2D(0.5, -0.3));
 }
 
@@ -36,88 +37,159 @@ TEST_F(EffectTransformTest, ColorEffect)
 {
     // 100
     m_effects[ShaderManager::Effect::Color] = 100;
+    auto mask = ShaderManager::Effect::Color;
     QRgb color = qRgba(0, 0, 0, 0);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), color);
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgb(0, 255, 255));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgb(0, 255, 255));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(128, 100, 100, 128));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(128, 100, 100, 128));
 
     // 175
     m_effects[ShaderManager::Effect::Color] = 175;
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgb(255, 0, 191));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgb(255, 0, 191));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(100, 128, 107, 128));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(100, 128, 107, 128));
 }
 
 TEST_F(EffectTransformTest, BrightnessEffect)
 {
     // -100
     m_effects[ShaderManager::Effect::Brightness] = -100;
+    auto mask = ShaderManager::Effect::Brightness;
     QRgb color = qRgba(0, 0, 0, 0);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), color);
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgb(0, 0, 0));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgb(0, 0, 0));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(0, 0, 0, 128));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(0, 0, 0, 128));
 
     // -50
     m_effects[ShaderManager::Effect::Brightness] = -50;
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgb(127, 0, 0));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgb(127, 0, 0));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(36, 64, 64, 128));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(36, 64, 64, 128));
 
     // 50
     m_effects[ShaderManager::Effect::Brightness] = 50;
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgb(255, 127, 127));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgb(255, 127, 127));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(128, 128, 128, 128));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(128, 128, 128, 128));
 
     // 100
     m_effects[ShaderManager::Effect::Brightness] = 100;
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgb(255, 255, 255));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgb(255, 255, 255));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(128, 128, 128, 128));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(128, 128, 128, 128));
 }
 
 TEST_F(EffectTransformTest, GhostEffect)
 {
     // 25
     m_effects[ShaderManager::Effect::Ghost] = 25;
+    auto mask = ShaderManager::Effect::Ghost;
     QRgb color = qRgba(0, 0, 0, 0);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), color);
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(191, 0, 0, 191));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(191, 0, 0, 191));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(75, 191, 150, 96));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(75, 191, 150, 96));
 
     // 50
     m_effects[ShaderManager::Effect::Ghost] = 50;
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(128, 0, 0, 128));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(128, 0, 0, 128));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(50, 128, 100, 64));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(50, 128, 100, 64));
 
     // 100
     m_effects[ShaderManager::Effect::Ghost] = 100;
     color = qRgba(255, 0, 0, 255);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(0, 0, 0, 0));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(0, 0, 0, 0));
 
     color = qRgba(100, 255, 200, 128);
-    ASSERT_EQ(EffectTransform::transformColor(m_effects, color), qRgba(0, 0, 0, 0));
+    ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), qRgba(0, 0, 0, 0));
+}
+
+TEST_F(EffectTransformTest, FisheyeEffect)
+{
+    // 50
+    m_effects[ShaderManager::Effect::Fisheye] = 50;
+    auto mask = ShaderManager::Effect::Fisheye;
+    QVector2D dst;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.51, 0.49), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 0.502f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 0.498f);
+
+    // 200
+    m_effects[ShaderManager::Effect::Fisheye] = 200;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.4, 0.68), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 0.483f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 0.531f);
+}
+
+TEST_F(EffectTransformTest, WhirlEffect)
+{
+    // 50
+    m_effects[ShaderManager::Effect::Whirl] = 50;
+    auto mask = ShaderManager::Effect::Whirl;
+    QVector2D dst;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.51, 0.49), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 0.499f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 0.486f);
+
+    // 200
+    m_effects[ShaderManager::Effect::Whirl] = 200;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.4, 0.68), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 0.633f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 0.657f);
+}
+
+TEST_F(EffectTransformTest, PixelateEffect)
+{
+    // 5
+    m_effects[ShaderManager::Effect::Pixelate] = 5;
+    auto mask = ShaderManager::Effect::Pixelate;
+    QVector2D dst;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.51, 0.05), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 0.75f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 0.25f);
+
+    // 20
+    m_effects[ShaderManager::Effect::Pixelate] = 20;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.97, 0.68), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 1.0f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 1.0f);
+}
+
+TEST_F(EffectTransformTest, MosaicEffect)
+{
+    // 50
+    m_effects[ShaderManager::Effect::Mosaic] = 50;
+    auto mask = ShaderManager::Effect::Mosaic;
+    QVector2D dst;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.75, 0.25), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 0.5f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 0.5f);
+
+    // 200
+    m_effects[ShaderManager::Effect::Mosaic] = 200;
+    EffectTransform::transformPoint(mask, m_effects, QSize(), QVector2D(0.8, 0.68), dst);
+    ASSERT_EQ(std::round(dst.x() * 1000.0f) / 1000.0f, 0.8f);
+    ASSERT_EQ(std::round(dst.y() * 1000.0f) / 1000.0f, 0.28f);
 }
diff --git a/test/graphicseffect/graphicseffect_test.cpp b/test/graphicseffect/graphicseffect_test.cpp
index bdd7238..4820439 100644
--- a/test/graphicseffect/graphicseffect_test.cpp
+++ b/test/graphicseffect/graphicseffect_test.cpp
@@ -54,5 +54,31 @@ TEST(GraphicsEffectTest, Clamp)
         ASSERT_EQ(effect.clamp(125), 100);
     }
 
-    // TODO: Test remaining effects
+    {
+        GraphicsEffect effect(ShaderManager::Effect::Fisheye, "fisheye");
+        ASSERT_EQ(effect.clamp(-5000), -5000);
+        ASSERT_EQ(effect.clamp(0), 0);
+        ASSERT_EQ(effect.clamp(5000), 5000);
+    }
+
+    {
+        GraphicsEffect effect(ShaderManager::Effect::Whirl, "whirl");
+        ASSERT_EQ(effect.clamp(-5000), -5000);
+        ASSERT_EQ(effect.clamp(0), 0);
+        ASSERT_EQ(effect.clamp(5000), 5000);
+    }
+
+    {
+        GraphicsEffect effect(ShaderManager::Effect::Pixelate, "pixelate");
+        ASSERT_EQ(effect.clamp(-5000), -5000);
+        ASSERT_EQ(effect.clamp(0), 0);
+        ASSERT_EQ(effect.clamp(5000), 5000);
+    }
+
+    {
+        GraphicsEffect effect(ShaderManager::Effect::Mosaic, "mosaic");
+        ASSERT_EQ(effect.clamp(-5000), -5000);
+        ASSERT_EQ(effect.clamp(0), 0);
+        ASSERT_EQ(effect.clamp(5000), 5000);
+    }
 }
diff --git a/test/monitor_models/listmonitorlistmodel_test.cpp b/test/monitor_models/listmonitorlistmodel_test.cpp
index 42c1539..544f4f0 100644
--- a/test/monitor_models/listmonitorlistmodel_test.cpp
+++ b/test/monitor_models/listmonitorlistmodel_test.cpp
@@ -28,7 +28,9 @@ TEST(ListMonitorListModelTest, LoadData)
     List list1("", "");
     list1.append(1);
     list1.append(2);
-    model.setList(&list1);
+    list1.append(3);
+    list1.append(4);
+    model.setList(&list1, 0, 3);
     ASSERT_TRUE(dataChangedSpy.empty());
     ASSERT_EQ(aboutToResetSpy.count(), 1);
     ASSERT_EQ(resetSpy.count(), 1);
@@ -37,8 +39,17 @@ TEST(ListMonitorListModelTest, LoadData)
     ASSERT_TRUE(aboutToRemoveSpy.empty());
     ASSERT_TRUE(removeSpy.empty());
 
-    model.setList(&list1);
-    ASSERT_EQ(dataChangedSpy.count(), 2);
+    model.setList(&list1, 0, 3);
+    ASSERT_EQ(dataChangedSpy.count(), 4);
+    ASSERT_EQ(aboutToResetSpy.count(), 1);
+    ASSERT_EQ(resetSpy.count(), 1);
+    ASSERT_TRUE(aboutToInsertSpy.empty());
+    ASSERT_TRUE(insertSpy.empty());
+    ASSERT_TRUE(aboutToRemoveSpy.empty());
+    ASSERT_TRUE(removeSpy.empty());
+
+    model.setList(&list1, 1, 3);
+    ASSERT_EQ(dataChangedSpy.count(), 7);
     ASSERT_EQ(aboutToResetSpy.count(), 1);
     ASSERT_EQ(resetSpy.count(), 1);
     ASSERT_TRUE(aboutToInsertSpy.empty());
@@ -65,8 +76,8 @@ TEST(ListMonitorListModelTest, LoadData)
     ASSERT_TRUE(args.at(2).toList().isEmpty());
 
     List list2("", "");
-    model.setList(&list2);
-    ASSERT_EQ(dataChangedSpy.count(), 2);
+    model.setList(&list2, 0, 3);
+    ASSERT_EQ(dataChangedSpy.count(), 7);
     ASSERT_EQ(aboutToResetSpy.count(), 2);
     ASSERT_EQ(resetSpy.count(), 2);
     ASSERT_TRUE(aboutToInsertSpy.empty());
@@ -89,7 +100,7 @@ TEST(ListMonitorListModelTest, AddRows)
     List list1("", "");
     list1.append(1);
     list1.append(2);
-    model.setList(&list1);
+    model.setList(&list1, 0, 1);
     ASSERT_TRUE(dataChangedSpy.empty());
     ASSERT_EQ(aboutToResetSpy.count(), 1);
     ASSERT_EQ(resetSpy.count(), 1);
@@ -101,7 +112,7 @@ TEST(ListMonitorListModelTest, AddRows)
     list1.append(9);
     list1.append(8);
     list1.append(7);
-    model.setList(&list1);
+    model.setList(&list1, 2, 3);
     ASSERT_EQ(dataChangedSpy.count(), 2);
     ASSERT_EQ(aboutToResetSpy.count(), 1);
     ASSERT_EQ(resetSpy.count(), 1);
@@ -113,18 +124,18 @@ TEST(ListMonitorListModelTest, AddRows)
     auto args = dataChangedSpy.at(0);
     QModelIndex arg1 = args.at(0).value<QModelIndex>();
     QModelIndex arg2 = args.at(1).value<QModelIndex>();
-    ASSERT_EQ(arg1.row(), 0);
+    ASSERT_EQ(arg1.row(), 2);
     ASSERT_EQ(arg1.column(), 0);
-    ASSERT_EQ(arg2.row(), 0);
+    ASSERT_EQ(arg2.row(), 2);
     ASSERT_EQ(arg2.column(), 0);
     ASSERT_TRUE(args.at(2).toList().isEmpty());
 
     args = dataChangedSpy.at(1);
     arg1 = args.at(0).value<QModelIndex>();
     arg2 = args.at(1).value<QModelIndex>();
-    ASSERT_EQ(arg1.row(), 1);
+    ASSERT_EQ(arg1.row(), 3);
     ASSERT_EQ(arg1.column(), 0);
-    ASSERT_EQ(arg2.row(), 1);
+    ASSERT_EQ(arg2.row(), 3);
     ASSERT_EQ(arg2.column(), 0);
     ASSERT_TRUE(args.at(2).toList().isEmpty());
 
@@ -150,7 +161,7 @@ TEST(ListMonitorListModelTest, RemoveRows)
     list1.append(1);
     list1.append(2);
     list1.append(3);
-    model.setList(&list1);
+    model.setList(&list1, 0, 2);
     ASSERT_TRUE(dataChangedSpy.empty());
     ASSERT_EQ(aboutToResetSpy.count(), 1);
     ASSERT_EQ(resetSpy.count(), 1);
@@ -161,7 +172,7 @@ TEST(ListMonitorListModelTest, RemoveRows)
 
     list1.removeAt(2);
     list1.removeAt(0);
-    model.setList(&list1);
+    model.setList(&list1, 0, 0);
     ASSERT_EQ(dataChangedSpy.count(), 1);
     ASSERT_EQ(aboutToResetSpy.count(), 1);
     ASSERT_EQ(resetSpy.count(), 1);
@@ -193,7 +204,7 @@ TEST(ListMonitorListModelTest, RowCount)
     list.append(1);
     list.append(2);
     list.append(3);
-    model.setList(&list);
+    model.setList(&list, 0, 2);
     ASSERT_EQ(model.rowCount(QModelIndex()), list.size());
 }
 
@@ -204,7 +215,7 @@ TEST(ListMonitorListModelTest, Data)
     list.append(1);
     list.append(2);
     list.append(3);
-    model.setList(&list);
+    model.setList(&list, 0, 2);
     ASSERT_EQ(model.data(model.index(0), 0).toString(), "1");
     ASSERT_EQ(model.data(model.index(1), 0).toString(), "2");
     ASSERT_EQ(model.data(model.index(2), 0).toString(), "3");
diff --git a/test/monitor_models/listmonitormodel_test.cpp b/test/monitor_models/listmonitormodel_test.cpp
index 1b00f00..6f9db75 100644
--- a/test/monitor_models/listmonitormodel_test.cpp
+++ b/test/monitor_models/listmonitormodel_test.cpp
@@ -28,6 +28,7 @@ TEST(ListMonitorModelTest, OnValueChanged)
 {
     ListMonitorModel model;
     ListMonitorListModel *listModel = model.listModel();
+    QSignalSpy dataChangedSpy(listModel, &ListMonitorListModel::dataChanged);
     VirtualMachine vm;
 
     List list1("", "");
@@ -57,6 +58,23 @@ TEST(ListMonitorModelTest, OnValueChanged)
     model.onValueChanged(&vm);
     ASSERT_EQ(listModel->rowCount(QModelIndex()), 3);
 
+    vm.reset();
+    vm.addReturnValue(2);
+    model.setMinIndex(0);
+    model.setMaxIndex(3);
+    model.onValueChanged(&vm);
+    ASSERT_EQ(listModel->rowCount(QModelIndex()), 3);
+    ASSERT_EQ(dataChangedSpy.count(), 4);
+
+    dataChangedSpy.clear();
+    vm.reset();
+    vm.addReturnValue(2);
+    model.setMinIndex(1);
+    model.setMaxIndex(2);
+    model.onValueChanged(&vm);
+    ASSERT_EQ(listModel->rowCount(QModelIndex()), 3);
+    ASSERT_EQ(dataChangedSpy.count(), 2);
+
     vm.reset();
     vm.addReturnValue(0);
     model.onValueChanged(&vm);
@@ -68,3 +86,21 @@ TEST(ListMonitorModelTest, Type)
     ListMonitorModel model;
     ASSERT_EQ(model.type(), MonitorModel::Type::List);
 }
+
+TEST(ListMonitorModelTest, MinIndex)
+{
+    ListMonitorModel model;
+    ASSERT_EQ(model.minIndex(), 0);
+
+    model.setMinIndex(2);
+    ASSERT_EQ(model.minIndex(), 2);
+}
+
+TEST(ListMonitorModelTest, MaxIndex)
+{
+    ListMonitorModel model;
+    ASSERT_EQ(model.maxIndex(), 0);
+
+    model.setMaxIndex(17);
+    ASSERT_EQ(model.maxIndex(), 17);
+}
diff --git a/test/penlayer/penlayer_test.cpp b/test/penlayer/penlayer_test.cpp
index 9470f5b..007ef81 100644
--- a/test/penlayer/penlayer_test.cpp
+++ b/test/penlayer/penlayer_test.cpp
@@ -30,11 +30,17 @@ class PenLayerTest : public testing::Test
             m_surface.create();
             Q_ASSERT(m_surface.isValid());
             m_context.makeCurrent(&m_surface);
+
+            QOpenGLFunctions glF(&m_context);
+            glF.initializeOpenGLFunctions();
+            glF.glEnable(GL_BLEND);
+            glF.glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
         }
 
         void TearDown() override
         {
             ASSERT_EQ(m_context.surface(), &m_surface);
+            emit m_context.aboutToBeDestroyed();
             m_context.doneCurrent();
         }
 
@@ -372,6 +378,8 @@ TEST_F(PenLayerTest, Stamp)
     loader.setFileName("stamp_env.sb3");
     loader.start(); // wait until it loads
 
+    EXPECT_CALL(engine, stageWidth()).WillRepeatedly(Return(480));
+    EXPECT_CALL(engine, stageHeight()).WillRepeatedly(Return(360));
     std::vector<std::unique_ptr<RenderedTarget>> targets;
     StageModel *stage = loader.stage();
     targets.push_back(std::make_unique<RenderedTarget>());
@@ -393,6 +401,7 @@ TEST_F(PenLayerTest, Stamp)
         targets.back()->updateCostume(sprite->sprite()->currentCostume().get());
         targets.back()->setGraphicEffect(ShaderManager::Effect::Color, i * 25);
         targets.back()->setGraphicEffect(ShaderManager::Effect::Ghost, i * 5);
+        targets.back()->setGraphicEffect(ShaderManager::Effect::Pixelate, i * 25);
         sprite->setRenderedTarget(targets.back().get());
         i++;
     }
@@ -404,12 +413,11 @@ TEST_F(PenLayerTest, Stamp)
         QOpenGLFramebufferObject *fbo = penLayer.framebufferObject();
         QImage image = fbo->toImage().scaled(240, 180);
         QImage ref("stamp.png");
-        ASSERT_LE(fuzzyCompareImages(image, ref), 0.1668);
+        ASSERT_LE(fuzzyCompareImages(image, ref), 0.22);
     }
 
     // Test HQ pen
     penLayer.clear();
-    EXPECT_CALL(engine, stageWidth()).Times(3).WillRepeatedly(Return(480));
     penLayer.setHqPen(true);
     penLayer.setWidth(720);
     penLayer.setHeight(540);
@@ -421,7 +429,7 @@ TEST_F(PenLayerTest, Stamp)
         QOpenGLFramebufferObject *fbo = penLayer.framebufferObject();
         QImage image = fbo->toImage();
         QImage ref("stamp_hq.png");
-        ASSERT_LE(fuzzyCompareImages(image, ref), 0.32);
+        ASSERT_LE(fuzzyCompareImages(image, ref), 0.42);
     }
 }
 
diff --git a/test/projectloader/projectloader_test.cpp b/test/projectloader/projectloader_test.cpp
index 5684efa..db430a3 100644
--- a/test/projectloader/projectloader_test.cpp
+++ b/test/projectloader/projectloader_test.cpp
@@ -57,8 +57,8 @@ class ProjectLoaderTest : public testing::Test
             ASSERT_EQ(loadingFinishedSpy.count(), 1);
             ASSERT_EQ(engineSpy.count(), 2);
             ASSERT_EQ(stageSpy.count(), 1);
-            ASSERT_EQ(spritesSpy.count(), 3);
-            ASSERT_EQ(clonesSpy.count(), 3);
+            ASSERT_EQ(spritesSpy.count(), 2);
+            ASSERT_EQ(clonesSpy.count(), 1);
             ASSERT_EQ(monitorsSpy.count(), loader->monitorList().size() + 1);
             ASSERT_EQ(monitorAddedSpy.count(), loader->monitorList().size());
             ASSERT_EQ(unsupportedBlocksSpy.count(), 1);
@@ -137,13 +137,13 @@ TEST_F(ProjectLoaderTest, Clones)
     load(&loader, "clones.sb3");
     ASSERT_TRUE(cloneCreatedSpy.empty());
     ASSERT_TRUE(cloneDeletedSpy.empty());
-    ASSERT_EQ(clonesChangedSpy.count(), 3);
+    ASSERT_EQ(clonesChangedSpy.count(), 1);
 
     auto engine = loader.engine();
     engine->run();
     ASSERT_EQ(cloneCreatedSpy.count(), 3);
     ASSERT_EQ(cloneDeletedSpy.count(), 0);
-    ASSERT_EQ(clonesChangedSpy.count(), 6);
+    ASSERT_EQ(clonesChangedSpy.count(), 4);
 
     const auto &sprites = loader.spriteList();
     const auto &clones = loader.cloneList();
@@ -162,7 +162,7 @@ TEST_F(ProjectLoaderTest, Clones)
     clones[1]->sprite()->deleteClone();
     ASSERT_EQ(cloneCreatedSpy.count(), 3);
     ASSERT_EQ(cloneDeletedSpy.count(), 1);
-    ASSERT_EQ(clonesChangedSpy.count(), 7);
+    ASSERT_EQ(clonesChangedSpy.count(), 5);
     ASSERT_EQ(clones.size(), 2);
 }
 
diff --git a/test/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index 8a9e6a5..4d67461 100644
--- a/test/renderedtarget/renderedtarget_test.cpp
+++ b/test/renderedtarget/renderedtarget_test.cpp
@@ -29,6 +29,18 @@ using ::testing::_;
 class RenderedTargetTest : public testing::Test
 {
     public:
+        void SetUp() override
+        {
+            // Create OpenGL context
+            createContextAndSurface(&m_context, &m_surface);
+        }
+
+        void TearDown() override
+        {
+            emit m_context.aboutToBeDestroyed();
+            m_context.doneCurrent();
+        }
+
         void createContextAndSurface(QOpenGLContext *context, QOffscreenSurface *surface)
         {
             QSurfaceFormat surfaceFormat;
@@ -46,6 +58,9 @@ class RenderedTargetTest : public testing::Test
             context->makeCurrent(surface);
             ASSERT_EQ(QOpenGLContext::currentContext(), context);
         }
+
+        QOpenGLContext m_context;
+        QOffscreenSurface m_surface;
 };
 
 TEST_F(RenderedTargetTest, Constructors)
@@ -58,9 +73,6 @@ TEST_F(RenderedTargetTest, Constructors)
 
 TEST_F(RenderedTargetTest, UpdateMethods)
 {
-    QOpenGLContext context;
-    QOffscreenSurface surface;
-    createContextAndSurface(&context, &surface);
     RenderedTarget parent; // a parent item is needed for setVisible() to work
     RenderedTarget target(&parent);
     EngineMock engine;
@@ -126,8 +138,8 @@ TEST_F(RenderedTargetTest, UpdateMethods)
     SpriteModel spriteModel;
     sprite.setInterface(&spriteModel);
 
-    EXPECT_CALL(engine, stageWidth()).Times(2).WillRepeatedly(Return(480));
-    EXPECT_CALL(engine, stageHeight()).Times(2).WillRepeatedly(Return(360));
+    EXPECT_CALL(engine, stageWidth()).Times(3).WillRepeatedly(Return(480));
+    EXPECT_CALL(engine, stageHeight()).Times(3).WillRepeatedly(Return(360));
     target.setSpriteModel(&spriteModel);
     target.loadCostumes();
     target.beforeRedraw();
@@ -325,8 +337,6 @@ TEST_F(RenderedTargetTest, UpdateMethods)
     ASSERT_TRUE(texture.isValid());
     ASSERT_EQ(texture.width(), 13);
     ASSERT_EQ(texture.height(), 13);
-
-    context.doneCurrent();
 }
 
 TEST_F(RenderedTargetTest, DeinitClone)
@@ -358,11 +368,6 @@ TEST_F(RenderedTargetTest, CpuRendering)
     target.setEngine(&engine);
     target.setSpriteModel(&model);
 
-    // Create OpenGL context
-    QOpenGLContext context;
-    QOffscreenSurface surface;
-    createContextAndSurface(&context, &surface);
-
     // Load costume
     EXPECT_CALL(engine, stageWidth()).WillRepeatedly(Return(480));
     EXPECT_CALL(engine, stageHeight()).WillRepeatedly(Return(360));
@@ -379,7 +384,7 @@ TEST_F(RenderedTargetTest, CpuRendering)
     // Test hull points
     target.setWidth(3);
     target.setHeight(3);
-    ASSERT_EQ(target.hullPoints(), std::vector<QPoint>({ { 1, 1 }, { 2, 1 }, { 3, 1 }, { 1, 2 }, { 3, 2 }, { 1, 3 }, { 2, 3 }, { 3, 3 } }));
+    ASSERT_EQ(target.hullPoints(), std::vector<QPoint>({ { 1, 1 }, { 1, 3 }, { 3, 3 }, { 3, 1 } }));
 
     // Test contains()
     ASSERT_FALSE(target.contains({ 0, 0 }));
@@ -466,10 +471,12 @@ TEST_F(RenderedTargetTest, CpuRendering)
     ASSERT_EQ(target.colorAtScratchPoint(-224.7, 161.5), 4278222912); // [3.3, 3.5]
     target.setGraphicEffect(ShaderManager::Effect::Color, 0);
 
-    // TODO: Test point transform (graphic effects that change shape)
-
-    // Cleanup
-    context.doneCurrent();
+    target.setGraphicEffect(ShaderManager::Effect::Whirl, 100);
+    ASSERT_EQ(target.colorAtScratchPoint(-227, 162), 4286578816); // [1, 3]
+    ASSERT_EQ(target.colorAtScratchPoint(-226, 162), 4286611711); // [2, 3]
+    ASSERT_EQ(target.colorAtScratchPoint(-225, 162), 0);          // [3, 3]
+    ASSERT_EQ(target.colorAtScratchPoint(-224.7, 161.5), 0);      // [3.3, 3.5]
+    target.setGraphicEffect(ShaderManager::Effect::Whirl, 100);
 }
 
 TEST_F(RenderedTargetTest, SpriteDragging)
@@ -738,9 +745,6 @@ TEST_F(RenderedTargetTest, GraphicEffects)
 
 TEST_F(RenderedTargetTest, GetBounds)
 {
-    QOpenGLContext context;
-    QOffscreenSurface surface;
-    createContextAndSurface(&context, &surface);
     RenderedTarget target;
 
     Sprite sprite;
@@ -826,16 +830,11 @@ TEST_F(RenderedTargetTest, GetBounds)
     ASSERT_EQ(std::round(bubbleBounds.top() * 100) / 100, 1294.13);
     ASSERT_EQ(std::round(bubbleBounds.right() * 100) / 100, -405.87);
     ASSERT_EQ(std::round(bubbleBounds.bottom() * 100) / 100, 1286.13);
-
-    context.doneCurrent();
 }
 
 TEST_F(RenderedTargetTest, GetFastBounds)
 {
-    QOpenGLContext context;
-    QOffscreenSurface surface;
-    createContextAndSurface(&context, &surface);
-    QOpenGLExtraFunctions glF(&context);
+    QOpenGLExtraFunctions glF(&m_context);
     glF.initializeOpenGLFunctions();
     RenderedTarget target;
 
@@ -898,8 +897,6 @@ TEST_F(RenderedTargetTest, GetFastBounds)
     ASSERT_EQ(std::round(bounds.top() * 100) / 100, 1324.22);
     ASSERT_EQ(std::round(bounds.right() * 100) / 100, -375.77);
     ASSERT_EQ(std::round(bounds.bottom() * 100) / 100, 1143.65);
-
-    context.doneCurrent();
 }
 
 TEST_F(RenderedTargetTest, TouchingClones)
@@ -923,11 +920,6 @@ TEST_F(RenderedTargetTest, TouchingClones)
     model1.setRenderedTarget(&target1);
     model2.setRenderedTarget(&target2);
 
-    // Create OpenGL context
-    QOpenGLContext context;
-    QOffscreenSurface surface;
-    createContextAndSurface(&context, &surface);
-
     // Load costume
     EXPECT_CALL(engine, stageWidth()).WillRepeatedly(Return(480));
     EXPECT_CALL(engine, stageHeight()).WillRepeatedly(Return(360));
@@ -1074,9 +1066,6 @@ TEST_F(RenderedTargetTest, TouchingClones)
     EXPECT_CALL(target1, containsScratchPoint).Times(0);
     EXPECT_CALL(target2, containsScratchPoint).Times(0);
     ASSERT_FALSE(target.touchingClones({ &clone1, &clone2 }));
-
-    // Cleanup
-    context.doneCurrent();
 }
 
 TEST_F(RenderedTargetTest, TouchingColor)
@@ -1146,11 +1135,6 @@ TEST_F(RenderedTargetTest, TouchingColor)
     model1.setRenderedTarget(&target1);
     model2.setRenderedTarget(&target2);
 
-    // Create OpenGL context
-    QOpenGLContext context;
-    QOffscreenSurface surface;
-    createContextAndSurface(&context, &surface);
-
     // Load costume
     EXPECT_CALL(engine, stageWidth()).WillRepeatedly(Return(480));
     EXPECT_CALL(engine, stageHeight()).WillRepeatedly(Return(360));
@@ -1168,12 +1152,12 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(target2, stageModel()).WillRepeatedly(Return(nullptr));
     EXPECT_CALL(penLayer, getBounds()).WillRepeatedly(ReturnRef(penBounds));
 
-    static const Value color1 = 4286611711; // "purple"
-    static const Value color2 = 596083443;  // close to color1 and transparent
-    static const Value color3 = "#808000";  // "olive" (4286611456)
+    static const QRgb color1 = 4286611711; // "purple"
+    static const QRgb color2 = 596083443;  // close to color1 and transparent
+    static const Value color3 = "#808000"; // "olive" (4286611456)
     static const QRgb color3Int = 4286611456;
-    static const Value color4 = 2505545047; // transparent "hippie green"
-    static const Value color5 = 4287417025; // color1 + color4
+    static const QRgb color4 = 2505545047; // transparent "hippie green"
+    static const QRgb color5 = 4287417025; // color1 + color4
 
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target1, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
@@ -1184,19 +1168,19 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(target2, colorAtScratchPoint(1, -2)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(3, -2)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(1, -1)).WillOnce(Return(color3Int));
-    EXPECT_CALL(target2, colorAtScratchPoint(2, -1)).WillOnce(Return(color4.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(2, -1)).WillOnce(Return(color4));
     EXPECT_CALL(target1, colorAtScratchPoint(2, -1)).WillOnce(Return(color3Int));
-    EXPECT_CALL(target2, colorAtScratchPoint(3, -1)).WillOnce(Return(color4.toInt()));
-    EXPECT_CALL(target1, colorAtScratchPoint(3, -1)).WillOnce(Return(color4.toInt()));
-    EXPECT_CALL(penLayer, colorAtScratchPoint(3, -1)).WillOnce(Return(color4.toInt()));
-    EXPECT_CALL(stageTarget, colorAtScratchPoint(3, -1)).WillOnce(Return(color4.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(3, -1)).WillOnce(Return(color4));
+    EXPECT_CALL(target1, colorAtScratchPoint(3, -1)).WillOnce(Return(color4));
+    EXPECT_CALL(penLayer, colorAtScratchPoint(3, -1)).WillOnce(Return(color4));
+    EXPECT_CALL(stageTarget, colorAtScratchPoint(3, -1)).WillOnce(Return(color4));
     ASSERT_FALSE(target.touchingColor(color1));
 
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target1, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target2, getFastBounds()).WillOnce(Return(Rect(-5, -1, 1, -8)));
     EXPECT_CALL(target2, colorAtScratchPoint(1, -3)).WillOnce(Return(color3Int));
-    EXPECT_CALL(target2, colorAtScratchPoint(2, -3)).WillOnce(Return(color1.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(2, -3)).WillOnce(Return(color1));
     ASSERT_TRUE(target.touchingColor(color1));
 
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(5, 1, 6, -5)));
@@ -1207,7 +1191,7 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(target2, colorAtScratchPoint(3, -3)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(1, -2)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(3, -2)).WillOnce(Return(color3Int));
-    EXPECT_CALL(target2, colorAtScratchPoint(1, -1)).WillOnce(Return(color4.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(1, -1)).WillOnce(Return(color4));
     EXPECT_CALL(target1, colorAtScratchPoint(1, -1)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(2, -1)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(3, -1)).WillOnce(Return(color3Int));
@@ -1223,7 +1207,7 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(target2, colorAtScratchPoint(3, -2)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(1, -1)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(2, -1)).WillOnce(Return(color3Int));
-    EXPECT_CALL(target2, colorAtScratchPoint(3, -1)).WillOnce(Return(color1.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(3, -1)).WillOnce(Return(color1));
     ASSERT_TRUE(target.touchingColor(color2));
 
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
@@ -1236,15 +1220,15 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(target2, colorAtScratchPoint(3, -2)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(1, -1)).WillOnce(Return(color3Int));
     EXPECT_CALL(target2, colorAtScratchPoint(2, -1)).WillOnce(Return(color3Int));
-    EXPECT_CALL(target2, colorAtScratchPoint(3, -1)).WillOnce(Return(color4.toInt()));
-    EXPECT_CALL(target1, colorAtScratchPoint(3, -1)).WillOnce(Return(color1.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(3, -1)).WillOnce(Return(color4));
+    EXPECT_CALL(target1, colorAtScratchPoint(3, -1)).WillOnce(Return(color1));
     ASSERT_FALSE(target.touchingColor(color1));
 
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target1, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target2, getFastBounds()).WillOnce(Return(Rect(-5, -6, 2, -8)));
-    EXPECT_CALL(target2, colorAtScratchPoint(1, -3)).WillOnce(Return(color4.toInt()));
-    EXPECT_CALL(target1, colorAtScratchPoint(1, -3)).WillOnce(Return(color1.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(1, -3)).WillOnce(Return(color4));
+    EXPECT_CALL(target1, colorAtScratchPoint(1, -3)).WillOnce(Return(color1));
     ASSERT_TRUE(target.touchingColor(color5));
 
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(5, 1, 6, -5)));
@@ -1260,8 +1244,8 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target1, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target2, getFastBounds()).WillOnce(Return(Rect(-5, -6, 2, -8)));
-    EXPECT_CALL(target2, colorAtScratchPoint(3, -3)).WillOnce(Return(color4.toInt()));
-    EXPECT_CALL(target1, colorAtScratchPoint(3, -3)).WillOnce(Return(color1.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(3, -3)).WillOnce(Return(color4));
+    EXPECT_CALL(target1, colorAtScratchPoint(3, -3)).WillOnce(Return(color1));
     ASSERT_TRUE(target.touchingColor(color5, color3));
 
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(5, 1, 6, -5)));
@@ -1278,8 +1262,8 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(stageTarget, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target1, getFastBounds()).WillOnce(Return(Rect(2, 1, 6, -5)));
     EXPECT_CALL(target2, getFastBounds()).WillOnce(Return(Rect(-5, -6, 2, -8)));
-    EXPECT_CALL(target2, colorAtScratchPoint(3, -3)).WillOnce(Return(color4.toInt()));
-    EXPECT_CALL(target1, colorAtScratchPoint(3, -3)).WillOnce(Return(color1.toInt()));
+    EXPECT_CALL(target2, colorAtScratchPoint(3, -3)).WillOnce(Return(color4));
+    EXPECT_CALL(target1, colorAtScratchPoint(3, -3)).WillOnce(Return(color1));
     ASSERT_TRUE(target.touchingColor(color5, color3));
     ASSERT_EQ(target.graphicEffects().at(ShaderManager::Effect::Ghost), 100);
 
@@ -1330,7 +1314,4 @@ TEST_F(RenderedTargetTest, TouchingColor)
     EXPECT_CALL(penLayer, colorAtScratchPoint).Times(0);
     EXPECT_CALL(stageTarget, colorAtScratchPoint).Times(0);
     ASSERT_FALSE(target.touchingColor(color1));
-
-    // Cleanup
-    context.doneCurrent();
 }
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index 6bbe0a2..1029323 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -12,9 +12,6 @@
 using namespace scratchcpprender;
 using namespace libscratchcpp;
 
-static const QString VERTEX_SHADER_SRC = ":/qt/qml/ScratchCPP/Render/shaders/sprite.vert";
-static const QString FRAGMENT_SHADER_SRC = ":/qt/qml/ScratchCPP/Render/shaders/sprite.frag";
-
 class ShaderManagerTest : public testing::Test
 {
     public:
@@ -27,16 +24,6 @@ class ShaderManagerTest : public testing::Test
             m_surface.create();
             Q_ASSERT(m_surface.isValid());
             m_context.makeCurrent(&m_surface);
-
-            QFile vertSource(VERTEX_SHADER_SRC);
-            vertSource.open(QFile::ReadOnly);
-            m_vertexShader = vertSource.readAll();
-            ASSERT_FALSE(m_vertexShader.isEmpty());
-
-            QFile fragSource(FRAGMENT_SHADER_SRC);
-            fragSource.open(QFile::ReadOnly);
-            m_fragmentShader = fragSource.readAll();
-            ASSERT_FALSE(m_fragmentShader.isEmpty());
         }
 
         void TearDown() override
@@ -47,8 +34,6 @@ class ShaderManagerTest : public testing::Test
 
         QOpenGLContext m_context;
         QOffscreenSurface m_surface;
-        QByteArray m_vertexShader;
-        QByteArray m_fragmentShader;
 };
 
 TEST_F(ShaderManagerTest, RegisteredEffects)
@@ -83,66 +68,26 @@ TEST_F(ShaderManagerTest, Instance)
 
 TEST_F(ShaderManagerTest, GetShaderProgram)
 {
-    static const QByteArray vertHeader = "#version 330 core\n#define lowp\n#define mediump\n#define highp\n#line 1\n";
-    static const QByteArray fragHeader =
-        "#version 330\n#ifdef GL_KHR_blend_equation_advanced\n#extension GL_ARB_fragment_coord_conventions : enable\n#extension GL_KHR_blend_equation_advanced : enable\n#endif\n#define lowp\n#define "
-        "mediump\n#define highp\n#line 1\n";
-
-    // Color and ghost
     ShaderManager manager;
-    const std::unordered_map<ShaderManager::Effect, double> effects1 = { { ShaderManager::Effect::Color, 64.9 }, { ShaderManager::Effect::Ghost, 12.5 } };
+    const std::unordered_map<ShaderManager::Effect, double> effects = { { ShaderManager::Effect::Color, 64.9 }, { ShaderManager::Effect::Ghost, 12.5 } };
 
-    QOpenGLShaderProgram *program1 = manager.getShaderProgram(effects1);
-    ASSERT_EQ(program1->parent(), &manager);
-    ASSERT_TRUE(program1->isLinked());
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    ASSERT_EQ(program->parent(), &manager);
+    ASSERT_TRUE(program->isLinked());
 
-    auto shaders = program1->shaders();
+    auto shaders = program->shaders();
     ASSERT_EQ(shaders.size(), 2);
     QOpenGLShader *vert = shaders[0];
     QOpenGLShader *frag = shaders[1];
     ASSERT_EQ(vert->shaderType(), QOpenGLShader::Vertex);
-    ASSERT_EQ(vert->sourceCode(), vertHeader + m_vertexShader);
-    ASSERT_EQ(frag->shaderType(), QOpenGLShader::Fragment);
-    ASSERT_EQ(frag->sourceCode(), fragHeader + "#define ENABLE_ghost\n#define ENABLE_color\n" + m_fragmentShader);
-
-    // Brightness and ghost
-    const std::unordered_map<ShaderManager::Effect, double> effects2 = { { ShaderManager::Effect::Brightness, 64.9 }, { ShaderManager::Effect::Ghost, 12.5 } };
-
-    QOpenGLShaderProgram *program2 = manager.getShaderProgram(effects2);
-    ASSERT_EQ(program2->parent(), &manager);
-    ASSERT_TRUE(program2->isLinked());
-
-    shaders = program2->shaders();
-    ASSERT_EQ(shaders.size(), 2);
-    vert = shaders[0];
-    frag = shaders[1];
-    ASSERT_EQ(vert->shaderType(), QOpenGLShader::Vertex);
-    ASSERT_EQ(vert->sourceCode(), vertHeader + m_vertexShader);
     ASSERT_EQ(frag->shaderType(), QOpenGLShader::Fragment);
-    ASSERT_EQ(frag->sourceCode(), fragHeader + "#define ENABLE_ghost\n#define ENABLE_brightness\n" + m_fragmentShader);
 
     // Test shader program cache
-    QOpenGLShaderProgram *program = manager.getShaderProgram(effects1);
-    ASSERT_EQ(program, program1);
-
-    program = manager.getShaderProgram(effects2);
-    ASSERT_EQ(program, program2);
-
-    // Color and brightness where color effect value is zero
-    const std::unordered_map<ShaderManager::Effect, double> effects3 = { { ShaderManager::Effect::Color, 0.0 }, { ShaderManager::Effect::Brightness, 22.3 } };
+    program = manager.getShaderProgram(effects);
+    ASSERT_EQ(program, program);
 
-    program = manager.getShaderProgram(effects3);
-    ASSERT_EQ(program->parent(), &manager);
-    ASSERT_TRUE(program->isLinked());
-
-    shaders = program->shaders();
-    ASSERT_EQ(shaders.size(), 2);
-    vert = shaders[0];
-    frag = shaders[1];
-    ASSERT_EQ(vert->shaderType(), QOpenGLShader::Vertex);
-    ASSERT_EQ(vert->sourceCode(), vertHeader + m_vertexShader);
-    ASSERT_EQ(frag->shaderType(), QOpenGLShader::Fragment);
-    ASSERT_EQ(frag->sourceCode(), fragHeader + "#define ENABLE_brightness\n" + m_fragmentShader);
+    program = manager.getShaderProgram(effects);
+    ASSERT_EQ(program, program);
 }
 
 TEST_F(ShaderManagerTest, SetUniforms)
@@ -150,27 +95,25 @@ TEST_F(ShaderManagerTest, SetUniforms)
     QOpenGLFunctions glF(&m_context);
     glF.initializeOpenGLFunctions();
     ShaderManager manager;
-    QOpenGLShaderProgram program;
-    program.addShaderFromSourceCode(QOpenGLShader::Vertex, "#version 330 core\n" + m_vertexShader);
-    program.addShaderFromSourceCode(QOpenGLShader::Fragment, "#version 330\n#define ENABLE_color\n#define ENABLE_ghost\n" + m_fragmentShader);
 
     std::unordered_map<ShaderManager::Effect, double> effects = { { ShaderManager::Effect::Color, 64.9 }, { ShaderManager::Effect::Ghost, 12.5 } };
-    program.bind();
-    manager.setUniforms(&program, 4, effects);
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 4, QSize(), effects);
 
     GLint texUnit = -1;
-    glF.glGetUniformiv(program.programId(), program.uniformLocation("u_skin"), &texUnit);
+    glF.glGetUniformiv(program->programId(), program->uniformLocation("u_skin"), &texUnit);
     ASSERT_EQ(texUnit, 4);
 
     GLfloat value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation("u_color"), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation("u_color"), &value);
     ASSERT_NE(value, 0.0f);
 
     value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation("u_ghost"), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation("u_ghost"), &value);
     ASSERT_NE(value, 0.0f);
 
-    program.release();
+    program->release();
 }
 
 TEST_F(ShaderManagerTest, ColorEffectValue)
@@ -184,44 +127,44 @@ TEST_F(ShaderManagerTest, ColorEffectValue)
     QOpenGLFunctions glF(&m_context);
     glF.initializeOpenGLFunctions();
     ShaderManager manager;
-    QOpenGLShaderProgram program;
-    program.addShaderFromSourceCode(QOpenGLShader::Vertex, "#version 330 core\n" + m_vertexShader);
-    program.addShaderFromSourceCode(QOpenGLShader::Fragment, "#version 330\n#define ENABLE_" + effectName + "\n" + m_fragmentShader);
 
     // In range
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 64.9 } };
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(value, 0.3245f);
     ASSERT_EQ(values.at(effect), value);
 
     // Below the minimum
     effects[effect] = -395.7;
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(std::round(value * 100.0f) / 100.0f, 0.02f);
     ASSERT_EQ(values.at(effect), value);
 
     // Above the maximum
     effects[effect] = 579.05;
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(std::round(value * 100.0f) / 100.0f, 0.9f);
     ASSERT_EQ(values.at(effect), value);
 
-    program.release();
+    program->release();
 }
 
 TEST_F(ShaderManagerTest, BrightnessEffectValue)
@@ -235,44 +178,44 @@ TEST_F(ShaderManagerTest, BrightnessEffectValue)
     QOpenGLFunctions glF(&m_context);
     glF.initializeOpenGLFunctions();
     ShaderManager manager;
-    QOpenGLShaderProgram program;
-    program.addShaderFromSourceCode(QOpenGLShader::Vertex, "#version 330 core\n" + m_vertexShader);
-    program.addShaderFromSourceCode(QOpenGLShader::Fragment, "#version 330\n#define ENABLE_" + effectName + "\n" + m_fragmentShader);
 
     // In range
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 4.6 } };
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(value, 0.046f);
     ASSERT_EQ(values.at(effect), value);
 
     // Below the minimum
     effects[effect] = -102.9;
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(value, -1.0f);
     ASSERT_EQ(values.at(effect), value);
 
     // Above the maximum
     effects[effect] = 353.2;
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(value, 1.0f);
     ASSERT_EQ(values.at(effect), value);
 
-    program.release();
+    program->release();
 }
 
 TEST_F(ShaderManagerTest, GhostEffectValue)
@@ -286,42 +229,246 @@ TEST_F(ShaderManagerTest, GhostEffectValue)
     QOpenGLFunctions glF(&m_context);
     glF.initializeOpenGLFunctions();
     ShaderManager manager;
-    QOpenGLShaderProgram program;
-    program.addShaderFromSourceCode(QOpenGLShader::Vertex, "#version 330 core\n" + m_vertexShader);
-    program.addShaderFromSourceCode(QOpenGLShader::Fragment, "#version 330\n#define ENABLE_" + effectName + "\n" + m_fragmentShader);
 
     // In range
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(std::round(value * 1000.0f) / 1000.0f, 0.415f);
     ASSERT_EQ(values.at(effect), value);
 
     // Below the minimum
     effects[effect] = -20.8;
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(value, 1.0f);
     ASSERT_EQ(values.at(effect), value);
 
     // Above the maximum
     effects[effect] = 248.2;
-    program.bind();
-    manager.setUniforms(&program, 0, effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
-    glF.glGetUniformfv(program.programId(), program.uniformLocation(uniformName), &value);
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
     ASSERT_EQ(value, 0.0f);
     ASSERT_EQ(values.at(effect), value);
 
-    program.release();
+    program->release();
+}
+
+TEST_F(ShaderManagerTest, FisheyeEffectValue)
+{
+    static const QString effectName = "fisheye";
+    static const QString uniformName = "u_" + effectName;
+    static const ShaderManager::Effect effect = ShaderManager::Effect::Fisheye;
+
+    std::unordered_map<ShaderManager::Effect, float> values;
+
+    QOpenGLFunctions glF(&m_context);
+    glF.initializeOpenGLFunctions();
+    ShaderManager manager;
+
+    // In range
+    std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    GLfloat value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 1.585f);
+    ASSERT_EQ(values.at(effect), value);
+
+    effects[effect] = -20.8;
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 0.792f);
+    ASSERT_EQ(values.at(effect), value);
+
+    // Below the minimum
+    effects[effect] = -101;
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 0.0f);
+    ASSERT_EQ(values.at(effect), value);
+
+    program->release();
+}
+
+TEST_F(ShaderManagerTest, WhirlEffectValue)
+{
+    static const QString effectName = "whirl";
+    static const QString uniformName = "u_" + effectName;
+    static const ShaderManager::Effect effect = ShaderManager::Effect::Whirl;
+
+    std::unordered_map<ShaderManager::Effect, float> values;
+
+    QOpenGLFunctions glF(&m_context);
+    glF.initializeOpenGLFunctions();
+    ShaderManager manager;
+
+    // In range
+    std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    GLfloat value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(std::round(value * 1000) / 1000, 1.021f);
+    ASSERT_EQ(values.at(effect), value);
+
+    effects[effect] = -20.8;
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(std::round(value * 1000) / 1000, -0.363f);
+    ASSERT_EQ(values.at(effect), value);
+
+    program->release();
+}
+
+TEST_F(ShaderManagerTest, PixelateEffectValue)
+{
+    static const QString effectName = "pixelate";
+    static const QString uniformName = "u_" + effectName;
+    static const ShaderManager::Effect effect = ShaderManager::Effect::Pixelate;
+
+    std::unordered_map<ShaderManager::Effect, float> values;
+
+    QOpenGLFunctions glF(&m_context);
+    glF.initializeOpenGLFunctions();
+    ShaderManager manager;
+
+    // In range
+    std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    GLfloat value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 5.85f);
+    ASSERT_EQ(values.at(effect), value);
+
+    effects[effect] = -20.8;
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 2.08f);
+    ASSERT_EQ(values.at(effect), value);
+
+    program->release();
+}
+
+TEST_F(ShaderManagerTest, MosaicEffectValue)
+{
+    static const QString effectName = "mosaic";
+    static const QString uniformName = "u_" + effectName;
+    static const ShaderManager::Effect effect = ShaderManager::Effect::Mosaic;
+
+    std::unordered_map<ShaderManager::Effect, float> values;
+
+    QOpenGLFunctions glF(&m_context);
+    glF.initializeOpenGLFunctions();
+    ShaderManager manager;
+
+    // In range
+    std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
+    QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    GLfloat value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 7.0f);
+    ASSERT_EQ(values.at(effect), value);
+
+    effects[effect] = -21.8;
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 3.0f);
+    ASSERT_EQ(values.at(effect), value);
+
+    // Below the minimum
+    effects[effect] = 4;
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 1.0f);
+    ASSERT_EQ(values.at(effect), value);
+
+    // Above the maximum
+    effects[effect] = 5120;
+    program->bind();
+    manager.setUniforms(program, 0, QSize(), effects);
+    manager.getUniformValuesForEffects(effects, values);
+
+    value = 0.0f;
+    glF.glGetUniformfv(program->programId(), program->uniformLocation(uniformName), &value);
+    ASSERT_EQ(value, 512.0f);
+    ASSERT_EQ(values.at(effect), value);
+
+    program->release();
+}
+
+TEST_F(ShaderManagerTest, Effects)
+{
+    ASSERT_EQ(
+        ShaderManager::effects(),
+        std::unordered_set<ShaderManager::Effect>(
+            { ShaderManager::Effect::Color,
+              ShaderManager::Effect::Brightness,
+              ShaderManager::Effect::Ghost,
+              ShaderManager::Effect::Fisheye,
+              ShaderManager::Effect::Whirl,
+              ShaderManager::Effect::Pixelate,
+              ShaderManager::Effect::Mosaic }));
+}
+
+TEST_F(ShaderManagerTest, EffectShapeChanges)
+{
+    ASSERT_FALSE(ShaderManager::effectShapeChanges(ShaderManager::Effect::Color));
+    ASSERT_FALSE(ShaderManager::effectShapeChanges(ShaderManager::Effect::Brightness));
+    ASSERT_FALSE(ShaderManager::effectShapeChanges(ShaderManager::Effect::Ghost));
+    ASSERT_TRUE(ShaderManager::effectShapeChanges(ShaderManager::Effect::Fisheye));
+    ASSERT_TRUE(ShaderManager::effectShapeChanges(ShaderManager::Effect::Whirl));
+    ASSERT_TRUE(ShaderManager::effectShapeChanges(ShaderManager::Effect::Pixelate));
+    ASSERT_TRUE(ShaderManager::effectShapeChanges(ShaderManager::Effect::Mosaic));
 }
diff --git a/test/shape_changing_effects.png b/test/shape_changing_effects.png
new file mode 100644
index 0000000..a7f4d96
Binary files /dev/null and b/test/shape_changing_effects.png differ
diff --git a/test/skins/bitmapskin_test.cpp b/test/skins/bitmapskin_test.cpp
index 48c28d1..6e5c936 100644
--- a/test/skins/bitmapskin_test.cpp
+++ b/test/skins/bitmapskin_test.cpp
@@ -33,6 +33,7 @@ class BitmapSkinTest : public testing::Test
         void TearDown() override
         {
             ASSERT_EQ(m_context.surface(), &m_surface);
+            emit m_context.aboutToBeDestroyed();
             m_jpegSkin.reset();
             m_pngSkin.reset();
             m_context.doneCurrent();
diff --git a/test/skins/svgskin_test.cpp b/test/skins/svgskin_test.cpp
index 7d68832..7f59952 100644
--- a/test/skins/svgskin_test.cpp
+++ b/test/skins/svgskin_test.cpp
@@ -28,6 +28,7 @@ class SVGSkinTest : public testing::Test
         void TearDown() override
         {
             ASSERT_EQ(m_context.surface(), &m_surface);
+            emit m_context.aboutToBeDestroyed();
             m_skin.reset();
             m_context.doneCurrent();
         }
diff --git a/test/stamp.png b/test/stamp.png
index 7206580..1cc2994 100644
Binary files a/test/stamp.png and b/test/stamp.png differ
diff --git a/test/stamp_hq.png b/test/stamp_hq.png
index b259b79..d5b1d44 100644
Binary files a/test/stamp_hq.png and b/test/stamp_hq.png differ
diff --git a/test/target_models/spritemodel_test.cpp b/test/target_models/spritemodel_test.cpp
index 3b1a776..55a7d5f 100644
--- a/test/target_models/spritemodel_test.cpp
+++ b/test/target_models/spritemodel_test.cpp
@@ -476,5 +476,5 @@ TEST(SpriteModelTest, BubbleLayer)
 
     sprite.bubble()->setLayerOrder(5);
     ASSERT_EQ(model.bubbleLayer(), 5);
-    // TODO: Use spy here
+    ASSERT_EQ(spy.count(), 1);
 }
diff --git a/test/target_models/stagemodel_test.cpp b/test/target_models/stagemodel_test.cpp
index 629f758..dc1214f 100644
--- a/test/target_models/stagemodel_test.cpp
+++ b/test/target_models/stagemodel_test.cpp
@@ -235,5 +235,5 @@ TEST(StageModelTest, BubbleLayer)
 
     stage.bubble()->setLayerOrder(5);
     ASSERT_EQ(model.bubbleLayer(), 5);
-    // TODO: Use spy here
+    ASSERT_EQ(spy.count(), 1);
 }
diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 57175be..2e02853 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -81,6 +81,8 @@ TEST_F(TargetPainterTest, Paint)
     Texture texture(refFbo.texture(), refFbo.size());
     std::unordered_map<ShaderManager::Effect, double> effects;
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
+    EXPECT_CALL(target, costumeWidth()).WillOnce(Return(texture.width()));
+    EXPECT_CALL(target, costumeHeight()).WillOnce(Return(texture.height()));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));
     targetPainter.paint(&painter);
     painter.endFrame();
@@ -95,18 +97,34 @@ TEST_F(TargetPainterTest, Paint)
     effects[ShaderManager::Effect::Brightness] = 20;
     effects[ShaderManager::Effect::Ghost] = 84;
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
+    EXPECT_CALL(target, costumeWidth()).WillOnce(Return(texture.width()));
+    EXPECT_CALL(target, costumeHeight()).WillOnce(Return(texture.height()));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));
     targetPainter.paint(&painter);
     painter.endFrame();
     effects.clear();
 
     // Compare with reference image
-    QBuffer refBuffer1;
-    fbo.toImage().save(&refBuffer1, "png");
-    QFile ref1("color_effects.png");
-    ref1.open(QFile::ReadOnly);
-    refBuffer1.open(QBuffer::ReadOnly);
-    ASSERT_EQ(ref1.readAll(), refBuffer1.readAll());
+    ASSERT_LE(fuzzyCompareImages(fbo.toImage(), QImage("color_effects.png")), 0.04);
+
+    // Paint with shape changing effects
+    glF.glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
+    glF.glClear(GL_COLOR_BUFFER_BIT);
+    effects.clear();
+    effects[ShaderManager::Effect::Fisheye] = 46;
+    effects[ShaderManager::Effect::Whirl] = 50;
+    effects[ShaderManager::Effect::Pixelate] = 25;
+    effects[ShaderManager::Effect::Mosaic] = 30;
+    EXPECT_CALL(target, texture()).WillOnce(Return(texture));
+    EXPECT_CALL(target, costumeWidth()).WillOnce(Return(texture.width()));
+    EXPECT_CALL(target, costumeHeight()).WillOnce(Return(texture.height()));
+    EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));
+    targetPainter.paint(&painter);
+    painter.endFrame();
+    effects.clear();
+
+    // Compare with reference image
+    ASSERT_LE(fuzzyCompareImages(fbo.toImage(), QImage("shape_changing_effects.png")), 0.04);
 
     // Release
     fbo.release();
diff --git a/test/texture/cputexturemanager_test.cpp b/test/texture/cputexturemanager_test.cpp
index e2634fa..af09715 100644
--- a/test/texture/cputexturemanager_test.cpp
+++ b/test/texture/cputexturemanager_test.cpp
@@ -77,12 +77,9 @@ TEST_F(CpuTextureManagerTest, TextureDataAndHullPoints)
         4,  0, 12, 255, 1,  0, 7,  255, 0,  1, 0,  255, 0,   3,   0,   255, 0,  0, 0,  255, 0,  0,  0,  255, 0,   0,   0,   255, 0,   0,   0,   255
     };
 
-    static const std::vector<QPoint> refHullPoints1 = { { 1, 1 }, { 2, 1 }, { 3, 1 }, { 1, 2 }, { 3, 2 }, { 1, 3 }, { 2, 3 }, { 3, 3 } };
-
-    static const std::vector<QPoint> refHullPoints2 = {
-        { 0, 0 }, { 1, 0 }, { 2, 0 }, { 3, 0 }, { 0, 1 }, { 1, 1 }, { 2, 1 }, { 3, 1 }, { 0, 2 }, { 1, 2 }, { 2, 2 }, { 3, 2 },
-        { 0, 3 }, { 1, 3 }, { 2, 3 }, { 3, 3 }, { 0, 4 }, { 1, 4 }, { 2, 4 }, { 3, 4 }, { 0, 5 }, { 1, 5 }, { 2, 5 }, { 3, 5 }
-    };
+    static const std::vector<QPoint> refHullPoints1 = { { 1, 1 }, { 1, 3 }, { 3, 3 }, { 3, 1 } };
+    static const std::vector<QPoint> refHullPoints2 = { { 0, 0 }, { 0, 5 }, { 3, 5 }, { 3, 0 } };
+    static const std::vector<QPoint> refHullPoints3 = { { 1, 0 }, { 1, 3 }, { 2, 3 }, { 3, 2 }, { 3, 0 } };
 
     // Create OpenGL context
     QOpenGLContext context;
@@ -96,19 +93,26 @@ TEST_F(CpuTextureManagerTest, TextureDataAndHullPoints)
 
     // Read texture data
     CpuTextureManager manager;
+    std::vector<QPoint> hullPoints;
 
     for (int i = 0; i < 2; i++) {
         Texture texture1(imgPainter1.fbo()->texture(), imgPainter1.fbo()->size());
         GLubyte *data = manager.getTextureData(texture1);
         ASSERT_EQ(memcmp(data, refData1, 96), 0);
-        const auto &hullPoints1 = manager.getTextureConvexHullPoints(texture1);
-        ASSERT_EQ(hullPoints1, refHullPoints1);
+        manager.getTextureConvexHullPoints(texture1, QSize(), ShaderManager::Effect::NoEffect, {}, hullPoints);
+        ASSERT_EQ(hullPoints, refHullPoints1);
 
         Texture texture2(imgPainter2.fbo()->texture(), imgPainter2.fbo()->size());
         data = manager.getTextureData(texture2);
         ASSERT_EQ(memcmp(data, refData2, 96), 0);
-        const auto &hullPoints2 = manager.getTextureConvexHullPoints(texture2);
-        ASSERT_EQ(hullPoints2, refHullPoints2);
+        manager.getTextureConvexHullPoints(texture2, QSize(), ShaderManager::Effect::NoEffect, {}, hullPoints);
+        ASSERT_EQ(hullPoints, refHullPoints2);
+
+        // Shape-changing effects
+        auto mask = ShaderManager::Effect::Fisheye | ShaderManager::Effect::Whirl;
+        const std::unordered_map<ShaderManager::Effect, double> effects = { { ShaderManager::Effect::Fisheye, 20 }, { ShaderManager::Effect::Whirl, 50 } };
+        manager.getTextureConvexHullPoints(texture1, texture1.size(), mask, effects, hullPoints);
+        ASSERT_EQ(hullPoints, refHullPoints3);
     }
 
     // Test removeTexture()
@@ -116,16 +120,17 @@ TEST_F(CpuTextureManagerTest, TextureDataAndHullPoints)
     Texture texture(imgPainter1.fbo()->texture(), imgPainter1.fbo()->size());
     GLubyte *data = manager.getTextureData(texture);
     ASSERT_EQ(memcmp(data, refData1, 96), 0);
-    const auto &hullPoints1 = manager.getTextureConvexHullPoints(texture);
-    ASSERT_EQ(hullPoints1, refHullPoints1);
+    manager.getTextureConvexHullPoints(texture, QSize(), ShaderManager::Effect::NoEffect, {}, hullPoints);
+    ASSERT_EQ(hullPoints, refHullPoints1);
 
     manager.removeTexture(texture);
     data = manager.getTextureData(texture);
     ASSERT_EQ(memcmp(data, refData2, 96), 0);
-    const auto &hullPoints2 = manager.getTextureConvexHullPoints(texture);
-    ASSERT_EQ(hullPoints2, refHullPoints2);
+    manager.getTextureConvexHullPoints(texture, QSize(), ShaderManager::Effect::NoEffect, {}, hullPoints);
+    ASSERT_EQ(hullPoints, refHullPoints2);
 
     // Cleanup
+    emit context.aboutToBeDestroyed();
     context.doneCurrent();
 }
 
@@ -145,30 +150,33 @@ TEST_F(CpuTextureManagerTest, GetPointColor)
 
     // Test
     CpuTextureManager manager;
-    ASSERT_EQ(manager.getPointColor(texture, 0, 0, {}), qRgba(0, 0, 0, 0));
-    ASSERT_EQ(manager.getPointColor(texture, 1, 0, {}), qRgba(0, 0, 0, 0));
-    ASSERT_EQ(manager.getPointColor(texture, 2, 0, {}), qRgba(0, 0, 0, 0));
-    ASSERT_EQ(manager.getPointColor(texture, 3, 0, {}), qRgba(0, 0, 0, 0));
-
-    ASSERT_FALSE(manager.textureContainsPoint(texture, { 0, 1 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 1 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1.4, 1.25 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 2, 1 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 1 }, {}));
-
-    ASSERT_EQ(manager.getPointColor(texture, 0, 1, {}), qRgba(0, 0, 0, 0));
-    ASSERT_EQ(manager.getPointColor(texture, 1, 1, {}), qRgb(0, 0, 255));
-    ASSERT_EQ(manager.getPointColor(texture, 2, 1, {}), qRgb(255, 0, 255));
-    ASSERT_EQ(manager.getPointColor(texture, 3, 1, {}), qRgb(255, 128, 128));
+    auto mask = ShaderManager::Effect::NoEffect;
+    ASSERT_EQ(manager.getPointColor(texture, 0, 0, mask, {}), qRgba(0, 0, 0, 0));
+    ASSERT_EQ(manager.getPointColor(texture, 1, 0, mask, {}), qRgba(0, 0, 0, 0));
+    ASSERT_EQ(manager.getPointColor(texture, 2, 0, mask, {}), qRgba(0, 0, 0, 0));
+    ASSERT_EQ(manager.getPointColor(texture, 3, 0, mask, {}), qRgba(0, 0, 0, 0));
+
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 0, 1 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 1 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1.4, 1.25 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 2, 1 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 1 }, mask, {}));
+
+    ASSERT_EQ(manager.getPointColor(texture, 0, 1, mask, {}), qRgba(0, 0, 0, 0));
+    ASSERT_EQ(manager.getPointColor(texture, 1, 1, mask, {}), qRgb(0, 0, 255));
+    ASSERT_EQ(manager.getPointColor(texture, 2, 1, mask, {}), qRgb(255, 0, 255));
+    ASSERT_EQ(manager.getPointColor(texture, 3, 1, mask, {}), qRgb(255, 128, 128));
 
     std::unordered_map<ShaderManager::Effect, double> effects = { { ShaderManager::Effect::Color, 50 } };
-    ASSERT_EQ(manager.getPointColor(texture, 1, 1, effects), qRgb(255, 0, 128));
-    ASSERT_EQ(manager.getPointColor(texture, 2, 1, effects), qRgb(255, 128, 0));
-    ASSERT_EQ(manager.getPointColor(texture, 3, 1, effects), qRgb(192, 255, 128));
+    mask = ShaderManager::Effect::Color;
+    ASSERT_EQ(manager.getPointColor(texture, 1, 1, mask, effects), qRgb(255, 0, 128));
+    ASSERT_EQ(manager.getPointColor(texture, 2, 1, mask, effects), qRgb(255, 128, 0));
+    ASSERT_EQ(manager.getPointColor(texture, 3, 1, mask, effects), qRgb(192, 255, 128));
 
     // TODO: Test point transform (graphic effects that change shape)
 
     // Cleanup
+    emit context.aboutToBeDestroyed();
     context.doneCurrent();
 }
 
@@ -188,29 +196,36 @@ TEST_F(CpuTextureManagerTest, TextureContainsPoint)
 
     // Test
     CpuTextureManager manager;
-    ASSERT_FALSE(manager.textureContainsPoint(texture, { 0, 0 }, {}));
-    ASSERT_FALSE(manager.textureContainsPoint(texture, { 1, 0 }, {}));
-    ASSERT_FALSE(manager.textureContainsPoint(texture, { 2, 0 }, {}));
-    ASSERT_FALSE(manager.textureContainsPoint(texture, { 3, 0 }, {}));
-
-    ASSERT_FALSE(manager.textureContainsPoint(texture, { 0, 1 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 1 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1.4, 1.25 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 2, 1 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 1 }, {}));
-
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 2 }, {}));
-    ASSERT_FALSE(manager.textureContainsPoint(texture, { 2, 2 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 2 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3.5, 2.1 }, {}));
-
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 3 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 2, 3 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 3 }, {}));
-    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3.3, 3.5 }, {}));
-
-    // TODO: Test point transform (graphic effects that change shape)
+    auto mask = ShaderManager::Effect::NoEffect;
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 0, 0 }, mask, {}));
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 1, 0 }, mask, {}));
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 2, 0 }, mask, {}));
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 3, 0 }, mask, {}));
+
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 0, 1 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 1 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1.4, 1.25 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 2, 1 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 1 }, mask, {}));
+
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 2 }, mask, {}));
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 2, 2 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 2 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3.5, 2.1 }, mask, {}));
+
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 3 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 2, 3 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 3 }, mask, {}));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 3.3, 3.5 }, mask, {}));
+
+    mask = ShaderManager::Effect::Whirl;
+    const std::unordered_map<ShaderManager::Effect, double> effects = { { ShaderManager::Effect::Whirl, 100 } };
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 1, 3 }, mask, effects));
+    ASSERT_TRUE(manager.textureContainsPoint(texture, { 2, 3 }, mask, effects));
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 3, 3 }, mask, effects));
+    ASSERT_FALSE(manager.textureContainsPoint(texture, { 3.3, 3.5 }, mask, effects));
 
     // Cleanup
+    emit context.aboutToBeDestroyed();
     context.doneCurrent();
 }
