Explorar o código

Optimizations and rendering improvements

Krzysztof Krysiński hai 1 mes
pai
achega
2c2bb05fd9
Modificáronse 27 ficheiros con 132 adicións e 122 borrados
  1. 4 3
      src/PixiEditor.ChangeableDocument/Changeables/Document.cs
  2. 16 0
      src/PixiEditor.ChangeableDocument/Changeables/Graph/NodeGraph.cs
  3. 4 12
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/LayerNode.cs
  4. 5 17
      src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs
  5. 3 3
      src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs
  6. 6 2
      src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs
  7. 2 4
      src/PixiEditor/Models/DocumentModels/Public/DocumentStructureModule.cs
  8. 1 0
      src/PixiEditor/Models/Handlers/INodeGraphHandler.cs
  9. 55 62
      src/PixiEditor/Models/Rendering/AffectedAreasGatherer.cs
  10. 13 18
      src/PixiEditor/Models/Rendering/MemberPreviewUpdater.cs
  11. 3 0
      src/PixiEditor/ViewModels/Document/AnimationDataViewModel.cs
  12. 6 1
      src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs
  13. 9 0
      src/PixiEditor/ViewModels/SubViewModels/DiscordViewModel.cs
  14. 5 0
      tests/PixiEditor.Tests/RenderTests.cs
  15. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingLinearSrgb.pixi
  16. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingLinearSrgb.png
  17. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingSrgb.pixi
  18. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingSrgb.png
  19. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorRectangleClippedToCircle.png
  20. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorRectangleClippedToCircleMasked.png
  21. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorRectangleClippedToCircleShadowFilter.png
  22. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilter.pixi
  23. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilter.png
  24. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterChained.pixi
  25. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterChained.png
  26. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterSrgb.pixi
  27. BIN=BIN
      tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterSrgb.png

+ 4 - 3
src/PixiEditor.ChangeableDocument/Changeables/Document.cs

@@ -50,6 +50,7 @@ internal class Document : IChangeable, IReadOnlyDocument
     public double VerticalSymmetryAxisX { get; set; }
     public bool IsDisposed { get; private set; }
 
+
     public Document()
     {
         AnimationData = new AnimationData(this);
@@ -279,14 +280,14 @@ internal class Document : IChangeable, IReadOnlyDocument
     /// <returns>The node with the given <paramref name="guid"/> or null if it doesn't exist.</returns>
     public Node? FindNode(Guid guid)
     {
-        return NodeGraph.Nodes.FirstOrDefault(x => x.Id == guid);
+        return NodeGraph.FindNode(guid);
     }
 
     IReadOnlyNode IReadOnlyDocument.FindNode(Guid guid) => FindNodeOrThrow<Node>(guid);
 
     public T? FindNode<T>(Guid guid) where T : Node
     {
-        return NodeGraph.Nodes.FirstOrDefault(x => x.Id == guid && x is T) as T;
+        return NodeGraph.FindNode<T>(guid);
     }
 
     /// <summary>
@@ -298,7 +299,7 @@ internal class Document : IChangeable, IReadOnlyDocument
     /// <returns>True if the node could be found, otherwise false.</returns>
     public bool TryFindNode<T>(Guid id, out T node) where T : Node
     {
-        node = (T?)NodeGraph.Nodes.FirstOrDefault(x => x.Id == id && x is T) ?? default;
+        node = (T?)NodeGraph.FindNode<T>(id) ?? null;
         return node != null;
     }
 

+ 16 - 0
src/PixiEditor.ChangeableDocument/Changeables/Graph/NodeGraph.cs

@@ -11,12 +11,16 @@ public class NodeGraph : IReadOnlyNodeGraph, IDisposable
     
     private readonly List<Node> _nodes = new();
     public IReadOnlyCollection<Node> Nodes => _nodes;
+    public IReadOnlyDictionary<Guid, Node> NodeLookup => nodeLookup;
     public Node? OutputNode => CustomOutputNode ?? Nodes.OfType<OutputNode>().FirstOrDefault();
     public Node? CustomOutputNode { get; set; }
 
+    private Dictionary<Guid, Node> nodeLookup = new();
+
     IReadOnlyCollection<IReadOnlyNode> IReadOnlyNodeGraph.AllNodes => Nodes;
     IReadOnlyNode IReadOnlyNodeGraph.OutputNode => OutputNode;
 
+
     public void AddNode(Node node)
     {
         if (Nodes.Contains(node))
@@ -26,6 +30,7 @@ public class NodeGraph : IReadOnlyNodeGraph, IDisposable
         
         node.ConnectionsChanged += ResetCache;
         _nodes.Add(node);
+        nodeLookup[node.Id] = node;
         ResetCache();
     }
 
@@ -38,9 +43,20 @@ public class NodeGraph : IReadOnlyNodeGraph, IDisposable
 
         node.ConnectionsChanged -= ResetCache;
         _nodes.Remove(node);
+        nodeLookup.Remove(node.Id);
         ResetCache();
     }
 
+    public Node? FindNode(Guid guid)
+    {
+        return nodeLookup.GetValueOrDefault(guid);
+    }
+
+    public T? FindNode<T>(Guid guid) where T : Node
+    {
+        return nodeLookup.TryGetValue(guid, out Node? node) && node is T typedNode ? typedNode : null;
+    }
+
     public Queue<IReadOnlyNode> CalculateExecutionQueue(IReadOnlyNode outputNode)
     {
         return new Queue<IReadOnlyNode>(CalculateExecutionQueueInternal(outputNode));

+ 4 - 12
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/LayerNode.cs

@@ -171,12 +171,11 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
         bool useFilters, Paint paint)
     {
         paint.Color = paint.Color.WithAlpha((byte)Math.Round(Opacity.Value * ctx.Opacity * 255));
-        var finalPaint = paint;
 
         var targetSurface = workingSurface;
         Texture? tex = null;
         int saved = -1;
-        if (!ctx.ProcessingColorSpace.IsSrgb)
+        if (!ctx.ProcessingColorSpace.IsSrgb && useFilters && Filters.Value != null)
         {
             saved = workingSurface.Canvas.Save();
 
@@ -185,29 +184,22 @@ public abstract class LayerNode : StructureNode, IReadOnlyLayerNode, IClipSource
             workingSurface.Canvas.SetMatrix(Matrix3X3.Identity);
 
             targetSurface = tex.DrawingSurface;
-
-            finalPaint = new Paint();
         }
 
         if (useFilters && Filters.Value != null)
         {
             paint.SetFilters(Filters.Value);
-            DrawWithFilters(ctx, targetSurface, finalPaint);
+            DrawWithFilters(ctx, targetSurface, paint);
         }
         else
         {
             paint.SetFilters(null);
-            DrawWithoutFilters(ctx, targetSurface, finalPaint);
-        }
-
-        if (finalPaint != paint)
-        {
-            finalPaint.Dispose();
+            DrawWithoutFilters(ctx, targetSurface, paint);
         }
 
         if (targetSurface != workingSurface)
         {
-            workingSurface.Canvas.DrawSurface(targetSurface, 0, 0, paint);
+            workingSurface.Canvas.DrawSurface(targetSurface, 0, 0);
             tex.Dispose();
             workingSurface.Canvas.RestoreToCount(saved);
         }

+ 5 - 17
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/VectorLayerNode.cs

@@ -50,7 +50,9 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
     IReadOnlyShapeVectorData IReadOnlyVectorNode.ShapeData => RenderableShapeData;
 
 
-    public override VecD GetScenePosition(KeyFrameTime time) => RenderableShapeData?.TransformedAABB.Center ?? VecD.Zero;
+    public override VecD GetScenePosition(KeyFrameTime time) =>
+        RenderableShapeData?.TransformedAABB.Center ?? VecD.Zero;
+
     public override VecD GetSceneSize(KeyFrameTime time) => RenderableShapeData?.TransformedAABB.Size ?? VecD.Zero;
 
     public VectorLayerNode()
@@ -130,21 +132,7 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
             return false;
         }
 
-        Matrix3X3 matrix = RenderableShapeData.TransformationMatrix;
-
-        if (!context.ProcessingColorSpace.IsSrgb)
-        {
-            int saved = renderOn.Canvas.Save();
-            using Texture tex = Texture.ForProcessing(renderOn, ColorSpace.CreateSrgb());
-            renderOn.Canvas.SetMatrix(Matrix3X3.Identity);
-            Rasterize(tex.DrawingSurface, paint);
-            renderOn.Canvas.DrawSurface(tex.DrawingSurface, 0, 0);
-            renderOn.Canvas.RestoreToCount(saved);
-        }
-        else
-        {
-            Rasterize(renderOn, paint);
-        }
+        Rasterize(renderOn, paint);
 
         return true;
     }
@@ -222,7 +210,7 @@ public class VectorLayerNode : LayerNode, ITransformableObject, IReadOnlyVectorN
             return;
         }
 
-        if(EmbeddedShapeData is IScalable resizable)
+        if (EmbeddedShapeData is IScalable resizable)
         {
             resizable.Resize(multiplier);
         }

+ 3 - 3
src/PixiEditor/Models/DocumentModels/ActionAccumulator.cs

@@ -121,6 +121,7 @@ internal class ActionAccumulator
                     toExecute.Any(static action => action.action is RefreshViewport_PassthroughAction);
                 bool changeFrameRequest =
                     toExecute.Any(static action => action.action is SetActiveFrame_PassthroughAction);
+
                 foreach (IChangeInfo info in optimizedChanges)
                 {
                     internals.Updater.ApplyChangeFromChangeInfo(info);
@@ -129,7 +130,6 @@ internal class ActionAccumulator
                 if (undoBoundaryPassed)
                     internals.Updater.AfterUndoBoundaryPassed();
 
-                // update the contents of the bitmaps
                 var affectedAreas = new AffectedAreasGatherer(document.AnimationHandler.ActiveFrameTime,
                     internals.Tracker,
                     optimizedChanges);
@@ -145,8 +145,8 @@ internal class ActionAccumulator
                 }*/
 
                 previewUpdater.UpdatePreviews(
-                    affectedAreas.ImagePreviewAreas.Keys,
-                    affectedAreas.MaskPreviewAreas.Keys,
+                    affectedAreas.ChangedMembers,
+                    affectedAreas.ChangedMasks,
                     affectedAreas.ChangedNodes, affectedAreas.ChangedKeyFrames);
 
                 // force refresh viewports for better responsiveness

+ 6 - 2
src/PixiEditor/Models/DocumentModels/DocumentUpdater.cs

@@ -395,7 +395,9 @@ internal class DocumentUpdater
         IStructureMemberHandler memberVM;
         if (info is CreateLayer_ChangeInfo layerInfo)
         {
-            memberVM = doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == info.Id) as ILayerHandler;
+            memberVM = doc.NodeGraphHandler.NodeLookup.TryGetValue(layerInfo.Id, out var node)
+                ? node as IStructureMemberHandler
+                : null;
             if (memberVM is ITransparencyLockableMember transparencyLockableMember)
             {
                 transparencyLockableMember.SetLockTransparency(layerInfo.LockTransparency);
@@ -403,7 +405,9 @@ internal class DocumentUpdater
         }
         else if (info is CreateFolder_ChangeInfo)
         {
-            memberVM = doc.NodeGraphHandler.AllNodes.FirstOrDefault(x => x.Id == info.Id) as IFolderHandler;
+            memberVM = doc.NodeGraphHandler.NodeLookup.TryGetValue(info.Id, out var node)
+                ? node as IFolderHandler
+                : null;
         }
         else
         {

+ 2 - 4
src/PixiEditor/Models/DocumentModels/Public/DocumentStructureModule.cs

@@ -1,6 +1,4 @@
-using System.Collections;
-using System.Collections.Generic;
-using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes;
 using PixiEditor.Models.Handlers;
 
 namespace PixiEditor.Models.DocumentModels.Public;
@@ -139,7 +137,7 @@ internal class DocumentStructureModule
     ///     Returns all layers in the document.
     /// </summary>
     /// <returns>List of ILayerHandlers. Empty if no layers found.</returns>
-    public List<ILayerHandler> GetAllLayers(bool includeFoldersWithMask = false)
+    public List<ILayerHandler> GetAllLayers()
     {
         List<ILayerHandler> layers = new List<ILayerHandler>();
 

+ 1 - 0
src/PixiEditor/Models/Handlers/INodeGraphHandler.cs

@@ -22,4 +22,5 @@ internal interface INodeGraphHandler
    public void RemoveConnections(Guid nodeId);
    public void UpdateAvailableRenderOutputs();
    public void RequestUpdateComputedPropertyValue(INodePropertyHandler property);
+   public IReadOnlyDictionary<Guid, INodeHandler> NodeLookup { get; }
 }

+ 55 - 62
src/PixiEditor/Models/Rendering/AffectedAreasGatherer.cs

@@ -1,4 +1,5 @@
 using System.Collections.Generic;
+using System.Diagnostics;
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
 using ChunkyImageLib.Operations;
@@ -28,13 +29,15 @@ internal class AffectedAreasGatherer
     private readonly DocumentChangeTracker tracker;
 
     public AffectedArea MainImageArea { get; private set; } = new();
-    public Dictionary<Guid, AffectedArea> ImagePreviewAreas { get; private set; } = new();
-    public Dictionary<Guid, AffectedArea> MaskPreviewAreas { get; private set; } = new();
-    public List<Guid> ChangedKeyFrames { get; private set; } = new();
-    
+    public HashSet<Guid> ChangedMembers { get; private set; } = new();
+    public HashSet<Guid> ChangedMasks { get; private set; } = new();
+    public HashSet<Guid> ChangedKeyFrames { get; private set; } = new();
+
 
     private KeyFrameTime ActiveFrame { get; set; }
-    public List<Guid> ChangedNodes { get; set; } = new();
+    public HashSet<Guid> ChangedNodes { get; set; } = new();
+
+    private bool alreadyAddedWholeCanvasToEveryImagePreview = false;
 
     public AffectedAreasGatherer(KeyFrameTime activeFrame, DocumentChangeTracker tracker,
         IReadOnlyList<IChangeInfo> changes)
@@ -42,11 +45,11 @@ internal class AffectedAreasGatherer
         this.tracker = tracker;
         ActiveFrame = activeFrame;
         ProcessChanges(changes);
-        
+
         var outputNode = tracker.Document.NodeGraph.OutputNode;
         if (outputNode is null)
             return;
-        
+
         if (tracker.Document.NodeGraph.CalculateExecutionQueue(tracker.Document.NodeGraph.OutputNode)
             .Any(x => x is ICustomShaderNode))
         {
@@ -64,20 +67,20 @@ internal class AffectedAreasGatherer
                     if (info.Area.Chunks is null)
                         throw new InvalidOperationException("Chunks must not be null");
                     AddToMainImage(info.Area);
-                    AddToImagePreviews(info.Id, info.Area, true);
-                    AddToMaskPreview(info.Id, info.Area);
+                    AddToImagePreviews(info.Id, true);
+                    AddToMaskPreview(info.Id);
                     AddToNodePreviews(info.Id);
                     break;
                 case LayerImageArea_ChangeInfo info:
                     if (info.Area.Chunks is null)
                         throw new InvalidOperationException("Chunks must not be null");
                     AddToMainImage(info.Area);
-                    AddToImagePreviews(info.Id, info.Area);
+                    AddToImagePreviews(info.Id);
                     AddToNodePreviews(info.Id);
                     break;
                 case TransformObject_ChangeInfo info:
                     AddToMainImage(info.Area);
-                    AddToImagePreviews(info.NodeGuid, info.Area);
+                    AddToImagePreviews(info.NodeGuid);
                     AddToNodePreviews(info.NodeGuid);
                     break;
                 case CreateStructureMember_ChangeInfo info:
@@ -104,7 +107,6 @@ internal class AffectedAreasGatherer
                     break;
                 case StructureMemberMask_ChangeInfo info:
                     AddWholeCanvasToMainImage();
-                    AddWholeCanvasToMaskPreview(info.Id);
                     AddWholeCanvasToImagePreviews(info.Id, true);
                     AddToNodePreviews(info.Id);
                     break;
@@ -172,7 +174,7 @@ internal class AffectedAreasGatherer
                     {
                         AddToNodePreviews(info.OutputNodeId.Value);
                     }
-                    
+
                     break;
                 case PropertyValueUpdated_ChangeInfo info:
                     AddWholeCanvasToMainImage();
@@ -187,12 +189,13 @@ internal class AffectedAreasGatherer
                     break;
                 case VectorShape_ChangeInfo info:
                     AddToMainImage(info.Affected);
-                    AddToImagePreviews(info.LayerId, info.Affected);
+                    AddToImagePreviews(info.LayerId);
                     AddToNodePreviews(info.LayerId);
                     break;
                 case ProcessingColorSpace_ChangeInfo:
                     AddWholeCanvasToMainImage();
                     AddWholeCanvasToEveryImagePreview();
+                    AddWholeCanvasToEveryMaskPreview();
                     break;
             }
         }
@@ -200,20 +203,20 @@ internal class AffectedAreasGatherer
 
     private void AddKeyFrame(Guid infoKeyFrameId)
     {
-        ChangedKeyFrames ??= new List<Guid>();
+        ChangedKeyFrames ??= new HashSet<Guid>();
         if (!ChangedKeyFrames.Contains(infoKeyFrameId))
             ChangedKeyFrames.Add(infoKeyFrameId);
     }
 
     private void AddToNodePreviews(Guid nodeId)
     {
-        ChangedNodes ??= new List<Guid>();
+        ChangedNodes ??= new HashSet<Guid>();
         if (!ChangedNodes.Contains(nodeId))
         {
             ChangedNodes.Add(nodeId);
         }
     }
-    
+
     private void AddAllNodesToImagePreviews()
     {
         foreach (var node in tracker.Document.NodeGraph.AllNodes)
@@ -234,8 +237,7 @@ internal class AffectedAreasGatherer
                 return;
             }
 
-            var chunks = result.FindAllChunks();
-            AddToImagePreviews(memberGuid, new AffectedArea(chunks), ignoreSelf);
+            AddToImagePreviews(member, ignoreSelf);
         }
         else if (member is IReadOnlyFolderNode folder)
         {
@@ -248,9 +250,7 @@ internal class AffectedAreasGatherer
             var tightBounds = genericLayerNode.GetTightBounds(frame);
             if (tightBounds is not null)
             {
-                var affectedArea = new AffectedArea(
-                    OperationHelper.FindChunksTouchingRectangle((RectI)tightBounds.Value, ChunkyImage.FullChunkSize));
-                AddToImagePreviews(memberGuid, affectedArea, ignoreSelf);
+                AddToImagePreviews(member, ignoreSelf);
             }
             else
             {
@@ -283,9 +283,9 @@ internal class AffectedAreasGatherer
             {
                 var affectedArea = new AffectedArea(
                     OperationHelper.FindChunksTouchingRectangle((RectI)tightBounds.Value, ChunkyImage.FullChunkSize));
-                
+
                 var lastArea = new AffectedArea(affectedArea);
-                
+
                 AddToMainImage(affectedArea);
             }
             else
@@ -312,7 +312,7 @@ internal class AffectedAreasGatherer
         if (member.EmbeddedMask is not null)
         {
             var chunks = member.EmbeddedMask.FindAllChunks();
-            AddToMaskPreview(memberGuid, new AffectedArea(chunks));
+            AddToMaskPreview(memberGuid);
         }
 
         if (member is IReadOnlyFolderNode folder)
@@ -330,41 +330,36 @@ internal class AffectedAreasGatherer
         MainImageArea = temp;
     }
 
-    private void AddToImagePreviews(Guid memberGuid, AffectedArea area, bool ignoreSelf = false)
+    private void AddToImagePreviews(Guid memberGuid, bool ignoreSelf = false)
     {
-        var path = tracker.Document.GetParents(memberGuid);
-        path.Insert(0, tracker.Document.FindMember(memberGuid));
-        for (int i = ignoreSelf ? 1 : 0; i < path.Count; i++)
+        var sourceMember = tracker.Document.FindMember(memberGuid);
+        if (sourceMember is null)
         {
-            var member = path[i];
-            if(member == null) continue;
-            if (!ImagePreviewAreas.ContainsKey(member.Id))
-            {
-                ImagePreviewAreas[member.Id] = new AffectedArea(area);
-            }
-            else
-            {
-                var temp = ImagePreviewAreas[member.Id];
-                temp.UnionWith(area);
-                ImagePreviewAreas[member.Id] = temp;
-            }
+            // If the member is not found, we cannot add it to previews
+            return;
         }
+
+        AddToImagePreviews(sourceMember, ignoreSelf);
     }
 
-    private void AddToMaskPreview(Guid memberGuid, AffectedArea area)
+    private void AddToImagePreviews(IReadOnlyStructureNode sourceMember, bool ignoreSelf)
     {
-        if (!MaskPreviewAreas.ContainsKey(memberGuid))
-        {
-            MaskPreviewAreas[memberGuid] = new AffectedArea(area);
-        }
-        else
+        var path = tracker.Document.GetParents(sourceMember.Id);
+        path.Insert(0, sourceMember);
+        for (int i = ignoreSelf ? 1 : 0; i < path.Count; i++)
         {
-            var temp = MaskPreviewAreas[memberGuid];
-            temp.UnionWith(area);
-            MaskPreviewAreas[memberGuid] = temp;
+            var member = path[i];
+            if (member == null) continue;
+
+            ChangedMembers.Add(member.Id);
         }
     }
 
+    private void AddToMaskPreview(Guid memberGuid)
+    {
+        ChangedMasks.Add(memberGuid);
+    }
+
 
     private void AddWholeCanvasToMainImage()
     {
@@ -380,23 +375,19 @@ internal class AffectedAreasGatherer
         for (int i = ignoreSelf ? 1 : 0; i < path.Count; i++)
         {
             var member = path[i];
-            if (!ImagePreviewAreas.ContainsKey(member.Id))
-                ImagePreviewAreas[member.Id] = new AffectedArea();
-            ImagePreviewAreas[member.Id] = AddWholeArea(ImagePreviewAreas[member.Id]);
-        }
-    }
+            if (member is null) continue;
 
-    private void AddWholeCanvasToMaskPreview(Guid memberGuid)
-    {
-        if (!MaskPreviewAreas.ContainsKey(memberGuid))
-            MaskPreviewAreas[memberGuid] = new AffectedArea();
-        MaskPreviewAreas[memberGuid] = AddWholeArea(MaskPreviewAreas[memberGuid]);
+            ChangedMembers.Add(member.Id);
+        }
     }
 
-
     private void AddWholeCanvasToEveryImagePreview()
     {
+        if (alreadyAddedWholeCanvasToEveryImagePreview)
+            return;
+
         tracker.Document.ForEveryReadonlyMember((member) => AddWholeCanvasToImagePreviews(member.Id));
+        alreadyAddedWholeCanvasToEveryImagePreview = true;
     }
 
     private void AddWholeCanvasToEveryMaskPreview()
@@ -404,7 +395,9 @@ internal class AffectedAreasGatherer
         tracker.Document.ForEveryReadonlyMember((member) =>
         {
             if (member.EmbeddedMask is not null)
-                AddWholeCanvasToMaskPreview(member.Id);
+            {
+                ChangedMasks.Add(member.Id);
+            }
         });
     }
 

+ 13 - 18
src/PixiEditor/Models/Rendering/MemberPreviewUpdater.cs

@@ -25,8 +25,8 @@ internal class MemberPreviewUpdater
         AnimationKeyFramePreviewRenderer = new AnimationKeyFramePreviewRenderer(internals);
     }
 
-    public void UpdatePreviews(IEnumerable<Guid> membersToUpdate,
-        IEnumerable<Guid> masksToUpdate, IEnumerable<Guid> nodesToUpdate, IEnumerable<Guid> keyFramesToUpdate)
+    public void UpdatePreviews(HashSet<Guid> membersToUpdate,
+        HashSet<Guid> masksToUpdate, HashSet<Guid> nodesToUpdate, HashSet<Guid> keyFramesToUpdate)
     {
         if (!membersToUpdate.Any() && !masksToUpdate.Any() && !nodesToUpdate.Any() &&
             !keyFramesToUpdate.Any())
@@ -40,21 +40,16 @@ internal class MemberPreviewUpdater
     /// </summary>
     /// <param name="members">Members that should be rendered</param>
     /// <param name="masksToUpdate">Masks that should be rendered</param>
-    private void UpdatePreviewPainters(IEnumerable<Guid> members, IEnumerable<Guid> masksToUpdate,
-        IEnumerable<Guid> nodesToUpdate, IEnumerable<Guid> keyFramesToUpdate)
+    private void UpdatePreviewPainters(HashSet<Guid> members, HashSet<Guid> masksToUpdate,
+        HashSet<Guid> nodesToUpdate, HashSet<Guid> keyFramesToUpdate)
     {
-        Guid[] memberGuids = members as Guid[] ?? members.ToArray();
-        Guid[] maskGuids = masksToUpdate as Guid[] ?? masksToUpdate.ToArray();
-        Guid[] nodesGuids = nodesToUpdate as Guid[] ?? nodesToUpdate.ToArray();
-        Guid[] keyFramesGuids = keyFramesToUpdate as Guid[] ?? keyFramesToUpdate.ToArray();
-
         RenderWholeCanvasPreview();
-        RenderLayersPreview(memberGuids);
-        RenderMaskPreviews(maskGuids);
+        RenderLayersPreview(members);
+        RenderMaskPreviews(masksToUpdate);
 
-        RenderAnimationPreviews(memberGuids, keyFramesGuids);
+        RenderAnimationPreviews(members, keyFramesToUpdate);
 
-        RenderNodePreviews(nodesGuids);
+        RenderNodePreviews(nodesToUpdate);
     }
 
     /// <summary>
@@ -77,7 +72,7 @@ internal class MemberPreviewUpdater
         doc.PreviewPainter.Repaint();
     }
 
-    private void RenderLayersPreview(Guid[] memberGuids)
+    private void RenderLayersPreview(HashSet<Guid> memberGuids)
     {
         foreach (var node in doc.NodeGraphHandler.AllNodes)
         {
@@ -111,7 +106,7 @@ internal class MemberPreviewUpdater
         }
     }
 
-    private void RenderAnimationPreviews(Guid[] memberGuids, Guid[] keyFramesGuids)
+    private void RenderAnimationPreviews(HashSet<Guid> memberGuids, HashSet<Guid> keyFramesGuids)
     {
         foreach (var keyFrame in doc.AnimationHandler.KeyFrames)
         {
@@ -191,7 +186,7 @@ internal class MemberPreviewUpdater
         }
     }
 
-    private void RenderMaskPreviews(Guid[] members)
+    private void RenderMaskPreviews(HashSet<Guid> members)
     {
         foreach (var node in doc.NodeGraphHandler.AllNodes)
         {
@@ -227,7 +222,7 @@ internal class MemberPreviewUpdater
         }
     }
 
-    private void RenderNodePreviews(Guid[] nodesGuids)
+    private void RenderNodePreviews(HashSet<Guid> nodesGuids)
     {
         var outputNode = internals.Tracker.Document.NodeGraph.OutputNode;
 
@@ -238,7 +233,7 @@ internal class MemberPreviewUpdater
             internals.Tracker.Document.NodeGraph
                 .AllNodes; //internals.Tracker.Document.NodeGraph.CalculateExecutionQueue(outputNode);
 
-        if (nodesGuids.Length == 0)
+        if (nodesGuids.Count == 0)
             return;
 
         List<Guid> actualRepaintedNodes = new();

+ 3 - 0
src/PixiEditor/ViewModels/Document/AnimationDataViewModel.cs

@@ -397,6 +397,9 @@ internal class AnimationDataViewModel : ObservableObject, IAnimationHandler
 
     public void SortByLayers()
     {
+        if (keyFrames.Count < 2)
+            return;
+
         var allLayers = Document.StructureHelper.GetAllLayers();
 
         if (!OrderDifferent(keyFrames, allLayers)) return;

+ 6 - 1
src/PixiEditor/ViewModels/Document/NodeGraphViewModel.cs

@@ -26,9 +26,12 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposabl
     public ObservableCollection<string> AvailableRenderOutputs { get; } = new();
     public StructureTree StructureTree { get; } = new();
     public INodeHandler? OutputNode { get; private set; }
-
     public Dictionary<string, INodeHandler> CustomRenderOutputs { get; } = new();
 
+    public Dictionary<Guid, INodeHandler> NodeLookup { get; } = new();
+
+    IReadOnlyDictionary<Guid, INodeHandler> INodeGraphHandler.NodeLookup => NodeLookup;
+
     private DocumentInternalParts Internals { get; }
 
     public NodeGraphViewModel(DocumentViewModel documentViewModel, DocumentInternalParts internals)
@@ -47,6 +50,7 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposabl
 
         AllNodes.Add(node);
         StructureTree.Update(this);
+        NodeLookup[node.Id] = node;
         UpdateAvailableRenderOutputs();
     }
 
@@ -60,6 +64,7 @@ internal class NodeGraphViewModel : ViewModelBase, INodeGraphHandler, IDisposabl
         }
 
         StructureTree.Update(this);
+        NodeLookup.Remove(nodeId);
         UpdateAvailableRenderOutputs();
     }
 

+ 9 - 0
src/PixiEditor/ViewModels/SubViewModels/DiscordViewModel.cs

@@ -10,10 +10,13 @@ namespace PixiEditor.ViewModels.SubViewModels;
 
 internal class DiscordViewModel : SubViewModel<ViewModelMain>, IDisposable
 {
+    public const double MinUpdateInterval = 5.0; // seconds
     private DiscordRpcClient client;
     private string clientId;
     private DocumentViewModel currentDocument;
 
+    private DateTime lastUpdate = DateTime.MinValue;
+
     public bool Enabled
     {
         get => client != null;
@@ -65,6 +68,11 @@ internal class DiscordViewModel : SubViewModel<ViewModelMain>, IDisposable
             return;
         }
 
+        if(lastUpdate != DateTime.MinValue && (DateTime.UtcNow - lastUpdate).TotalSeconds < MinUpdateInterval)
+        {
+            return; // Prevent too frequent updates
+        }
+
         RichPresence richPresence = NewDefaultRP();
 
         if (document != null)
@@ -96,6 +104,7 @@ internal class DiscordViewModel : SubViewModel<ViewModelMain>, IDisposable
         }
 
         client.SetPresence(richPresence);
+        lastUpdate = DateTime.UtcNow;
     }
 
     private int CountLayers(NodeGraphViewModel graph)

+ 5 - 0
tests/PixiEditor.Tests/RenderTests.cs

@@ -30,6 +30,11 @@ public class RenderTests : FullPixiEditorTest
     [InlineData("VectorRectangleClippedToCircle")]
     [InlineData("VectorRectangleClippedToCircleShadowFilter")]
     [InlineData("VectorRectangleClippedToCircleMasked")]
+    [InlineData("BlendingLinearSrgb")]
+    [InlineData("BlendingSrgb")]
+    [InlineData("VectorWithSepiaFilter")]
+    [InlineData("VectorWithSepiaFilterSrgb")]
+    [InlineData("VectorWithSepiaFilterChained")]
     public void TestThatPixiFilesRenderTheSameResultAsSavedPng(string fileName)
     {
         if (!DrawingBackendApi.Current.IsHardwareAccelerated)

BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingLinearSrgb.pixi


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingLinearSrgb.png


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingSrgb.pixi


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/BlendingSrgb.png


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorRectangleClippedToCircle.png


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorRectangleClippedToCircleMasked.png


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorRectangleClippedToCircleShadowFilter.png


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilter.pixi


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilter.png


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterChained.pixi


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterChained.png


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterSrgb.pixi


BIN=BIN
tests/PixiEditor.Tests/TestFiles/RenderTests/VectorWithSepiaFilterSrgb.png