From 9a9b0a40c06ae042af8c4d945f4757370afe86ed Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 25 Sep 2024 08:24:55 +0200
Subject: [PATCH 01/56] Set version to 0.8.0

---
 CMakeLists.txt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 892ae06..678842f 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)

From 309561bdad995589619b2b7004b160a990389829 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 28 Sep 2024 23:28:50 +0200
Subject: [PATCH 02/56] Update libscratchcpp to latest master

---
 libscratchcpp | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/libscratchcpp b/libscratchcpp
index bc5e5a8..47beef7 160000
--- a/libscratchcpp
+++ b/libscratchcpp
@@ -1 +1 @@
-Subproject commit bc5e5a8d7cb25148affaf008096dc71167e6328b
+Subproject commit 47beef7c9116ea2a90e037e113d975c1a0644524

From 93ad76052bad68adc7f4f1c88a142aa1543e6f44 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 5 Oct 2024 10:21:20 +0200
Subject: [PATCH 03/56] fix #150: Revert "ProjectLoader: Clear sprites before
 loading project"

This reverts commit 594b90d3b92f4e8f9bad44027bbf28f5fda2bb4a.

Resolves #150
---
 src/projectloader.cpp                     |  8 --------
 test/projectloader/projectloader_test.cpp | 10 +++++-----
 2 files changed, 5 insertions(+), 13 deletions(-)

diff --git a/src/projectloader.cpp b/src/projectloader.cpp
index d8c6fea..85a53ee 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -251,11 +251,6 @@ void ProjectLoader::callLoad(ProjectLoader *loader)
 void ProjectLoader::load()
 {
     m_unpositionedMonitors.clear();
-    m_sprites.clear();
-    m_clones.clear();
-    emit spritesChanged();
-    emit clonesChanged();
-
     m_loadStatus = m_project.load();
     m_engineMutex.lock();
     m_engine = m_project.engine().get();
@@ -267,7 +262,6 @@ void ProjectLoader::load()
         emit loadingFinished();
         emit engineChanged();
         emit spritesChanged();
-        emit clonesChanged();
         return;
     }
 
@@ -314,7 +308,6 @@ void ProjectLoader::load()
         emit loadingFinished();
         emit engineChanged();
         emit spritesChanged();
-        emit clonesChanged();
         return;
     }
 
@@ -334,7 +327,6 @@ void ProjectLoader::load()
     emit engineChanged();
     emit stageChanged();
     emit spritesChanged();
-    emit clonesChanged();
 }
 
 void ProjectLoader::initTimer()
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);
 }
 

From 4fc8d4fdc7ac08eb9dd478843ad2585353acfd81 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 5 Oct 2024 10:47:40 +0200
Subject: [PATCH 04/56] Accept null sprite and stage in models

---
 src/spritemodel.cpp | 43 +++++++++++++++++++++----------------------
 src/stagemodel.cpp  | 43 +++++++++++++++++++++----------------------
 2 files changed, 42 insertions(+), 44 deletions(-)

diff --git a/src/spritemodel.cpp b/src/spritemodel.cpp
index 9aca0db..ecee36a 100644
--- a/src/spritemodel.cpp
+++ b/src/spritemodel.cpp
@@ -20,35 +20,34 @@ 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) {
+        m_sprite->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);
+        m_sprite->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();
+            }
+        });
+    }
 }
 
 void SpriteModel::deinitClone()
diff --git a/src/stagemodel.cpp b/src/stagemodel.cpp
index 31f8f58..987c807 100644
--- a/src/stagemodel.cpp
+++ b/src/stagemodel.cpp
@@ -16,35 +16,34 @@ 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) {
+        m_stage->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_stage->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();
+            }
+        });
+    }
 }
 
 void StageModel::onCostumeChanged(libscratchcpp::Costume *costume)

From 958346ced63aedf024e8c7eb4aaf8e1d8f0405c7 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 5 Oct 2024 10:48:06 +0200
Subject: [PATCH 05/56] ProjectLoader: Clear loaded project before closing app

---
 src/projectloader.cpp | 85 +++++++++++++++++++++++--------------------
 src/projectloader.h   |  1 +
 2 files changed, 47 insertions(+), 39 deletions(-)

diff --git a/src/projectloader.cpp b/src/projectloader.cpp
index 85a53ee..b6038df 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -32,6 +32,8 @@ ProjectLoader::ProjectLoader(QObject *parent) :
         }
     });
 
+    connect(qApp, &QCoreApplication::aboutToQuit, this, &ProjectLoader::clear);
+
     initTimer();
 
     // Register pen blocks
@@ -64,45 +66,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;
@@ -248,6 +212,49 @@ void ProjectLoader::callLoad(ProjectLoader *loader)
     loader->load();
 }
 
+void ProjectLoader::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();
+    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();
+}
+
 void ProjectLoader::load()
 {
     m_unpositionedMonitors.clear();
diff --git a/src/projectloader.h b/src/projectloader.h
index 21f95e6..a86c407 100644
--- a/src/projectloader.h
+++ b/src/projectloader.h
@@ -130,6 +130,7 @@ class ProjectLoader : public QObject
 
     private:
         static void callLoad(ProjectLoader *loader);
+        void clear();
         void load();
         void initTimer();
         void redraw();

From 34be71e4d24e3d342903402a293395041626e673 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 13 Oct 2024 11:31:54 +0200
Subject: [PATCH 06/56] fix #140: Optimize list monitors

---
 src/internal/ListMonitor.qml                  | 20 +++++++++
 src/listmonitorlistmodel.cpp                  | 15 +++----
 src/listmonitorlistmodel.h                    |  6 ++-
 src/listmonitormodel.cpp                      | 30 +++++++++++++-
 src/listmonitormodel.h                        | 12 ++++++
 .../listmonitorlistmodel_test.cpp             | 41 ++++++++++++-------
 test/monitor_models/listmonitormodel_test.cpp | 36 ++++++++++++++++
 7 files changed, 135 insertions(+), 25 deletions(-)

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/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);
+}

From 9d012ae87d0d74560965880ad698dfb8d949759e Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 13 Oct 2024 21:39:44 +0200
Subject: [PATCH 07/56] Revert "Temporarily force Qt 6.7.2"

This reverts commit 331571f8fc57c0289df2c616230b86f4c0079038.
---
 .github/workflows/build.yml  | 2 +-
 .github/workflows/utests.yml | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 19a752b..c228c0c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,7 +16,7 @@ jobs:
         include:
           - qt_version: '6.6'
             qt_arch: 'gcc_64'
-          - qt_version: '6.7.2'
+          - qt_version: '6.7'
             qt_arch: 'linux_gcc_64'
 
     runs-on: ubuntu-latest
diff --git a/.github/workflows/utests.yml b/.github/workflows/utests.yml
index 616004e..bc84c7b 100644
--- a/.github/workflows/utests.yml
+++ b/.github/workflows/utests.yml
@@ -16,7 +16,7 @@ jobs:
         include:
           - qt_version: '6.6'
             qt_arch: 'gcc_64'
-          - qt_version: '6.7.2'
+          - qt_version: '6.7'
             qt_arch: 'linux_gcc_64'
 
     runs-on: ubuntu-latest

From 38a11ecdd77666f02c105d5c6ce7a58c8411bfb1 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 24 Oct 2024 17:50:14 +0200
Subject: [PATCH 08/56] Set up CI for Qt 6.8

---
 .github/workflows/build.yml  | 2 ++
 .github/workflows/utests.yml | 2 ++
 2 files changed, 4 insertions(+)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index c228c0c..91e2b0c 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,6 +18,8 @@ jobs:
             qt_arch: 'gcc_64'
           - 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 bc84c7b..7c12a1a 100644
--- a/.github/workflows/utests.yml
+++ b/.github/workflows/utests.yml
@@ -18,6 +18,8 @@ jobs:
             qt_arch: 'gcc_64'
           - qt_version: '6.7'
             qt_arch: 'linux_gcc_64'
+          - qt_version: '6.8'
+            qt_arch: 'linux_gcc_64'
 
     runs-on: ubuntu-latest
 

From a9268eda5aab4eaa7f656a03707e33c5081d8e7d Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Fri, 25 Oct 2024 10:09:12 +0200
Subject: [PATCH 09/56] ProjectLoader: Fix duplicate connections

---
 src/projectloader.cpp | 13 ++++++++-----
 src/projectloader.h   |  1 +
 2 files changed, 9 insertions(+), 5 deletions(-)

diff --git a/src/projectloader.cpp b/src/projectloader.cpp
index b6038df..e4fd932 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -251,6 +251,7 @@ void ProjectLoader::clear()
     if (m_engine)
         m_engine->clear();
 
+    m_oldEngine = m_engine;
     m_engine = nullptr;
     emit engineChanged();
 }
@@ -280,12 +281,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();
diff --git a/src/projectloader.h b/src/projectloader.h
index a86c407..4f081c4 100644
--- a/src/projectloader.h
+++ b/src/projectloader.h
@@ -146,6 +146,7 @@ class ProjectLoader : public QObject
         libscratchcpp::Project m_project;
         bool m_running = false;
         libscratchcpp::IEngine *m_engine = nullptr;
+        libscratchcpp::IEngine *m_oldEngine = nullptr;
         QMutex m_engineMutex;
         bool m_loadStatus = false;
         StageModel m_stage;

From b940173c2d0be603ad8488db940a04250f531f9d Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Fri, 25 Oct 2024 12:16:39 +0200
Subject: [PATCH 10/56] ProjectLoader: Add renderFps property

---
 src/projectloader.cpp | 15 +++++++++++++++
 src/projectloader.h   |  7 +++++++
 2 files changed, 22 insertions(+)

diff --git a/src/projectloader.cpp b/src/projectloader.cpp
index e4fd932..abb915f 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -35,6 +35,7 @@ ProjectLoader::ProjectLoader(QObject *parent) :
     connect(qApp, &QCoreApplication::aboutToQuit, this, &ProjectLoader::clear);
 
     initTimer();
+    m_renderTimer.start();
 
     // Register pen blocks
     ScratchConfiguration::registerExtension(std::make_shared<PenBlocks>());
@@ -97,6 +98,11 @@ bool ProjectLoader::running() const
     return m_running;
 }
 
+int ProjectLoader::renderFps() const
+{
+    return m_renderFps;
+}
+
 IEngine *ProjectLoader::engine() const
 {
     if (m_loadThread.isRunning())
@@ -202,6 +208,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();
diff --git a/src/projectloader.h b/src/projectloader.h
index 4f081c4..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();
@@ -145,6 +149,9 @@ 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;

From 688bb9ddb314197b3dbb22ed92e4e71b199f2117 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Fri, 25 Oct 2024 12:17:00 +0200
Subject: [PATCH 11/56] ProjectPlayer: Add renderFps property

Resolves: #139
---
 src/ProjectPlayer.qml | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml
index 86fa183..186ffba 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

From b255ca0ab948865895489ddb77ae71161fc6c3a7 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Fri, 25 Oct 2024 12:18:19 +0200
Subject: [PATCH 12/56] Set swap interval to 0

FPS can't be limited until rendering is refactored.
---
 src/global_functions.cpp | 4 ++++
 1 file changed, 4 insertions(+)

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()

From ab3d2a246c9d0e3d9dd6920ddba18edae516b35f Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 26 Oct 2024 22:45:34 +0200
Subject: [PATCH 13/56] Update libscratchcpp to latest master

---
 libscratchcpp | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/libscratchcpp b/libscratchcpp
index 47beef7..d37f312 160000
--- a/libscratchcpp
+++ b/libscratchcpp
@@ -1 +1 @@
-Subproject commit 47beef7c9116ea2a90e037e113d975c1a0644524
+Subproject commit d37f31232887cb588333731b56d019cbb8ea86ae

From ea4340ad15166973595e47126c9469a459d1b2ab Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 26 Oct 2024 23:16:34 +0200
Subject: [PATCH 14/56] Fix touching color test

---
 test/renderedtarget/renderedtarget_test.cpp | 42 ++++++++++-----------
 1 file changed, 21 insertions(+), 21 deletions(-)

diff --git a/test/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index 8a9e6a5..59b5d41 100644
--- a/test/renderedtarget/renderedtarget_test.cpp
+++ b/test/renderedtarget/renderedtarget_test.cpp
@@ -1168,12 +1168,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 +1184,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 +1207,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 +1223,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 +1236,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 +1260,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 +1278,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);
 

From 6a539ae369522f1f205d6db1d55c25560d32858a Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 26 Oct 2024 23:17:11 +0200
Subject: [PATCH 15/56] Disable pen blocks when using LLVM

---
 src/CMakeLists.txt    | 9 +++++++--
 src/projectloader.cpp | 2 ++
 2 files changed, 9 insertions(+), 2 deletions(-)

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/projectloader.cpp b/src/projectloader.cpp
index abb915f..d18f450 100644
--- a/src/projectloader.cpp
+++ b/src/projectloader.cpp
@@ -37,8 +37,10 @@ ProjectLoader::ProjectLoader(QObject *parent) :
     initTimer();
     m_renderTimer.start();
 
+#ifndef USE_LLVM
     // Register pen blocks
     ScratchConfiguration::registerExtension(std::make_shared<PenBlocks>());
+#endif
 }
 
 ProjectLoader::~ProjectLoader()

From dcffe3f001a3cb09582a53a7f299e4f2018c52e4 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 26 Oct 2024 23:17:37 +0200
Subject: [PATCH 16/56] Fix build on wasm

---
 CMakeLists.txt | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 678842f..c675f56 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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)

From 95616e47a2094d7a0ff495a64665db9a186935da Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 26 Oct 2024 23:18:37 +0200
Subject: [PATCH 17/56] Make shaders more portable

---
 src/shadermanager.cpp                     | 12 ++++-
 src/shaders/sprite.frag                   |  9 +++-
 src/shaders/sprite.vert                   |  6 +--
 test/shadermanager/shadermanager_test.cpp | 58 ++++-------------------
 4 files changed, 29 insertions(+), 56 deletions(-)

diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index 928e32f..d3442b5 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -22,6 +22,14 @@ 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 std::unordered_map<ShaderManager::Effect, const char *>
@@ -58,7 +66,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);
@@ -150,7 +158,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/shaders/sprite.frag b/src/shaders/sprite.frag
index 9568bf5..e08d03f 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,7 @@ uniform float u_brightness;
 uniform float u_ghost;
 #endif // ENABLE_ghost
 
-in vec2 v_texCoord;
-//out vec4 FragColor;
+varying vec2 v_texCoord;
 uniform sampler2D u_skin;
 
 // Add this to divisors to prevent division by 0, which results in NaNs propagating through calculations.
diff --git a/src/shaders/sprite.vert b/src/shaders/sprite.vert
index f7d09b0..08245ff 100644
--- a/src/shaders/sprite.vert
+++ b/src/shaders/sprite.vert
@@ -1,7 +1,7 @@
-layout (location = 0) in vec2 a_position;
-layout (location = 1) in vec2 a_texCoord;
+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);
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index 6bbe0a2..f49d10f 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -83,66 +83,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(effects);
+    ASSERT_EQ(program, program);
 
-    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(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)

From b3da8fa0b580e0f99236ccf5d0078fb5dd68b681 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sat, 26 Oct 2024 23:56:09 +0200
Subject: [PATCH 18/56] Use Scratch's workaround for fully transparent pixels

---
 src/shaders/sprite.frag |  29 +++++++++++++++++------------
 test/color_effects.png  | Bin 238 -> 240 bytes
 2 files changed, 17 insertions(+), 12 deletions(-)

diff --git a/src/shaders/sprite.frag b/src/shaders/sprite.frag
index e08d03f..80dbdf7 100644
--- a/src/shaders/sprite.frag
+++ b/src/shaders/sprite.frag
@@ -87,11 +87,18 @@ const vec2 kCenter = vec2(0.5, 0.5);
 
 void main()
 {
-    vec4 texColor = texture2D(u_skin, v_texCoord);
+    vec2 texcoord0 = v_texCoord;
+
+    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;
@@ -102,22 +109,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/test/color_effects.png b/test/color_effects.png
index c18e4a366d13075d64daef1412701b1dbde46b2e..5a344417edac59416cf5780d2c348cc7caccffad 100644
GIT binary patch

From e691f7671d92c9c31bb7f6fcff9cff135c988792 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 13:26:48 +0100
Subject: [PATCH 19/56] Improve ShaderManager test

---
 test/shadermanager/shadermanager_test.cpp | 107 +++++++++-------------
 1 file changed, 44 insertions(+), 63 deletions(-)

diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index f49d10f..f55b774 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)
@@ -110,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, 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)
@@ -144,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, 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, 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, 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)
@@ -195,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, 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, 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, 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)
@@ -246,42 +229,40 @@ 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, 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, 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, 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();
 }

From 5f231097ba8477de57f7f8241feb3097fccc2d29 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 16:14:24 +0100
Subject: [PATCH 20/56] test: Fix transparent pixels in fuzzyCompareImages()

---
 test/common.h | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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++;
         }
     }

From 56d97901e098a4f260a06dcc12fd65ec34508f3a Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 16:15:21 +0100
Subject: [PATCH 21/56] Fix TargetPainter test

---
 test/targetpainter/targetpainter_test.cpp | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)

diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 57175be..4664b45 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -101,12 +101,7 @@ TEST_F(TargetPainterTest, Paint)
     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);
 
     // Release
     fbo.release();

From f851fb9611f7671dd0d5656ab835068d1511a3ba Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 16:25:03 +0100
Subject: [PATCH 22/56] Implement the fisheye effect

---
 src/shadermanager.cpp                     |  21 +++++++---
 src/shadermanager.h                       |   3 +-
 src/shaders/sprite.frag                   |  15 +++++++
 test/shadermanager/shadermanager_test.cpp |  48 ++++++++++++++++++++++
 test/shape_changing_effects.png           | Bin 0 -> 356 bytes
 test/targetpainter/targetpainter_test.cpp |  17 ++++++++
 6 files changed, 97 insertions(+), 7 deletions(-)
 create mode 100644 test/shape_changing_effects.png

diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index d3442b5..61b99c2 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -32,20 +32,29 @@ static const QString SHADER_PREFIX = "#version 140\n";
 
 static const char *TEXTURE_UNIT_UNIFORM = "u_skin";
 
-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" }
+};
 
-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" }
+};
 
 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); } }
 };
 
 static const std::unordered_map<ShaderManager::Effect, bool>
-    EFFECT_SHAPE_CHANGES = { { ShaderManager::Effect::Color, false }, { ShaderManager::Effect::Brightness, false }, { ShaderManager::Effect::Ghost, false } };
+    EFFECT_SHAPE_CHANGES = { { ShaderManager::Effect::Color, false }, { ShaderManager::Effect::Brightness, false }, { ShaderManager::Effect::Ghost, false }, { ShaderManager::Effect::Fisheye, true } };
 
 Q_GLOBAL_STATIC(ShaderManager, globalInstance)
 
diff --git a/src/shadermanager.h b/src/shadermanager.h
index edcb614..277d822 100644
--- a/src/shadermanager.h
+++ b/src/shadermanager.h
@@ -18,7 +18,8 @@ class ShaderManager : public QObject
         {
             Color = 1 << 0,
             Brightness = 1 << 1,
-            Ghost = 1 << 2
+            Ghost = 1 << 2,
+            Fisheye = 1 << 3
         };
 
         explicit ShaderManager(QObject *parent = nullptr);
diff --git a/src/shaders/sprite.frag b/src/shaders/sprite.frag
index 80dbdf7..77e1fa1 100644
--- a/src/shaders/sprite.frag
+++ b/src/shaders/sprite.frag
@@ -18,6 +18,10 @@ uniform float u_brightness;
 uniform float u_ghost;
 #endif // ENABLE_ghost
 
+#ifdef ENABLE_fisheye
+uniform float u_fisheye;
+#endif // ENABLE_fisheye
+
 varying vec2 v_texCoord;
 uniform sampler2D u_skin;
 
@@ -89,6 +93,17 @@ void main()
 {
     vec2 texcoord0 = v_texCoord;
 
+    #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)
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index f55b774..4da7164 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -266,3 +266,51 @@ TEST_F(ShaderManagerTest, GhostEffectValue)
 
     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, 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, 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, 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();
+}
diff --git a/test/shape_changing_effects.png b/test/shape_changing_effects.png
new file mode 100644
index 0000000000000000000000000000000000000000..02a9d0604e139aed5ca16d5406342112d5cc8cec
GIT binary patch
diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 4664b45..6415dda 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -103,6 +103,23 @@ TEST_F(TargetPainterTest, Paint)
     // Compare with reference image
     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, 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();
     refFbo.release();

From ca7ab447d0800f7ccb9aa162ecf05f0a07df033c Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 17:50:00 +0100
Subject: [PATCH 23/56] Implement the whirl effect

---
 src/shadermanager.cpp                     |  20 +++++++++---
 src/shadermanager.h                       |   3 +-
 src/shaders/sprite.frag                   |  22 +++++++++++++
 test/shadermanager/shadermanager_test.cpp |  37 ++++++++++++++++++++++
 test/shape_changing_effects.png           | Bin 356 -> 361 bytes
 test/targetpainter/targetpainter_test.cpp |   4 +--
 6 files changed, 78 insertions(+), 8 deletions(-)

diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index 61b99c2..9bbd097 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
@@ -36,25 +38,33 @@ static const std::unordered_map<ShaderManager::Effect, const char *> EFFECT_TO_N
     { ShaderManager::Effect::Color, "color" },
     { ShaderManager::Effect::Brightness, "brightness" },
     { ShaderManager::Effect::Ghost, "ghost" },
-    { ShaderManager::Effect::Fisheye, "fisheye" }
+    { ShaderManager::Effect::Fisheye, "fisheye" },
+    { ShaderManager::Effect::Whirl, "whirl" }
 };
 
 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::Fisheye, "u_fisheye" },
+    { ShaderManager::Effect::Whirl, "u_whirl" }
 };
 
 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::Fisheye, [](float x) { return std::max(0.0f, (x + 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; } }
 };
 
-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 } };
+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 }
+};
 
 Q_GLOBAL_STATIC(ShaderManager, globalInstance)
 
diff --git a/src/shadermanager.h b/src/shadermanager.h
index 277d822..3afc8d9 100644
--- a/src/shadermanager.h
+++ b/src/shadermanager.h
@@ -19,7 +19,8 @@ class ShaderManager : public QObject
             Color = 1 << 0,
             Brightness = 1 << 1,
             Ghost = 1 << 2,
-            Fisheye = 1 << 3
+            Fisheye = 1 << 3,
+            Whirl = 1 << 4
         };
 
         explicit ShaderManager(QObject *parent = nullptr);
diff --git a/src/shaders/sprite.frag b/src/shaders/sprite.frag
index 77e1fa1..d5ad92b 100644
--- a/src/shaders/sprite.frag
+++ b/src/shaders/sprite.frag
@@ -22,6 +22,10 @@ uniform float u_ghost;
 uniform float u_fisheye;
 #endif // ENABLE_fisheye
 
+#ifdef ENABLE_whirl
+uniform float u_whirl;
+#endif // ENABLE_whirl
+
 varying vec2 v_texCoord;
 uniform sampler2D u_skin;
 
@@ -93,6 +97,24 @@ void main()
 {
     vec2 texcoord0 = v_texCoord;
 
+    #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;
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index 4da7164..4e24303 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -314,3 +314,40 @@ TEST_F(ShaderManagerTest, FisheyeEffectValue)
 
     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, 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, 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();
+}
diff --git a/test/shape_changing_effects.png b/test/shape_changing_effects.png
index 02a9d0604e139aed5ca16d5406342112d5cc8cec..b4ccfbd33f653afa88da74297f3f6311c755f411 100644
GIT binary patch
diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 6415dda..4d7376a 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -108,8 +108,8 @@ TEST_F(TargetPainterTest, Paint)
     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::Whirl] = 50;
+    /*effects[ShaderManager::Effect::Pixelate] = 25;
     effects[ShaderManager::Effect::Mosaic] = 30;*/
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));

From 4045e25fc2dedaa5075e4155846fc0f8aa56a346 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 18:10:38 +0100
Subject: [PATCH 24/56] Add skin size uniform

---
 src/penlayer.cpp                          |  2 +-
 src/shadermanager.cpp                     |  6 ++++-
 src/shadermanager.h                       |  5 ++--
 src/targetpainter.cpp                     |  2 +-
 test/shadermanager/shadermanager_test.cpp | 30 +++++++++++------------
 test/targetpainter/targetpainter_test.cpp |  3 +++
 6 files changed, 28 insertions(+), 20 deletions(-)

diff --git a/src/penlayer.cpp b/src/penlayer.cpp
index 031d504..401d461 100644
--- a/src/penlayer.cpp
+++ b/src/penlayer.cpp
@@ -303,7 +303,7 @@ void PenLayer::stamp(IRenderedTarget *target)
     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
+    shaderManager->setUniforms(shaderProgram, 0, texture.size(), effects); // set texture and effect uniforms
     m_glF->glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
 
     m_painter->endFrame();
diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index 9bbd097..5567dd9 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -33,6 +33,7 @@ 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" },
@@ -146,11 +147,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);
diff --git a/src/shadermanager.h b/src/shadermanager.h
index 3afc8d9..5e20fbd 100644
--- a/src/shadermanager.h
+++ b/src/shadermanager.h
@@ -20,7 +20,8 @@ class ShaderManager : public QObject
             Brightness = 1 << 1,
             Ghost = 1 << 2,
             Fisheye = 1 << 3,
-            Whirl = 1 << 4
+            Whirl = 1 << 4,
+            Pixelate = 1 << 5
         };
 
         explicit ShaderManager(QObject *parent = nullptr);
@@ -29,7 +30,7 @@ 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);
 
     private:
         struct Registrar
diff --git a/src/targetpainter.cpp b/src/targetpainter.cpp
index bd1cc40..d87d175 100644
--- a/src/targetpainter.cpp
+++ b/src/targetpainter.cpp
@@ -94,7 +94,7 @@ 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
+    shaderManager->setUniforms(shaderProgram, 0, m_target->cpuTexture().size(), effects); // set texture and effect uniforms
     glF.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
 
     // Process the resulting texture
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index 4e24303..972eeeb 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -99,7 +99,7 @@ TEST_F(ShaderManagerTest, SetUniforms)
     std::unordered_map<ShaderManager::Effect, double> effects = { { ShaderManager::Effect::Color, 64.9 }, { ShaderManager::Effect::Ghost, 12.5 } };
     QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 4, effects);
+    manager.setUniforms(program, 4, QSize(), effects);
 
     GLint texUnit = -1;
     glF.glGetUniformiv(program->programId(), program->uniformLocation("u_skin"), &texUnit);
@@ -132,7 +132,7 @@ TEST_F(ShaderManagerTest, ColorEffectValue)
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 64.9 } };
     QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
@@ -144,7 +144,7 @@ TEST_F(ShaderManagerTest, ColorEffectValue)
     effects[effect] = -395.7;
     program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -156,7 +156,7 @@ TEST_F(ShaderManagerTest, ColorEffectValue)
     effects[effect] = 579.05;
     program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -183,7 +183,7 @@ TEST_F(ShaderManagerTest, BrightnessEffectValue)
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 4.6 } };
     QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
@@ -195,7 +195,7 @@ TEST_F(ShaderManagerTest, BrightnessEffectValue)
     effects[effect] = -102.9;
     program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -207,7 +207,7 @@ TEST_F(ShaderManagerTest, BrightnessEffectValue)
     effects[effect] = 353.2;
     program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -234,7 +234,7 @@ TEST_F(ShaderManagerTest, GhostEffectValue)
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
     QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
@@ -245,7 +245,7 @@ TEST_F(ShaderManagerTest, GhostEffectValue)
     // Below the minimum
     effects[effect] = -20.8;
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -256,7 +256,7 @@ TEST_F(ShaderManagerTest, GhostEffectValue)
     // Above the maximum
     effects[effect] = 248.2;
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -283,7 +283,7 @@ TEST_F(ShaderManagerTest, FisheyeEffectValue)
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
     QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
@@ -293,7 +293,7 @@ TEST_F(ShaderManagerTest, FisheyeEffectValue)
 
     effects[effect] = -20.8;
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -304,7 +304,7 @@ TEST_F(ShaderManagerTest, FisheyeEffectValue)
     // Below the minimum
     effects[effect] = -101;
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
@@ -331,7 +331,7 @@ TEST_F(ShaderManagerTest, WhirlEffectValue)
     std::unordered_map<ShaderManager::Effect, double> effects = { { effect, 58.5 } };
     QOpenGLShaderProgram *program = manager.getShaderProgram(effects);
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     GLfloat value = 0.0f;
@@ -341,7 +341,7 @@ TEST_F(ShaderManagerTest, WhirlEffectValue)
 
     effects[effect] = -20.8;
     program->bind();
-    manager.setUniforms(program, 0, effects);
+    manager.setUniforms(program, 0, QSize(), effects);
     manager.getUniformValuesForEffects(effects, values);
 
     value = 0.0f;
diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 4d7376a..8f7ee4c 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -81,6 +81,7 @@ 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, cpuTexture()).WillOnce(ReturnRef(texture));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));
     targetPainter.paint(&painter);
     painter.endFrame();
@@ -95,6 +96,7 @@ TEST_F(TargetPainterTest, Paint)
     effects[ShaderManager::Effect::Brightness] = 20;
     effects[ShaderManager::Effect::Ghost] = 84;
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
+    EXPECT_CALL(target, cpuTexture()).WillOnce(ReturnRef(texture));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));
     targetPainter.paint(&painter);
     painter.endFrame();
@@ -112,6 +114,7 @@ TEST_F(TargetPainterTest, Paint)
     /*effects[ShaderManager::Effect::Pixelate] = 25;
     effects[ShaderManager::Effect::Mosaic] = 30;*/
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
+    EXPECT_CALL(target, cpuTexture()).WillOnce(ReturnRef(texture));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));
     targetPainter.paint(&painter);
     painter.endFrame();

From fc4b640b0358169409445a0d7d8e0f4bd80ccdc8 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 18:51:33 +0100
Subject: [PATCH 25/56] Implement the pixelate effect

---
 src/shadermanager.cpp                     |  24 +++++---------
 src/shaders/sprite.frag                   |  13 ++++++++
 test/penlayer/penlayer_test.cpp           |   1 +
 test/shadermanager/shadermanager_test.cpp |  37 ++++++++++++++++++++++
 test/shape_changing_effects.png           | Bin 361 -> 383 bytes
 test/stamp.png                            | Bin 57950 -> 58286 bytes
 test/stamp_hq.png                         | Bin 203265 -> 199344 bytes
 test/targetpainter/targetpainter_test.cpp |   4 +--
 8 files changed, 61 insertions(+), 18 deletions(-)

diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index 5567dd9..afe03f7 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -36,19 +36,13 @@ 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" },
-    { ShaderManager::Effect::Fisheye, "fisheye" },
-    { ShaderManager::Effect::Whirl, "whirl" }
+    { ShaderManager::Effect::Color, "color" },     { ShaderManager::Effect::Brightness, "brightness" }, { ShaderManager::Effect::Ghost, "ghost" },
+    { ShaderManager::Effect::Fisheye, "fisheye" }, { ShaderManager::Effect::Whirl, "whirl" },           { ShaderManager::Effect::Pixelate, "pixelate" }
 };
 
 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::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" }
 };
 
 static const std::unordered_map<ShaderManager::Effect, ConverterFunc> EFFECT_CONVERTER = {
@@ -56,15 +50,13 @@ static const std::unordered_map<ShaderManager::Effect, ConverterFunc> EFFECT_CON
     { 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::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::Whirl, [](float x) { return x * (float)pi / 180.0f; } },
+    { ShaderManager::Effect::Pixelate, [](float x) { return std::abs(x) / 10.0f; } }
 };
 
 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::Color, false },  { ShaderManager::Effect::Brightness, false }, { ShaderManager::Effect::Ghost, false },
+    { ShaderManager::Effect::Fisheye, true }, { ShaderManager::Effect::Whirl, true },       { ShaderManager::Effect::Pixelate, true }
 };
 
 Q_GLOBAL_STATIC(ShaderManager, globalInstance)
diff --git a/src/shaders/sprite.frag b/src/shaders/sprite.frag
index d5ad92b..1f7b421 100644
--- a/src/shaders/sprite.frag
+++ b/src/shaders/sprite.frag
@@ -26,6 +26,11 @@ uniform float u_fisheye;
 uniform float u_whirl;
 #endif // ENABLE_whirl
 
+#ifdef ENABLE_pixelate
+uniform float u_pixelate;
+uniform vec2 u_skinSize;
+#endif // ENABLE_pixelate
+
 varying vec2 v_texCoord;
 uniform sampler2D u_skin;
 
@@ -97,6 +102,14 @@ void main()
 {
     vec2 texcoord0 = v_texCoord;
 
+    #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;
diff --git a/test/penlayer/penlayer_test.cpp b/test/penlayer/penlayer_test.cpp
index 9470f5b..fa55110 100644
--- a/test/penlayer/penlayer_test.cpp
+++ b/test/penlayer/penlayer_test.cpp
@@ -393,6 +393,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++;
     }
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index 972eeeb..46e62bd 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -351,3 +351,40 @@ TEST_F(ShaderManagerTest, WhirlEffectValue)
 
     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();
+}
diff --git a/test/shape_changing_effects.png b/test/shape_changing_effects.png
index b4ccfbd33f653afa88da74297f3f6311c755f411..13e707984f7c01ef89396b940b24c2bad19c9f19 100644
GIT binary patch
diff --git a/test/stamp.png b/test/stamp.png
index 7206580407fd420e311f87fdb663c5a0963d8b38..7bf93058d49aacef0034ee01877d1a012a8dd906 100644
GIT binary patch
diff --git a/test/stamp_hq.png b/test/stamp_hq.png
index b259b797f863b3f9eba23c7f493c0cfad04cb438..c32caf92847c6e1a2acfe6cbd5dd612251ea64a1 100644
GIT binary patch
diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 8f7ee4c..5184d3d 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -111,8 +111,8 @@ TEST_F(TargetPainterTest, Paint)
     effects.clear();
     effects[ShaderManager::Effect::Fisheye] = 46;
     effects[ShaderManager::Effect::Whirl] = 50;
-    /*effects[ShaderManager::Effect::Pixelate] = 25;
-    effects[ShaderManager::Effect::Mosaic] = 30;*/
+    effects[ShaderManager::Effect::Pixelate] = 25;
+    // effects[ShaderManager::Effect::Mosaic] = 30;
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
     EXPECT_CALL(target, cpuTexture()).WillOnce(ReturnRef(texture));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));

From 3a0187a0613ddbcd318fa90fc2fbf64b22499eca Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 27 Oct 2024 19:41:41 +0100
Subject: [PATCH 26/56] Implement the mosaic effect

---
 src/shadermanager.cpp                     |  15 +++---
 src/shadermanager.h                       |   3 +-
 src/shaders/sprite.frag                   |   8 +++
 test/shadermanager/shadermanager_test.cpp |  59 ++++++++++++++++++++++
 test/shape_changing_effects.png           | Bin 383 -> 403 bytes
 test/targetpainter/targetpainter_test.cpp |   2 +-
 6 files changed, 78 insertions(+), 9 deletions(-)

diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index afe03f7..c3baebc 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -36,13 +36,13 @@ 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" },
-    { ShaderManager::Effect::Fisheye, "fisheye" }, { ShaderManager::Effect::Whirl, "whirl" },           { ShaderManager::Effect::Pixelate, "pixelate" }
+    { 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" },
-    { ShaderManager::Effect::Fisheye, "u_fisheye" }, { ShaderManager::Effect::Whirl, "u_whirl" },           { ShaderManager::Effect::Pixelate, "u_pixelate" }
+    { 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 = {
@@ -51,12 +51,13 @@ static const std::unordered_map<ShaderManager::Effect, ConverterFunc> EFFECT_CON
     { 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::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 },
-    { ShaderManager::Effect::Fisheye, true }, { ShaderManager::Effect::Whirl, true },       { ShaderManager::Effect::Pixelate, true }
+    { 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 }
 };
 
 Q_GLOBAL_STATIC(ShaderManager, globalInstance)
diff --git a/src/shadermanager.h b/src/shadermanager.h
index 5e20fbd..c31a853 100644
--- a/src/shadermanager.h
+++ b/src/shadermanager.h
@@ -21,7 +21,8 @@ class ShaderManager : public QObject
             Ghost = 1 << 2,
             Fisheye = 1 << 3,
             Whirl = 1 << 4,
-            Pixelate = 1 << 5
+            Pixelate = 1 << 5,
+            Mosaic = 1 << 6
         };
 
         explicit ShaderManager(QObject *parent = nullptr);
diff --git a/src/shaders/sprite.frag b/src/shaders/sprite.frag
index 1f7b421..3209cf8 100644
--- a/src/shaders/sprite.frag
+++ b/src/shaders/sprite.frag
@@ -31,6 +31,10 @@ 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;
 
@@ -102,6 +106,10 @@ void main()
 {
     vec2 texcoord0 = v_texCoord;
 
+    #ifdef ENABLE_mosaic
+    texcoord0 = fract(u_mosaic * texcoord0);
+    #endif // ENABLE_mosaic
+
     #ifdef ENABLE_pixelate
     {
         // TODO: clean up "pixel" edges
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index 46e62bd..ea14450 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -388,3 +388,62 @@ TEST_F(ShaderManagerTest, PixelateEffectValue)
 
     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();
+}
diff --git a/test/shape_changing_effects.png b/test/shape_changing_effects.png
index 13e707984f7c01ef89396b940b24c2bad19c9f19..a7f4d96aa1d91e0dc28bb8de9b85a511788b6c12 100644
GIT binary patch
diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 5184d3d..982d2e2 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -112,7 +112,7 @@ TEST_F(TargetPainterTest, Paint)
     effects[ShaderManager::Effect::Fisheye] = 46;
     effects[ShaderManager::Effect::Whirl] = 50;
     effects[ShaderManager::Effect::Pixelate] = 25;
-    // effects[ShaderManager::Effect::Mosaic] = 30;
+    effects[ShaderManager::Effect::Mosaic] = 30;
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
     EXPECT_CALL(target, cpuTexture()).WillOnce(ReturnRef(texture));
     EXPECT_CALL(target, graphicEffects()).WillOnce(ReturnRef(effects));

From e5fb1cc9552946e28917f11d52acde0c96ddad71 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Tue, 29 Oct 2024 13:19:10 +0100
Subject: [PATCH 27/56] ShaderManager: Add operators for effect mask

---
 src/shadermanager.h | 46 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 46 insertions(+)

diff --git a/src/shadermanager.h b/src/shadermanager.h
index c31a853..7acefea 100644
--- a/src/shadermanager.h
+++ b/src/shadermanager.h
@@ -16,6 +16,7 @@ class ShaderManager : public QObject
     public:
         enum class Effect
         {
+            NoEffect = 0,
             Color = 1 << 0,
             Brightness = 1 << 1,
             Ghost = 1 << 2,
@@ -50,4 +51,49 @@ class ShaderManager : public QObject
         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

From 10f9639b194d43be53248f44085b0729ebcec289 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Tue, 29 Oct 2024 13:21:12 +0100
Subject: [PATCH 28/56] Use effect mask instead of map for enabled effects

---
 src/cputexturemanager.cpp                     | 16 ++--
 src/cputexturemanager.h                       |  4 +-
 src/effecttransform.cpp                       |  8 +-
 src/effecttransform.h                         |  4 +-
 src/renderedtarget.cpp                        | 32 +++++---
 src/renderedtarget.h                          |  1 +
 test/effecttransform/effecttransform_test.cpp | 56 +++++++------
 test/texture/cputexturemanager_test.cpp       | 79 ++++++++++---------
 8 files changed, 107 insertions(+), 93 deletions(-)

diff --git a/src/cputexturemanager.cpp b/src/cputexturemanager.cpp
index 2174cf4..3b20719 100644
--- a/src/cputexturemanager.cpp
+++ b/src/cputexturemanager.cpp
@@ -52,16 +52,16 @@ const std::vector<QPoint> &CpuTextureManager::getTextureConvexHullPoints(const T
         return it->second;
 }
 
-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, localCoords, transformedCoords);
         x = transformedCoords.x() * width;
         y = transformedCoords.y() * height;
     }
@@ -72,13 +72,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 +86,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, localCoords, transformedCoords);
         x = transformedCoords.x() * width;
         y = transformedCoords.y() * height;
     }
diff --git a/src/cputexturemanager.h b/src/cputexturemanager.h
index 4abdb5a..5a3ad0c 100644
--- a/src/cputexturemanager.h
+++ b/src/cputexturemanager.h
@@ -22,8 +22,8 @@ class CpuTextureManager
         GLubyte *getTextureData(const Texture &texture);
         const std::vector<QPoint> &getTextureConvexHullPoints(const Texture &texture);
 
-        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);
 
diff --git a/src/effecttransform.cpp b/src/effecttransform.cpp
index 4ea1b70..0a59f4b 100644
--- a/src/effecttransform.cpp
+++ b/src/effecttransform.cpp
@@ -6,7 +6,7 @@
 
 using namespace scratchcpprender;
 
-QRgb EffectTransform::transformColor(const std::unordered_map<ShaderManager::Effect, double> &effectValues, QRgb color)
+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 +18,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,7 +99,7 @@ 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 QVector2D &vec, QVector2D &dst)
 {
     // TODO: Implement remaining effects
     dst = vec;
diff --git a/src/effecttransform.h b/src/effecttransform.h
index 6992f25..904d34e 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 QVector2D &vec, QVector2D &dst);
 };
 
 } // namespace scratchcpprender
diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp
index 348aabf..3624027 100644
--- a/src/renderedtarget.cpp
+++ b/src/renderedtarget.cpp
@@ -540,18 +540,20 @@ 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;
     }
@@ -569,6 +571,7 @@ void RenderedTarget::clearGraphicEffects()
 
     // TODO: Set m_convexHullDirty to true if any of the previous effects changed shape
     m_graphicEffects.clear();
+    m_graphicEffectMask = ShaderManager::Effect::NoEffect;
 }
 
 const std::vector<QPoint> &RenderedTarget::hullPoints() const
@@ -618,7 +621,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
@@ -806,7 +809,7 @@ const std::vector<QPointF> &RenderedTarget::transformedHullPoints() const
 
 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 +877,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 +912,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 +924,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..54ea86c 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;
diff --git a/test/effecttransform/effecttransform_test.cpp b/test/effecttransform/effecttransform_test.cpp
index d5f92fa..f50526e 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, QVector2D(0.5, -0.3), dst);
     ASSERT_EQ(dst, QVector2D(0.5, -0.3));
 }
 
@@ -36,88 +37,91 @@ 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));
 }
diff --git a/test/texture/cputexturemanager_test.cpp b/test/texture/cputexturemanager_test.cpp
index e2634fa..7f4a438 100644
--- a/test/texture/cputexturemanager_test.cpp
+++ b/test/texture/cputexturemanager_test.cpp
@@ -145,26 +145,28 @@ 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)
 
@@ -188,26 +190,27 @@ 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 }, {}));
+    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, {}));
 
     // TODO: Test point transform (graphic effects that change shape)
 

From c6c9fa06a12e3a38d72833910eb597119e95a258 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Tue, 29 Oct 2024 13:26:57 +0100
Subject: [PATCH 29/56] ShaderManager: Add effectShapeChanges() method

---
 src/shadermanager.cpp                     |  6 ++++++
 src/shadermanager.h                       |  2 ++
 test/shadermanager/shadermanager_test.cpp | 11 +++++++++++
 3 files changed, 19 insertions(+)

diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index c3baebc..30bed58 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -156,6 +156,12 @@ void ShaderManager::setUniforms(QOpenGLShaderProgram *program, int textureUnit,
         program->setUniformValue(EFFECT_UNIFORM_NAME.at(effect), value);
 }
 
+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
diff --git a/src/shadermanager.h b/src/shadermanager.h
index 7acefea..437481f 100644
--- a/src/shadermanager.h
+++ b/src/shadermanager.h
@@ -34,6 +34,8 @@ class ShaderManager : public QObject
         static void getUniformValuesForEffects(const std::unordered_map<Effect, double> &effectValues, std::unordered_map<Effect, float> &dst);
         void setUniforms(QOpenGLShaderProgram *program, int textureUnit, const QSize skinSize, const std::unordered_map<Effect, double> &effectValues);
 
+        static bool effectShapeChanges(Effect effect);
+
     private:
         struct Registrar
         {
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index ea14450..ba7c301 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -447,3 +447,14 @@ TEST_F(ShaderManagerTest, MosaicEffectValue)
 
     program->release();
 }
+
+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));
+}

From 5fce95dc86c91f329389bcecc93fa2d55be0b9fc Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Tue, 29 Oct 2024 16:51:40 +0100
Subject: [PATCH 30/56] Fix stamp test

---
 test/penlayer/penlayer_test.cpp | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/test/penlayer/penlayer_test.cpp b/test/penlayer/penlayer_test.cpp
index fa55110..aabc56c 100644
--- a/test/penlayer/penlayer_test.cpp
+++ b/test/penlayer/penlayer_test.cpp
@@ -405,7 +405,7 @@ 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.18);
     }
 
     // Test HQ pen
@@ -422,7 +422,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.33);
     }
 }
 

From 0e43c14e41765c75ca17d58738661062d4618d25 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 19:07:11 +0100
Subject: [PATCH 31/56] TargetPainter: Fix skin size

---
 src/targetpainter.cpp                     | 2 +-
 test/targetpainter/targetpainter_test.cpp | 9 ++++++---
 2 files changed, 7 insertions(+), 4 deletions(-)

diff --git a/src/targetpainter.cpp b/src/targetpainter.cpp
index d87d175..a67f10e 100644
--- a/src/targetpainter.cpp
+++ b/src/targetpainter.cpp
@@ -94,7 +94,7 @@ void TargetPainter::paint(QNanoPainter *painter)
     glF.glBindVertexArray(VAO);
     glF.glActiveTexture(GL_TEXTURE0);
     glF.glBindTexture(GL_TEXTURE_2D, texture.handle());
-    shaderManager->setUniforms(shaderProgram, 0, m_target->cpuTexture().size(), effects); // set texture and effect uniforms
+    shaderManager->setUniforms(shaderProgram, 0, QSize(m_target->costumeWidth(), m_target->costumeHeight()), effects); // set texture and effect uniforms
     glF.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
 
     // Process the resulting texture
diff --git a/test/targetpainter/targetpainter_test.cpp b/test/targetpainter/targetpainter_test.cpp
index 982d2e2..2e02853 100644
--- a/test/targetpainter/targetpainter_test.cpp
+++ b/test/targetpainter/targetpainter_test.cpp
@@ -81,7 +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, cpuTexture()).WillOnce(ReturnRef(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();
@@ -96,7 +97,8 @@ TEST_F(TargetPainterTest, Paint)
     effects[ShaderManager::Effect::Brightness] = 20;
     effects[ShaderManager::Effect::Ghost] = 84;
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
-    EXPECT_CALL(target, cpuTexture()).WillOnce(ReturnRef(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();
@@ -114,7 +116,8 @@ TEST_F(TargetPainterTest, Paint)
     effects[ShaderManager::Effect::Pixelate] = 25;
     effects[ShaderManager::Effect::Mosaic] = 30;
     EXPECT_CALL(target, texture()).WillOnce(Return(texture));
-    EXPECT_CALL(target, cpuTexture()).WillOnce(ReturnRef(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();

From b855d00fca907e32c1305782192e3335746814d6 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 19:14:05 +0100
Subject: [PATCH 32/56] ShaderManager: Add effects() method

---
 src/shadermanager.cpp                     | 12 ++++++++++++
 src/shadermanager.h                       |  3 +++
 test/shadermanager/shadermanager_test.cpp | 14 ++++++++++++++
 3 files changed, 29 insertions(+)

diff --git a/src/shadermanager.cpp b/src/shadermanager.cpp
index 30bed58..7d96640 100644
--- a/src/shadermanager.cpp
+++ b/src/shadermanager.cpp
@@ -60,6 +60,8 @@ static const std::unordered_map<ShaderManager::Effect, bool> EFFECT_SHAPE_CHANGE
     { 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)
 
 ShaderManager::Registrar ShaderManager::m_registrar;
@@ -156,6 +158,16 @@ 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());
diff --git a/src/shadermanager.h b/src/shadermanager.h
index 437481f..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;
@@ -34,6 +35,7 @@ class ShaderManager : public QObject
         static void getUniformValuesForEffects(const std::unordered_map<Effect, double> &effectValues, std::unordered_map<Effect, float> &dst);
         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:
@@ -47,6 +49,7 @@ 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;
diff --git a/test/shadermanager/shadermanager_test.cpp b/test/shadermanager/shadermanager_test.cpp
index ba7c301..1029323 100644
--- a/test/shadermanager/shadermanager_test.cpp
+++ b/test/shadermanager/shadermanager_test.cpp
@@ -448,6 +448,20 @@ TEST_F(ShaderManagerTest, MosaicEffectValue)
     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));

From cab76f66fcf55011a8d3478485fb40bcc056810b Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 19:14:55 +0100
Subject: [PATCH 33/56] Implement CPU rendering for shape-changing effects

---
 src/cputexturemanager.cpp                     | 201 +++++++++++++++---
 src/cputexturemanager.h                       |  16 +-
 src/effecttransform.cpp                       |  83 +++++++-
 src/effecttransform.h                         |   2 +-
 src/penlayer.cpp                              |   3 +-
 src/renderedtarget.cpp                        |  20 +-
 test/effecttransform/effecttransform_test.cpp |  70 +++++-
 test/renderedtarget/renderedtarget_test.cpp   |   2 +-
 test/texture/cputexturemanager_test.cpp       |  32 +--
 9 files changed, 367 insertions(+), 62 deletions(-)

diff --git a/src/cputexturemanager.cpp b/src/cputexturemanager.cpp
index 3b20719..8f0437e 100644
--- a/src/cputexturemanager.cpp
+++ b/src/cputexturemanager.cpp
@@ -33,23 +33,40 @@ 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, ShaderManager::Effect effectMask, const std::unordered_map<ShaderManager::Effect, double> &effects)
@@ -61,7 +78,7 @@ QRgb CpuTextureManager::getPointColor(const Texture &texture, int x, int y, Shad
         // Get local position with effect transform
         QVector2D transformedCoords;
         const QVector2D localCoords(x / static_cast<float>(width), y / static_cast<float>(height));
-        EffectTransform::transformPoint(effectMask, effects, localCoords, transformedCoords);
+        EffectTransform::transformPoint(effectMask, effects, texture.size(), localCoords, transformedCoords);
         x = transformedCoords.x() * width;
         y = transformedCoords.y() * height;
     }
@@ -90,7 +107,7 @@ bool CpuTextureManager::textureContainsPoint(const Texture &texture, const QPoin
         // Get local position with effect transform
         QVector2D transformedCoords;
         const QVector2D localCoords(x / static_cast<float>(width), y / static_cast<float>(height));
-        EffectTransform::transformPoint(effectMask, effects, localCoords, transformedCoords);
+        EffectTransform::transformPoint(effectMask, effects, texture.size(), localCoords, transformedCoords);
         x = transformedCoords.x() * width;
         y = transformedCoords.y() * height;
     }
@@ -118,7 +135,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;
@@ -146,37 +180,134 @@ 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];
-
-    for (size_t i = 0; i < height / 2; ++i) {
-        size_t topRowIndex = i * rowSize;
-        size_t bottomRowIndex = (height - 1 - i) * rowSize;
+    std::vector<QPoint> leftHull;
+    std::vector<QPoint> rightHull;
+    leftHull.reserve(height);
+    rightHull.reserve(height);
 
-        // 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;
+                }
+            }
+        }
 
-            // Check alpha channel
-            if (pixels[index + 3] > 0)
-                hullPoints.push_back(QPoint(x, y));
+        if (x >= width)
+            continue;
+
+        while (leftEndPointIndex > 0) {
+            if (determinant(leftHull[leftEndPointIndex], leftHull[leftEndPointIndex - 1], currentPoint) > 0)
+                break;
+            else {
+                leftEndPointIndex--;
+            }
+        }
+
+        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);
diff --git a/src/cputexturemanager.h b/src/cputexturemanager.h
index 5a3ad0c..5d7c6e6 100644
--- a/src/cputexturemanager.h
+++ b/src/cputexturemanager.h
@@ -20,7 +20,12 @@ 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, 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);
@@ -28,7 +33,14 @@ class CpuTextureManager
         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;
 
         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 0a59f4b..8be1e8c 100644
--- a/src/effecttransform.cpp
+++ b/src/effecttransform.cpp
@@ -6,6 +6,16 @@
 
 using namespace scratchcpprender;
 
+// 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
@@ -99,8 +109,77 @@ QRgb EffectTransform::transformColor(ShaderManager::Effect effectMask, const std
     return inOutColor.rgba();
 }
 
-void EffectTransform::transformPoint(ShaderManager::Effect effectMask, 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 904d34e..0a4e74a 100644
--- a/src/effecttransform.h
+++ b/src/effecttransform.h
@@ -15,7 +15,7 @@ class EffectTransform
         EffectTransform() = delete;
 
         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 QVector2D &vec, QVector2D &dst);
+        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/penlayer.cpp b/src/penlayer.cpp
index 401d461..0065f7f 100644
--- a/src/penlayer.cpp
+++ b/src/penlayer.cpp
@@ -412,7 +412,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/renderedtarget.cpp b/src/renderedtarget.cpp
index 3624027..2f731ad 100644
--- a/src/renderedtarget.cpp
+++ b/src/renderedtarget.cpp
@@ -558,10 +558,14 @@ void RenderedTarget::setGraphicEffect(ShaderManager::Effect effect, double value
         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()
@@ -569,7 +573,14 @@ 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;
 }
@@ -767,8 +778,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
diff --git a/test/effecttransform/effecttransform_test.cpp b/test/effecttransform/effecttransform_test.cpp
index f50526e..d9ee128 100644
--- a/test/effecttransform/effecttransform_test.cpp
+++ b/test/effecttransform/effecttransform_test.cpp
@@ -29,7 +29,7 @@ TEST_F(EffectTransformTest, NoEffect)
     ASSERT_EQ(EffectTransform::transformColor(mask, m_effects, color), color);
 
     QVector2D dst;
-    EffectTransform::transformPoint(mask, 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));
 }
 
@@ -125,3 +125,71 @@ TEST_F(EffectTransformTest, GhostEffect)
     color = qRgba(100, 255, 200, 128);
     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/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index 59b5d41..936d91b 100644
--- a/test/renderedtarget/renderedtarget_test.cpp
+++ b/test/renderedtarget/renderedtarget_test.cpp
@@ -379,7 +379,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 }));
diff --git a/test/texture/cputexturemanager_test.cpp b/test/texture/cputexturemanager_test.cpp
index 7f4a438..93ee54a 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,14 +120,14 @@ 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
     context.doneCurrent();

From f61d346315a60173dcc0518f41ce19d6a16c3c1a Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 19:16:54 +0100
Subject: [PATCH 34/56] RenderedTarget: Add missing transformed hull dirty
 changes

---
 src/renderedtarget.cpp | 2 ++
 src/renderedtarget.h   | 2 +-
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp
index 2f731ad..d97a7c1 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();
 
@@ -814,6 +815,7 @@ const std::vector<QPointF> &RenderedTarget::transformedHullPoints() const
         m_transformedHullPoints.push_back(QPointF(x, y));
     }
 
+    m_transformedHullDirty = false;
     return m_transformedHullPoints;
 }
 
diff --git a/src/renderedtarget.h b/src/renderedtarget.h
index 54ea86c..9550762 100644
--- a/src/renderedtarget.h
+++ b/src/renderedtarget.h
@@ -174,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;

From 0efc1d851c7231d84870e6dd4c65c32bf7c0f731 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 19:17:40 +0100
Subject: [PATCH 35/56] README: Mark graphic effects as finished

---
 README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

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)
 

From 285aa887b7ec67ea337c64a34cfae4c28d39c21d Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 21:01:00 +0100
Subject: [PATCH 36/56] Do not destroy textures in Skin subclasses

Skin should already handle this.
---
 src/bitmapskin.cpp | 5 -----
 src/bitmapskin.h   | 1 -
 src/svgskin.cpp    | 6 ------
 src/svgskin.h      | 1 -
 4 files changed, 13 deletions(-)

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/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;

From 7576f5b17109ae6ceddfd4783a52835a12a80f9d Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 21:01:18 +0100
Subject: [PATCH 37/56] Skin: Fix texture deallocation

---
 src/skin.cpp | 6 ++++--
 src/skin.h   | 4 ++--
 2 files changed, 6 insertions(+), 4 deletions(-)

diff --git a/src/skin.cpp b/src/skin.cpp
index 5d25b48..ac870ec 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_destroyConnected && context) {
+        QObject::connect(context, &QOpenGLContext::aboutToBeDestroyed, []() {
             // Destroy textures
             m_textures.clear();
         });
+
+        m_destroyConnected = true;
     }
 }
 
diff --git a/src/skin.h b/src/skin.h
index 855ef65..c576db2 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 bool m_destroyConnected = false;
 };
 
 } // namespace scratchcpprender

From fd899714bbdd2bcce02fafaa4cebe2d5761e7ce7 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 22:15:05 +0100
Subject: [PATCH 38/56] Fix OpenGL context deinitialization in tests

---
 src/skin.cpp                                |  4 +-
 src/skin.h                                  |  2 +-
 test/penlayer/penlayer_test.cpp             |  1 +
 test/renderedtarget/renderedtarget_test.cpp | 56 ++++++---------------
 test/skins/bitmapskin_test.cpp              |  1 +
 test/skins/svgskin_test.cpp                 |  1 +
 6 files changed, 22 insertions(+), 43 deletions(-)

diff --git a/src/skin.cpp b/src/skin.cpp
index ac870ec..4bc87b9 100644
--- a/src/skin.cpp
+++ b/src/skin.cpp
@@ -12,13 +12,13 @@ Skin::Skin()
     QOpenGLContext *context = QOpenGLContext::currentContext();
     Q_ASSERT(context);
 
-    if (!m_destroyConnected && context) {
+    if (!m_connectedCtx || (context && context != m_connectedCtx)) {
         QObject::connect(context, &QOpenGLContext::aboutToBeDestroyed, []() {
             // Destroy textures
             m_textures.clear();
         });
 
-        m_destroyConnected = true;
+        m_connectedCtx = context;
     }
 }
 
diff --git a/src/skin.h b/src/skin.h
index c576db2..4113024 100644
--- a/src/skin.h
+++ b/src/skin.h
@@ -27,7 +27,7 @@ class Skin
 
     private:
         static inline std::vector<std::shared_ptr<QOpenGLTexture>> m_textures;
-        static inline bool m_destroyConnected = false;
+        static inline QOpenGLContext *m_connectedCtx = nullptr;
 };
 
 } // namespace scratchcpprender
diff --git a/test/penlayer/penlayer_test.cpp b/test/penlayer/penlayer_test.cpp
index aabc56c..ba963e0 100644
--- a/test/penlayer/penlayer_test.cpp
+++ b/test/penlayer/penlayer_test.cpp
@@ -35,6 +35,7 @@ class PenLayerTest : public testing::Test
         void TearDown() override
         {
             ASSERT_EQ(m_context.surface(), &m_surface);
+            emit m_context.aboutToBeDestroyed();
             m_context.doneCurrent();
         }
 
diff --git a/test/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index 936d91b..8df8ddb 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;
@@ -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));
@@ -467,9 +472,6 @@ TEST_F(RenderedTargetTest, CpuRendering)
     target.setGraphicEffect(ShaderManager::Effect::Color, 0);
 
     // TODO: Test point transform (graphic effects that change shape)
-
-    // Cleanup
-    context.doneCurrent();
 }
 
 TEST_F(RenderedTargetTest, SpriteDragging)
@@ -738,9 +740,6 @@ TEST_F(RenderedTargetTest, GraphicEffects)
 
 TEST_F(RenderedTargetTest, GetBounds)
 {
-    QOpenGLContext context;
-    QOffscreenSurface surface;
-    createContextAndSurface(&context, &surface);
     RenderedTarget target;
 
     Sprite sprite;
@@ -826,16 +825,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 +892,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 +915,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 +1061,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 +1130,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));
@@ -1330,7 +1309,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/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();
         }

From e68f02ede32fb4ddfa9f1aa26d1d55592cebe44d Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 22:15:40 +0100
Subject: [PATCH 39/56] CpuTextureManager: Use a single FBO for all textures

---
 src/cputexturemanager.cpp               | 23 +++++++++++++++++------
 src/cputexturemanager.h                 |  1 +
 test/texture/cputexturemanager_test.cpp |  3 +++
 3 files changed, 21 insertions(+), 6 deletions(-)

diff --git a/src/cputexturemanager.cpp b/src/cputexturemanager.cpp
index 8f0437e..7f711f8 100644
--- a/src/cputexturemanager.cpp
+++ b/src/cputexturemanager.cpp
@@ -164,15 +164,27 @@ bool CpuTextureManager::readTexture(
     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;
     }
 
@@ -310,7 +322,6 @@ bool CpuTextureManager::readTexture(
 
     // Cleanup
     glF.glBindFramebuffer(GL_FRAMEBUFFER, 0);
-    glF.glDeleteFramebuffers(1, &fbo);
 
     return true;
 }
diff --git a/src/cputexturemanager.h b/src/cputexturemanager.h
index 5d7c6e6..ebe047f 100644
--- a/src/cputexturemanager.h
+++ b/src/cputexturemanager.h
@@ -42,6 +42,7 @@ class CpuTextureManager
             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/test/texture/cputexturemanager_test.cpp b/test/texture/cputexturemanager_test.cpp
index 93ee54a..87daa5b 100644
--- a/test/texture/cputexturemanager_test.cpp
+++ b/test/texture/cputexturemanager_test.cpp
@@ -130,6 +130,7 @@ TEST_F(CpuTextureManagerTest, TextureDataAndHullPoints)
     ASSERT_EQ(hullPoints, refHullPoints2);
 
     // Cleanup
+    emit context.aboutToBeDestroyed();
     context.doneCurrent();
 }
 
@@ -175,6 +176,7 @@ TEST_F(CpuTextureManagerTest, GetPointColor)
     // TODO: Test point transform (graphic effects that change shape)
 
     // Cleanup
+    emit context.aboutToBeDestroyed();
     context.doneCurrent();
 }
 
@@ -219,5 +221,6 @@ TEST_F(CpuTextureManagerTest, TextureContainsPoint)
     // TODO: Test point transform (graphic effects that change shape)
 
     // Cleanup
+    emit context.aboutToBeDestroyed();
     context.doneCurrent();
 }

From c101e4c2d938b9a6636470dc65cc95797f7d9af5 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Wed, 30 Oct 2024 22:25:54 +0100
Subject: [PATCH 40/56] Add missing tests for remaining effects

---
 test/graphicseffect/graphicseffect_test.cpp | 28 ++++++++++++++++++++-
 test/renderedtarget/renderedtarget_test.cpp |  7 +++++-
 test/texture/cputexturemanager_test.cpp     |  7 +++++-
 3 files changed, 39 insertions(+), 3 deletions(-)

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/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index 8df8ddb..f2ebac0 100644
--- a/test/renderedtarget/renderedtarget_test.cpp
+++ b/test/renderedtarget/renderedtarget_test.cpp
@@ -471,7 +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)
+    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)
diff --git a/test/texture/cputexturemanager_test.cpp b/test/texture/cputexturemanager_test.cpp
index 87daa5b..af09715 100644
--- a/test/texture/cputexturemanager_test.cpp
+++ b/test/texture/cputexturemanager_test.cpp
@@ -218,7 +218,12 @@ TEST_F(CpuTextureManagerTest, TextureContainsPoint)
     ASSERT_TRUE(manager.textureContainsPoint(texture, { 3, 3 }, mask, {}));
     ASSERT_TRUE(manager.textureContainsPoint(texture, { 3.3, 3.5 }, mask, {}));
 
-    // TODO: Test point transform (graphic effects that change shape)
+    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();

From 512b15190a474caa7e710e42013560dfd1c403b9 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 19:52:26 +0100
Subject: [PATCH 41/56] RenderedTarget: Calculate position and rotation of
 hidden sprites

---
 src/renderedtarget.cpp                      | 68 ++++++++++-----------
 test/renderedtarget/renderedtarget_test.cpp |  4 +-
 2 files changed, 34 insertions(+), 38 deletions(-)

diff --git a/src/renderedtarget.cpp b/src/renderedtarget.cpp
index d97a7c1..101e236 100644
--- a/src/renderedtarget.cpp
+++ b/src/renderedtarget.cpp
@@ -683,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;
+            break;
 
-            case Sprite::RotationStyle::LeftRight: {
-                setRotation(0);
-                m_mirrorHorizontally = (m_direction < 0);
+        case Sprite::RotationStyle::LeftRight: {
+            setRotation(0);
+            m_mirrorHorizontally = (m_direction < 0);
 
-                break;
-            }
-
-            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;
 }
 
diff --git a/test/renderedtarget/renderedtarget_test.cpp b/test/renderedtarget/renderedtarget_test.cpp
index f2ebac0..4d67461 100644
--- a/test/renderedtarget/renderedtarget_test.cpp
+++ b/test/renderedtarget/renderedtarget_test.cpp
@@ -138,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();

From a103b966acf37be1ee27f3207272c40eaeeed601 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 19:53:08 +0100
Subject: [PATCH 42/56] TargetPainter: Use GL_TRIANGLES instead of
 GL_TRIANGLE_STEP

---
 src/targetpainter.cpp | 6 ++----
 1 file changed, 2 insertions(+), 4 deletions(-)

diff --git a/src/targetpainter.cpp b/src/targetpainter.cpp
index a67f10e..10f4ab6 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);
@@ -95,7 +93,7 @@ void TargetPainter::paint(QNanoPainter *painter)
     glF.glActiveTexture(GL_TEXTURE0);
     glF.glBindTexture(GL_TEXTURE_2D, texture.handle());
     shaderManager->setUniforms(shaderProgram, 0, QSize(m_target->costumeWidth(), m_target->costumeHeight()), effects); // set texture and effect uniforms
-    glF.glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
+    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

From f33e005b42933ae54e883ad9f68ca6084ac0fd78 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 19:53:51 +0100
Subject: [PATCH 43/56] Add projection and model matrices

---
 src/shaders/sprite.vert | 4 +++-
 src/targetpainter.cpp   | 2 ++
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/src/shaders/sprite.vert b/src/shaders/sprite.vert
index 08245ff..ef79415 100644
--- a/src/shaders/sprite.vert
+++ b/src/shaders/sprite.vert
@@ -1,9 +1,11 @@
+uniform mat4 u_projectionMatrix;
+uniform mat4 u_modelMatrix;
 attribute vec2 a_position;
 attribute vec2 a_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/targetpainter.cpp b/src/targetpainter.cpp
index 10f4ab6..7eb2dd7 100644
--- a/src/targetpainter.cpp
+++ b/src/targetpainter.cpp
@@ -93,6 +93,8 @@ void TargetPainter::paint(QNanoPainter *painter)
     glF.glActiveTexture(GL_TEXTURE0);
     glF.glBindTexture(GL_TEXTURE_2D, texture.handle());
     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

From 971769783c2d9f5cf537a3e6d901d608ac63dbb1 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 19:54:19 +0100
Subject: [PATCH 44/56] PenLayer: Rewrite stamp()

---
 src/penlayer.cpp                | 143 +++++++++++---------------------
 src/penlayer.h                  |   1 -
 test/penlayer/penlayer_test.cpp |   7 +-
 test/stamp.png                  | Bin 58286 -> 60694 bytes
 test/stamp_hq.png               | Bin 199344 -> 335345 bytes
 5 files changed, 54 insertions(+), 97 deletions(-)

diff --git a/src/penlayer.cpp b/src/penlayer.cpp
index 0065f7f..4b817cf 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,7 +33,7 @@ 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);
@@ -68,13 +77,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);
@@ -195,74 +200,65 @@ 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;
+    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;
 
+    libscratchcpp::Rect bounds = target->getFastBounds();
     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
@@ -271,10 +267,6 @@ void PenLayer::stamp(IRenderedTarget *target)
     m_painter->stroke();
     m_painter->endFrame();
 
-    // Create a temporary FBO for graphic effects
-    QOpenGLFramebufferObject tmpFbo(texture.size());
-    m_painter->beginFrame(tmpFbo.width(), tmpFbo.height());
-
     // Create a FBO for the current texture
     unsigned int fbo;
     m_glF->glGenFramebuffers(1, &fbo);
@@ -287,6 +279,9 @@ void PenLayer::stamp(IRenderedTarget *target)
         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,32 +293,15 @@ 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, texture.size(), 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();
+    shaderProgram->setUniformValue("u_projectionMatrix", projectionMatrix);
+    shaderProgram->setUniformValue("u_modelMatrix", modelMatrix);
+    m_glF->glDrawArrays(GL_TRIANGLES, 0, 6);
 
     // Cleanup
     shaderProgram->release();
@@ -332,27 +310,6 @@ void PenLayer::stamp(IRenderedTarget *target)
     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_textureDirty = true;
diff --git a/src/penlayer.h b/src/penlayer.h
index 2f93a9c..e904ef8 100644
--- a/src/penlayer.h
+++ b/src/penlayer.h
@@ -72,7 +72,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/test/penlayer/penlayer_test.cpp b/test/penlayer/penlayer_test.cpp
index ba963e0..6400b29 100644
--- a/test/penlayer/penlayer_test.cpp
+++ b/test/penlayer/penlayer_test.cpp
@@ -373,6 +373,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>());
@@ -406,12 +408,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.18);
+        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);
@@ -423,7 +424,7 @@ TEST_F(PenLayerTest, Stamp)
         QOpenGLFramebufferObject *fbo = penLayer.framebufferObject();
         QImage image = fbo->toImage();
         QImage ref("stamp_hq.png");
-        ASSERT_LE(fuzzyCompareImages(image, ref), 0.33);
+        ASSERT_LE(fuzzyCompareImages(image, ref), 0.42);
     }
 }
 
diff --git a/test/stamp.png b/test/stamp.png
index 7bf93058d49aacef0034ee01877d1a012a8dd906..5bdb747c75c08ccc50d64f181ab1e8179131c76c 100644
GIT binary patch
diff --git a/test/stamp_hq.png b/test/stamp_hq.png
index c32caf92847c6e1a2acfe6cbd5dd612251ea64a1..84b1f42e179a450c180c433a1a57212b06e3bbbe 100644
GIT binary patch

From 4ad39b79f8a0525e402e4483a13d99ad1ccd22c5 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 20:14:25 +0100
Subject: [PATCH 45/56] PenLayer: Do not create FBO in stamp()

---
 src/penlayer.cpp | 13 ++++++++-----
 src/penlayer.h   |  1 +
 2 files changed, 9 insertions(+), 5 deletions(-)

diff --git a/src/penlayer.cpp b/src/penlayer.cpp
index 4b817cf..6bc9c7a 100644
--- a/src/penlayer.cpp
+++ b/src/penlayer.cpp
@@ -37,6 +37,9 @@ PenLayer::~PenLayer()
         // 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);
     }
 }
 
@@ -99,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();
@@ -268,14 +274,12 @@ void PenLayer::stamp(IRenderedTarget *target)
     m_painter->endFrame();
 
     // 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;
     }
 
@@ -308,7 +312,6 @@ void PenLayer::stamp(IRenderedTarget *target)
     m_glF->glBindVertexArray(0);
     m_glF->glBindBuffer(GL_ARRAY_BUFFER, 0);
     m_glF->glBindFramebuffer(GL_FRAMEBUFFER, 0);
-    m_glF->glDeleteFramebuffers(1, &fbo);
 
     m_glF->glEnable(GL_SCISSOR_TEST);
 
diff --git a/src/penlayer.h b/src/penlayer.h
index e904ef8..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;

From d4d29803597501a29de2a6a96bc3bcc09a005727 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 20:14:43 +0100
Subject: [PATCH 46/56] Use nearest neighbor by default

---
 src/skin.cpp      |   4 ++--
 test/stamp.png    | Bin 60694 -> 57962 bytes
 test/stamp_hq.png | Bin 335345 -> 198128 bytes
 3 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/src/skin.cpp b/src/skin.cpp
index 4bc87b9..ad4c933 100644
--- a/src/skin.cpp
+++ b/src/skin.cpp
@@ -58,8 +58,8 @@ 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);
 
     return Texture(texture->textureId(), width, height);
 }
diff --git a/test/stamp.png b/test/stamp.png
index 5bdb747c75c08ccc50d64f181ab1e8179131c76c..e52d08c7429423e0a97849183e75e396d6cd6ffa 100644
GIT binary patch
diff --git a/test/stamp_hq.png b/test/stamp_hq.png
index 84b1f42e179a450c180c433a1a57212b06e3bbbe..533493d9575650d87d93c2d621bbe4daf9fcace4 100644
GIT binary patch

From f414538adff57bd06db0b3ce318e57f88d7e5406 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 20:50:34 +0100
Subject: [PATCH 47/56] PenLayer: Disable depth test in stamp()

---
 src/penlayer.cpp | 8 ++------
 1 file changed, 2 insertions(+), 6 deletions(-)

diff --git a/src/penlayer.cpp b/src/penlayer.cpp
index 6bc9c7a..bebcb0d 100644
--- a/src/penlayer.cpp
+++ b/src/penlayer.cpp
@@ -266,12 +266,7 @@ void PenLayer::stamp(IRenderedTarget *target)
     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();
+    m_glF->glDisable(GL_DEPTH_TEST);
 
     // Create a FBO for the current texture
     m_glF->glBindFramebuffer(GL_FRAMEBUFFER, m_stampFbo);
@@ -314,6 +309,7 @@ void PenLayer::stamp(IRenderedTarget *target)
     m_glF->glBindFramebuffer(GL_FRAMEBUFFER, 0);
 
     m_glF->glEnable(GL_SCISSOR_TEST);
+    m_glF->glEnable(GL_DEPTH_TEST);
 
     m_textureDirty = true;
     m_boundsDirty = true;

From f4e4cbf1318b0ef1eb7b32007213a6215de6f746 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 21:58:35 +0100
Subject: [PATCH 48/56] Skin: Clamp texture coordinates

---
 src/skin.cpp | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/src/skin.cpp b/src/skin.cpp
index ad4c933..6ac4d6b 100644
--- a/src/skin.cpp
+++ b/src/skin.cpp
@@ -60,6 +60,10 @@ Texture Skin::createAndPaintTexture(int width, int height)
     m_textures.push_back(texture);
     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);
 }

From fd8d695ad22bccaa9f258c43f45574f957da4bce Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Thu, 31 Oct 2024 22:02:55 +0100
Subject: [PATCH 49/56] Enable blending in PenLayer test

---
 test/penlayer/penlayer_test.cpp |   5 +++++
 test/stamp.png                  | Bin 57962 -> 58769 bytes
 test/stamp_hq.png               | Bin 198128 -> 201157 bytes
 3 files changed, 5 insertions(+)

diff --git a/test/penlayer/penlayer_test.cpp b/test/penlayer/penlayer_test.cpp
index 6400b29..007ef81 100644
--- a/test/penlayer/penlayer_test.cpp
+++ b/test/penlayer/penlayer_test.cpp
@@ -30,6 +30,11 @@ 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
diff --git a/test/stamp.png b/test/stamp.png
index e52d08c7429423e0a97849183e75e396d6cd6ffa..c9018b28e146f125abc9860dce6c9947eab5ff5c 100644
GIT binary patch
diff --git a/test/stamp_hq.png b/test/stamp_hq.png
index 533493d9575650d87d93c2d621bbe4daf9fcace4..ad21d34180167006bb26ea263a948377d4c3c41c 100644
GIT binary patch

From e16ef109e921e5cd6b627252c899e65982208555 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 3 Nov 2024 15:58:17 +0100
Subject: [PATCH 50/56] PenLayer: Snap bounds to int when stamping

---
 src/penlayer.cpp  |   1 +
 test/stamp.png    | Bin 58769 -> 58709 bytes
 test/stamp_hq.png | Bin 201157 -> 201154 bytes
 3 files changed, 1 insertion(+)

diff --git a/src/penlayer.cpp b/src/penlayer.cpp
index bebcb0d..ed6c46d 100644
--- a/src/penlayer.cpp
+++ b/src/penlayer.cpp
@@ -245,6 +245,7 @@ void PenLayer::stamp(IRenderedTarget *target)
     scaleY *= m_scale;
 
     libscratchcpp::Rect bounds = target->getFastBounds();
+    bounds.snapToInt();
     const Texture &texture = target->cpuTexture();
 
     if (!texture.isValid())
diff --git a/test/stamp.png b/test/stamp.png
index c9018b28e146f125abc9860dce6c9947eab5ff5c..1cc2994420f903e8000ab6162a538a3f2eac0034 100644
GIT binary patch
diff --git a/test/stamp_hq.png b/test/stamp_hq.png
index ad21d34180167006bb26ea263a948377d4c3c41c..d5b1d44f7af5f620dab30feefaf467d5e53301f4 100644
GIT binary patch

From 70d5420ff74af85f89af743ad9fec9656ba72648 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 3 Nov 2024 16:56:47 +0100
Subject: [PATCH 51/56] PenLayer: Do not stamp if out of bounds

---
 src/penlayer.cpp | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/src/penlayer.cpp b/src/penlayer.cpp
index ed6c46d..0c95759 100644
--- a/src/penlayer.cpp
+++ b/src/penlayer.cpp
@@ -213,6 +213,13 @@ void PenLayer::stamp(IRenderedTarget *target)
 
     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;
@@ -244,8 +251,6 @@ void PenLayer::stamp(IRenderedTarget *target)
     scaleX *= m_scale;
     scaleY *= m_scale;
 
-    libscratchcpp::Rect bounds = target->getFastBounds();
-    bounds.snapToInt();
     const Texture &texture = target->cpuTexture();
 
     if (!texture.isValid())

From 5bfcc1a168fb0f5103599b04092da906a4517c24 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 3 Nov 2024 16:58:20 +0100
Subject: [PATCH 52/56] CpuTextureManager: Add missing null check

---
 src/cputexturemanager.cpp | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/src/cputexturemanager.cpp b/src/cputexturemanager.cpp
index 7f711f8..3193358 100644
--- a/src/cputexturemanager.cpp
+++ b/src/cputexturemanager.cpp
@@ -116,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)

From 1a3f66d1f21b0a3a51a4e74b1772ceb92a4ff2d5 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 3 Nov 2024 17:06:39 +0100
Subject: [PATCH 53/56] ProjectPlayer: Fix text bubble active property

---
 src/ProjectPlayer.qml | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/ProjectPlayer.qml b/src/ProjectPlayer.qml
index 186ffba..1653eb2 100644
--- a/src/ProjectPlayer.qml
+++ b/src/ProjectPlayer.qml
@@ -154,7 +154,7 @@ ProjectScene {
 
         Loader {
             readonly property alias modelData: stageTarget.stageModel
-            active: model ? model.bubbleText !== "" : false
+            active: modelData ? modelData.bubbleText !== "" : false
             sourceComponent: renderedTextBubble
         }
 

From 7bda3257d29ab7f043d7606d6a1ce7cccbc8530e Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 3 Nov 2024 18:56:52 +0100
Subject: [PATCH 54/56] Update libscratchcpp to latest master

---
 libscratchcpp | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/libscratchcpp b/libscratchcpp
index d37f312..11583d1 160000
--- a/libscratchcpp
+++ b/libscratchcpp
@@ -1 +1 @@
-Subproject commit d37f31232887cb588333731b56d019cbb8ea86ae
+Subproject commit 11583d160fbc55605208da67b6a1d6622e30d06d

From d2bb2061085ac12bb2296a37e168abb2b862409c Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Sun, 3 Nov 2024 19:24:38 +0100
Subject: [PATCH 55/56] Notify about bubble layer changes

Resolves: #148
---
 src/spritemodel.cpp                     | 8 ++++++--
 src/stagemodel.cpp                      | 8 ++++++--
 test/target_models/spritemodel_test.cpp | 2 +-
 test/target_models/stagemodel_test.cpp  | 2 +-
 4 files changed, 14 insertions(+), 6 deletions(-)

diff --git a/src/spritemodel.cpp b/src/spritemodel.cpp
index ecee36a..4383022 100644
--- a/src/spritemodel.cpp
+++ b/src/spritemodel.cpp
@@ -23,7 +23,9 @@ void SpriteModel::init(libscratchcpp::Sprite *sprite)
     m_sprite = sprite;
 
     if (m_sprite) {
-        m_sprite->bubble()->typeChanged().connect([this](libscratchcpp::TextBubble::Type type) {
+        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;
@@ -39,7 +41,7 @@ void SpriteModel::init(libscratchcpp::Sprite *sprite)
             emit bubbleTypeChanged();
         });
 
-        m_sprite->bubble()->textChanged().connect([this](const std::string &text) {
+        bubble->textChanged().connect([this](const std::string &text) {
             QString newText = QString::fromStdString(text);
 
             if (m_bubbleText != newText) {
@@ -47,6 +49,8 @@ void SpriteModel::init(libscratchcpp::Sprite *sprite)
                 emit bubbleTextChanged();
             }
         });
+
+        bubble->layerOrderChanged().connect([this](int) { emit bubbleLayerChanged(); });
     }
 }
 
diff --git a/src/stagemodel.cpp b/src/stagemodel.cpp
index 987c807..26c7dff 100644
--- a/src/stagemodel.cpp
+++ b/src/stagemodel.cpp
@@ -19,7 +19,9 @@ void StageModel::init(libscratchcpp::Stage *stage)
     m_stage = stage;
 
     if (m_stage) {
-        m_stage->bubble()->typeChanged().connect([this](libscratchcpp::TextBubble::Type type) {
+        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;
@@ -35,7 +37,7 @@ void StageModel::init(libscratchcpp::Stage *stage)
             emit bubbleTypeChanged();
         });
 
-        m_stage->bubble()->textChanged().connect([this](const std::string &text) {
+        bubble->textChanged().connect([this](const std::string &text) {
             QString newText = QString::fromStdString(text);
 
             if (m_bubbleText != newText) {
@@ -43,6 +45,8 @@ void StageModel::init(libscratchcpp::Stage *stage)
                 emit bubbleTextChanged();
             }
         });
+
+        bubble->layerOrderChanged().connect([this](int) { emit bubbleLayerChanged(); });
     }
 }
 
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);
 }

From 7b1ced7b9e6edc56f961e1cdf7a9507462bc9158 Mon Sep 17 00:00:00 2001
From: adazem009 <68537469+adazem009@users.noreply.github.com>
Date: Mon, 4 Nov 2024 14:53:37 +0100
Subject: [PATCH 56/56] Update libscratchcpp to v0.12.0

---
 libscratchcpp | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/libscratchcpp b/libscratchcpp
index 11583d1..d9e0e11 160000
--- a/libscratchcpp
+++ b/libscratchcpp
@@ -1 +1 @@
-Subproject commit 11583d160fbc55605208da67b6a1d6622e30d06d
+Subproject commit d9e0e1124174a23d9f2783e756027da2f57d67b5
