瀏覽代碼

Merge branch 'brush-engine' of https://github.com/PixiEditor/PixiEditor into brush-engine

flabbet 1 周之前
父節點
當前提交
7da198898e

+ 1 - 1
samples/Directory.Build.props

@@ -1,7 +1,7 @@
 <Project>
     <PropertyGroup>
         <CodeAnalysisRuleSet>../Custom.ruleset</CodeAnalysisRuleSet>
-		    <AvaloniaVersion>11.3.6</AvaloniaVersion>
+		    <AvaloniaVersion>11.3.9-cibuild0004033-alpha</AvaloniaVersion>
     </PropertyGroup>
     <ItemGroup>
         <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" />

+ 15 - 0
src/ChunkyImageLib/ChunkyImage.cs

@@ -11,6 +11,7 @@ using Drawie.Backend.Core.Bridge;
 using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.ColorsImpl.Paintables;
 using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Shaders;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
@@ -935,6 +936,20 @@ public class ChunkyImage : IReadOnlyChunkyImage, IDisposable, ICloneable, ICache
         }
     }
 
+    /// <param name="customBounds">Bounds used for affected chunks, will be computed from path in O(n) if null is passed</param>
+    /// <exception cref="ObjectDisposedException">This image is disposed</exception>
+    public void EnqueueDrawPath(VectorPath path, Paintable paintable, float strokeWidth, StrokeCap strokeCap,
+        Blender blender, PaintStyle style, bool antiAliasing, RectI? customBounds = null)
+    {
+        lock (lockObject)
+        {
+            ThrowIfDisposed();
+            PathOperation operation = new(path, paintable, strokeWidth, strokeCap, blender, style, antiAliasing,
+                customBounds);
+            EnqueueOperation(operation);
+        }
+    }
+
     /// <exception cref="ObjectDisposedException">This image is disposed</exception>
     public void EnqueueDrawBresenhamLine(VecI from, VecI to, Paintable paintable, BlendMode blendMode)
     {

+ 34 - 2
src/ChunkyImageLib/Operations/PathOperation.cs

@@ -2,6 +2,7 @@
 using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.ColorsImpl.Paintables;
 using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Shaders;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Backend.Core.Vector;
@@ -28,6 +29,15 @@ internal class PathOperation : IMirroredDrawOperation
         bounds = floatBounds.Inflate((int)Math.Ceiling(strokeWidth) + 1);
     }
 
+    public PathOperation(VectorPath path, Color color, float strokeWidth, StrokeCap cap, Blender blender, RectI? customBounds = null)
+    {
+        this.path = new VectorPath(path);
+        paint = new() { Color = color, Style = PaintStyle.Stroke, StrokeWidth = strokeWidth, StrokeCap = cap, Blender = blender };
+
+        RectI floatBounds = customBounds ?? (RectI)(path.TightBounds).RoundOutwards();
+        bounds = floatBounds.Inflate((int)Math.Ceiling(strokeWidth) + 1);
+    }
+
     public PathOperation(VectorPath path, Paintable paintable, float strokeWidth, StrokeCap cap, BlendMode blendMode,
         PaintStyle style, bool antiAliasing, RectI? customBounds = null)
     {
@@ -39,6 +49,16 @@ internal class PathOperation : IMirroredDrawOperation
         bounds = floatBounds.Inflate((int)Math.Ceiling(strokeWidth) + 1);
     }
 
+    public PathOperation(VectorPath path, Paintable paintable, float strokeWidth, StrokeCap cap, Blender blender, PaintStyle style, bool antiAliasing, RectI? customBounds)
+    {
+        this.antiAliasing = antiAliasing;
+        this.path = new VectorPath(path);
+        paint = new() { Paintable = paintable, Style = style, StrokeWidth = strokeWidth, StrokeCap = cap, Blender = blender };
+
+        RectI floatBounds = customBounds ?? (RectI)(path.Bounds).RoundOutwards();
+        bounds = floatBounds.Inflate((int)Math.Ceiling(strokeWidth) + 1);
+    }
+
     public void DrawOnChunk(Chunk targetChunk, VecI chunkPos)
     {
         paint.IsAntiAliased = antiAliasing || targetChunk.Resolution != ChunkResolution.Full;
@@ -68,8 +88,20 @@ internal class PathOperation : IMirroredDrawOperation
             newBounds = (RectI)newBounds.ReflectY((double)horAxisY).Round();
         if (paint.Paintable != null)
         {
-            return new PathOperation(copy, paint.Paintable, paint.StrokeWidth, paint.StrokeCap, paint.BlendMode,
-                paint.Style, antiAliasing, newBounds);
+            if( paint.Blender != null)
+            {
+                return new PathOperation(copy, paint.Paintable, paint.StrokeWidth, paint.StrokeCap, paint.Blender, paint.Style, antiAliasing, newBounds);
+            }
+            else
+            {
+                return new PathOperation(copy, paint.Paintable, paint.StrokeWidth, paint.StrokeCap, paint.BlendMode,
+                    paint.Style, antiAliasing, newBounds);
+            }
+        }
+
+        if (paint.Blender != null)
+        {
+            return new PathOperation(copy, paint.Color, paint.StrokeWidth, paint.StrokeCap, paint.Blender, newBounds);
         }
 
         return new PathOperation(copy, paint.Color, paint.StrokeWidth, paint.StrokeCap, paint.BlendMode, newBounds);

+ 1 - 1
src/Drawie

@@ -1 +1 @@
-Subproject commit e95271836953c7a9dbc0f3b2ee02f732f670e3a5
+Subproject commit bc83f2b962f7e2ff4140bda8dd421aa2309f2488

+ 46 - 10
src/PixiEditor.ChangeableDocument/Changeables/Brushes/BrushEngine.cs

@@ -4,6 +4,7 @@ using Drawie.Backend.Core;
 using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.ColorsImpl.Paintables;
 using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Shaders;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
@@ -42,6 +43,10 @@ public class BrushEngine : IDisposable
     public void ResetState()
     {
         lastAppliedPointIndex = -1;
+        lastAppliedHistoryIndex = -1;
+        lastPos = VecD.Zero;
+        lastPressure = 1.0;
+        startPos = VecD.Zero;
         drawnOnce = false;
         pointsHistory.Clear();
     }
@@ -142,21 +147,26 @@ public class BrushEngine : IDisposable
 
         float strokeWidth = brushData.StrokeWidth;
         float spacing = brushNode.Spacing.Value / 100f;
+        int startingIndex = Math.Max(lastAppliedHistoryIndex, 0);
+        float spacingPressure = pointsHistory.Count < startingIndex + 1
+            ? (float)lastPressure
+            : pointsHistory[startingIndex].PointerInfo.Pressure;
 
         for (int i = Math.Max(lastAppliedHistoryIndex, 0); i < pointsHistory.Count; i++)
         {
             var point = pointsHistory[i];
 
-            float spacingPixels = (strokeWidth * point.PointerInfo.Pressure) * spacing;
+            float spacingPixels = (strokeWidth * spacingPressure) * spacing;
             if (VecD.Distance(lastPos, point.Position) < spacingPixels)
                 continue;
-            
 
             ExecuteVectorShapeBrush(target, brushNode, brushData, point.Position, frameTime, cs, samplingOptions,
                 point.PointerInfo,
                 point.KeyboardInfo,
                 point.EditorData);
 
+            spacingPressure = brushNode.Pressure.Value;
+
             lastPos = point.Position;
         }
 
@@ -315,9 +325,11 @@ public class BrushEngine : IDisposable
         var stroke = brushNode.Stroke.Value;
         bool snapToPixels = brushNode.SnapToPixels.Value;
         bool canReuseStamps = brushNode.CanReuseStamps.Value;
+        Blender? stampBlender = brushNode.UseCustomStampBlender.Value ? brushNode.LastStampBlender : null;
+        //Blender? imageBlender = brushNode.UseCustomImageBlender.Value ? brushNode.LastImageBlender : null;
 
         if (PaintBrush(target, autoPosition, vectorShape, rect, fitToStrokeSize, pressure, content, contentTexture,
-                brushNode.StampBlendMode.Value, antiAliasing, fill, stroke, snapToPixels, canReuseStamps))
+                stampBlender, brushNode.StampBlendMode.Value, antiAliasing, fill, stroke, snapToPixels, canReuseStamps))
         {
             lastPos = point;
         }
@@ -325,7 +337,7 @@ public class BrushEngine : IDisposable
 
     public bool PaintBrush(ChunkyImage target, bool autoPosition, ShapeVectorData vectorShape,
         RectD rect, bool fitToStrokeSize, float pressure, Painter? content,
-        Texture? contentTexture, DrawingApiBlendMode blendMode, bool antiAliasing, Paintable fill, Paintable stroke,
+        Texture? contentTexture, Blender? blender, DrawingApiBlendMode blendMode, bool antiAliasing, Paintable fill, Paintable stroke,
         bool snapToPixels, bool canReuseStamps)
     {
         var path = vectorShape.ToPath(true);
@@ -358,15 +370,31 @@ public class BrushEngine : IDisposable
 
         if (paintable is { AnythingVisible: true })
         {
-            target.EnqueueDrawPath(path, paintable, vectorShape.StrokeWidth,
-                strokeCap, blendMode, strokeStyle, antiAliasing, null);
+            if (blender != null)
+            {
+                target.EnqueueDrawPath(path, paintable, vectorShape.StrokeWidth,
+                    strokeCap, blender, strokeStyle, antiAliasing, null);
+            }
+            else
+            {
+                target.EnqueueDrawPath(path, paintable, vectorShape.StrokeWidth,
+                    strokeCap, blendMode, strokeStyle, antiAliasing, null);
+            }
         }
 
         if (fill is { AnythingVisible: true } && stroke is { AnythingVisible: true })
         {
             strokeStyle = PaintStyle.Stroke;
-            target.EnqueueDrawPath(path, stroke, vectorShape.StrokeWidth,
-                strokeCap, blendMode, strokeStyle, antiAliasing, null);
+            if (blender != null)
+            {
+                target.EnqueueDrawPath(path, stroke, vectorShape.StrokeWidth,
+                    strokeCap, blender, strokeStyle, antiAliasing, null);
+            }
+            else
+            {
+                target.EnqueueDrawPath(path, stroke, vectorShape.StrokeWidth,
+                    strokeCap, blendMode, strokeStyle, antiAliasing, null);
+            }
         }
 
         if (content != null)
@@ -390,8 +418,16 @@ public class BrushEngine : IDisposable
                     brushPaintable = new TexturePaintable(new Texture(contentTexture), true);
                 }
 
-                target.EnqueueDrawPath(path, brushPaintable, vectorShape.StrokeWidth,
-                    StrokeCap.Butt, blendMode, PaintStyle.Fill, antiAliasing, null);
+                if (blender != null)
+                {
+                    target.EnqueueDrawPath(path, brushPaintable, vectorShape.StrokeWidth,
+                        StrokeCap.Butt, blender, PaintStyle.Fill, antiAliasing, null);
+                }
+                else
+                {
+                    target.EnqueueDrawPath(path, brushPaintable, vectorShape.StrokeWidth,
+                        StrokeCap.Butt, blendMode, PaintStyle.Fill, antiAliasing, null);
+                }
             }
         }
 

+ 74 - 4
src/PixiEditor.ChangeableDocument/Changeables/Graph/Nodes/Brushes/BrushOutputNode.cs

@@ -3,6 +3,7 @@ using Drawie.Backend.Core;
 using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.ColorsImpl.Paintables;
 using Drawie.Backend.Core.Numerics;
+using Drawie.Backend.Core.Shaders;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Backend.Core.Surfaces.PaintImpl;
@@ -31,6 +32,21 @@ public class BrushOutputNode : Node
     public const int StrokePreviewSizeX = 200;
     public const int StrokePreviewSizeY = 50;
 
+    public const string DefaultBlenderCode = @"
+    vec4 main(vec4 src, vec4 dst) {
+    	return src + (1 - src.a) * dst;
+    }
+";
+
+    private string? lastStampBlenderCode = "";
+    private string? lastImageBlenderCode = "";
+
+    public Blender? LastStampBlender => cachedStampBlender;
+    public Blender? LastImageBlender => cachedImageBlender;
+
+    private Blender? cachedStampBlender = null;
+    private Blender? cachedImageBlender = null;
+
     public InputProperty<string> BrushName { get; }
     public InputProperty<ShapeVectorData> VectorShape { get; }
     public InputProperty<Paintable> Stroke { get; }
@@ -38,6 +54,8 @@ public class BrushOutputNode : Node
     public RenderInputProperty Content { get; }
     public InputProperty<Drawie.Backend.Core.Surfaces.BlendMode> StampBlendMode { get; }
     public InputProperty<Drawie.Backend.Core.Surfaces.BlendMode> ImageBlendMode { get; }
+    public InputProperty<bool> UseCustomStampBlender { get; }
+    public InputProperty<string> CustomStampBlenderCode { get; }
     public InputProperty<Matrix3X3> Transform { get; }
     public InputProperty<float> Pressure { get; }
     public InputProperty<float> Spacing { get; }
@@ -59,7 +77,7 @@ public class BrushOutputNode : Node
     private TextureCache cache = new();
 
     private ChunkyImage? previewChunkyImage;
-    private BrushEngine previewEngine = new BrushEngine() { PressureSmoothingWindowSize = 0};
+    private BrushEngine previewEngine = new BrushEngine() { PressureSmoothingWindowSize = 0 };
 
     protected override bool ExecuteOnlyOnCacheChange => true;
     public Guid PersistentId { get; private set; } = Guid.NewGuid();
@@ -68,6 +86,9 @@ public class BrushOutputNode : Node
         "M0.25 99.4606C0.25 99.4606 60.5709 79.3294 101.717 99.4606C147.825 122.019 199.75 99.4606 199.75 99.4606";
 
     public const int YOffsetInPreview = -88;
+    public const string UseCustomStampBlenderProperty = "UseCustomStampBlender";
+    public const string CustomStampBlenderCodeProperty = "CustomStampBlender";
+    public const string StampBlendModeProperty = "StampBlendMode";
 
     private VectorPath? previewVectorPath;
 
@@ -81,8 +102,14 @@ public class BrushOutputNode : Node
         Transform = CreateInput<Matrix3X3>("Transform", "TRANSFORM", Matrix3X3.Identity);
         ImageBlendMode = CreateInput<Drawie.Backend.Core.Surfaces.BlendMode>("BlendMode", "BLEND_MODE",
             Drawie.Backend.Core.Surfaces.BlendMode.SrcOver);
-        StampBlendMode = CreateInput<Drawie.Backend.Core.Surfaces.BlendMode>("StampBlendMode", "STAMP_BLEND_MODE",
+        StampBlendMode = CreateInput<Drawie.Backend.Core.Surfaces.BlendMode>(StampBlendModeProperty, "STAMP_BLEND_MODE",
             Drawie.Backend.Core.Surfaces.BlendMode.SrcOver);
+
+        UseCustomStampBlender = CreateInput<bool>(UseCustomStampBlenderProperty, "USE_CUSTOM_STAMP_BLENDER", false);
+
+        CustomStampBlenderCode =
+            CreateInput<string>(CustomStampBlenderCodeProperty, "CUSTOM_STAMP_BLENDER_CODE", DefaultBlenderCode)
+                .WithRules(validator => validator.Custom(ValidateBlenderCode));
         CanReuseStamps = CreateInput<bool>("CanReuseStamps", "CAN_REUSE_STAMPS", false);
 
         Pressure = CreateInput<float>("Pressure", "PRESSURE", 1f);
@@ -96,6 +123,23 @@ public class BrushOutputNode : Node
         Previous = CreateInput<IReadOnlyNodeGraph>("Previous", "PREVIOUS", null);
     }
 
+    private ValidatorResult ValidateBlenderCode(object? value)
+    {
+        if (value is string code)
+        {
+            Blender? blender = Blender.CreateFromString(code, out string? error);
+            if (blender != null)
+            {
+                blender.Dispose();
+                return new ValidatorResult(true, null);
+            }
+
+            return new ValidatorResult(false, error);
+        }
+
+        return new ValidatorResult(false, "Blender code must be a string.");
+    }
+
     protected override void OnExecute(RenderContext context)
     {
         if (Content.Value != null)
@@ -110,6 +154,22 @@ public class BrushOutputNode : Node
             }
         }
 
+        if (UseCustomStampBlender.Value)
+        {
+            if (CustomStampBlenderCode.Value != lastStampBlenderCode || cachedStampBlender == null)
+            {
+                cachedStampBlender?.Dispose();
+                cachedStampBlender = Blender.CreateFromString(CustomStampBlenderCode.Value, out _);
+                lastStampBlenderCode = CustomStampBlenderCode.Value;
+            }
+        }
+        else
+        {
+            cachedStampBlender?.Dispose();
+            cachedStampBlender = null;
+            lastStampBlenderCode = "";
+        }
+
         RenderPreviews(context.GetPreviewTexturesForNode(Id), context);
     }
 
@@ -180,6 +240,8 @@ public class BrushOutputNode : Node
         const int spacing = 10;
         const int marginEdges = 30;
         VecD pos = VecD.Zero;
+        previewEngine.ResetState();
+
         for (var i = 0; i < sizes.Length; i++)
         {
             var size = sizes[i];
@@ -193,6 +255,7 @@ public class BrushOutputNode : Node
                 new KeyboardInfo(),
                 new EditorData(Colors.White, Colors.Black));
         }
+        previewChunkyImage.CommitChanges();
 
         DrawStrokePreview(previewChunkyImage, context, maxSize);
 
@@ -212,18 +275,21 @@ public class BrushOutputNode : Node
         float pressure;
         VecD pos;
         List<RecordedPoint> points = new();
+        previewEngine.ResetState();
+
         while (offset <= target.CommittedSize.X)
         {
             pressure = (float)Math.Sin((offset / target.CommittedSize.X) * Math.PI);
             var vec4D = previewVectorPath.GetPositionAndTangentAtDistance(offset, false);
             pos = vec4D.XY;
             pos = new VecD(pos.X, pos.Y + maxSize / 2f) + shift;
-            
+
             points.Add(new RecordedPoint((VecI)pos, new PointerInfo(pos, pressure, 0, VecD.Zero, vec4D.ZW),
                 new KeyboardInfo(), new EditorData(Colors.White, Colors.Black)));
 
             previewEngine.ExecuteBrush(target,
-                new BrushData(context.Graph, Id) { StrokeWidth = maxSize, AntiAliasing = true }, points, context.FrameTime,
+                new BrushData(context.Graph, Id) { StrokeWidth = maxSize, AntiAliasing = true }, points,
+                context.FrameTime,
                 context.ProcessingColorSpace, context.DesiredSamplingOptions);
             offset += 1;
         }
@@ -236,11 +302,14 @@ public class BrushOutputNode : Node
         {
             previewVectorPath = VectorPath.FromSvgPath(PreviewSvg);
         }
+
         List<RecordedPoint> points = new();
 
         float offset = 0;
         float pressure;
         VecD pos;
+        previewEngine.ResetState();
+
         while (offset <= target.CommittedSize.X)
         {
             pressure = (float)Math.Sin((offset / target.CommittedSize.X) * Math.PI);
@@ -260,6 +329,7 @@ public class BrushOutputNode : Node
 
     public void DrawPointPreview(ChunkyImage img, RenderContext context, int size, VecD pos)
     {
+        previewEngine.ResetState();
         previewEngine.ExecuteBrush(img,
             new BrushData(context.Graph, Id) { StrokeWidth = size, AntiAliasing = true },
             pos, context.FrameTime, context.ProcessingColorSpace, context.DesiredSamplingOptions,

二進制
src/PixiEditor/Data/Brushes/BasicFlowOpacity.pixi


二進制
src/PixiEditor/Data/Brushes/Texture.pixi


+ 17 - 5
src/PixiEditor/Data/Localization/Languages/en.json

@@ -988,7 +988,6 @@
   "DOWNLOAD_UPDATE": "Download",
   "DOWNLOADING_UPDATE": "Downloading update...",
   "CHECKING_FOR_UPDATES": "Checking for updates...",
-  "PAINT_SHAPE_SETTING": "Brush shape",
   "BOOL_OPERATION_NODE": "Boolean Operation",
   "FIRST_SHAPE": "First shape",
   "SECOND_SHAPE": "Second shape",
@@ -1150,8 +1149,6 @@
   "TOGGLE_TINTING_SELECTION": "Toggle selection tinting",
   "TOGGLE_TINTING_SELECTION_DESCRIPTIVE": "Toggle selection tinting",
   "TINT_SELECTION": "Selection tinting",
-  "PAINT_BRUSH_SHAPE_CIRCLE": "Circle",
-  "PAINT_BRUSH_SHAPE_SQUARE": "Square",
   "BRIGHTNESS_MODE_DEFAULT": "Default",
   "BRIGHTNESS_MODE_REPEAT": "Repeat",
   "ROUND_STROKE_CAP": "Round",
@@ -1207,7 +1204,6 @@
   "BRUSH": "Brush",
   "UNLINK": "Unlink",
   "UNLINK_DESCRIPTIVE": "Unlink this layer from the original document",
-  "CANNOT_SAVE_NESTED_DOCUMENT_PARENT_CLOSED": "Cannot save nested document because the parent document is not open.",
   "INPUTS": "Inputs",
   "BRUSHES": "Brushes",
   "EDITOR": "Editor",
@@ -1266,5 +1262,21 @@
   "TEXTURE": "Texture",
   "PAINTER": "Painter",
   "VECTOR_PATH": "Vector Path",
-  "CAN_REUSE_STAMPS": "Can Reuse Stamps"
+  "CAN_REUSE_STAMPS": "Can Reuse Stamps",
+  "USE_CUSTOM_STAMP_BLENDER": "Use Custom Stamp Blender",
+  "CUSTOM_STAMP_BLENDER_CODE": "Stamp Blender",
+  "CLEAR_BLEND_MODE": "Clear",
+  "REPLACE_BLEND_MODE": "Replace",
+  "BEHIND_BLEND_MODE": "Behind",
+  "MASK_INSIDE_BLEND_MODE": "Mask Inside",
+  "KEEP_INSIDE_BLEND_MODE": "Keep Inside",
+  "OUTSIDE_BLEND_MODE": "Outside",
+  "ON_TOP_OF_BLEND_MODE": "On Top Of",
+  "KEEP_UNDER_BLEND_MODE": "Keep Under",
+  "XOR_BLEND_MODE": "XOR",
+  "MODULATE_BLEND_MODE": "Modulate",
+  "TARGET_BLEND_MODE": "Target",
+  "LAST_APPLIED_POINT": "Last Applied Point",
+  "VIEWPORT_INFO_NODE": "Viewport Info",
+  "EQUALS_NODE": "Equals"
 }

+ 14 - 0
src/PixiEditor/Helpers/LocalizedKeyAttribute.cs

@@ -0,0 +1,14 @@
+namespace PixiEditor.Helpers;
+
+public interface ILocalizedKeyInfo
+{
+    public string LocalizationKey { get; }
+    public string Location { get; }
+}
+
+[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true)]
+public class LocalizedKeyAttribute(string key, string whereItIsUsed) : Attribute, ILocalizedKeyInfo
+{
+    public string LocalizationKey { get; } = key;
+    public string Location { get; } = whereItIsUsed;
+}

+ 1 - 1
src/PixiEditor/Helpers/SurfaceHelpers.cs

@@ -40,7 +40,7 @@ public static class SurfaceHelpers
 
     public static unsafe byte[] ToByteArray(this Surface surface, ColorType colorType = ColorType.Bgra8888, AlphaType alphaType = AlphaType.Premul, ColorSpace colorSpace = null)
     {
-        using var ctx = IDrawieInteropContext.Current.EnsureContext();
+        using var ctx = DrawingBackendApi.Current.RenderingDispatcher.EnsureContext();
         int width = surface.Size.X;
         int height = surface.Size.Y;
         var imageInfo = new ImageInfo(width, height, colorType, alphaType, colorSpace == null ? surface.ImageInfo.ColorSpace : colorSpace);

+ 4 - 0
src/PixiEditor/Models/MarkedLocalizationKeys.cs

@@ -0,0 +1,4 @@
+using PixiEditor.Helpers;
+
+[assembly:LocalizedKey("HARDNESS_SETTING", "Used as a blackboard variable within brush inside built-in .pixi files.")]
+[assembly:LocalizedKey("SPACING_SETTING", "Used as a blackboard variable within brush inside built-in .pixi files.")]

+ 71 - 0
src/PixiEditor/Models/Serialization/Factories/Paintables/TexturePaintableSerializationFactory.cs

@@ -0,0 +1,71 @@
+using Drawie.Backend.Core.ColorsImpl.Paintables;
+using Silk.NET.OpenGL;
+
+namespace PixiEditor.Models.Serialization.Factories.Paintables;
+
+public class TexturePaintableSerializationFactory : SerializationFactory<byte[], TexturePaintable>,
+    IPaintableSerializationFactory
+{
+    public override string DeserializationId { get; } = "PixiEditor.TexturePaintable";
+
+    private readonly TextureSerializationFactory textureFactory = new TextureSerializationFactory();
+
+    public override byte[] Serialize(TexturePaintable original)
+    {
+        ByteBuilder builder = new();
+        Serialize(original, builder);
+
+        return builder.Build();
+    }
+
+    public override bool TryDeserialize(object serialized, out TexturePaintable original,
+        (string serializerName, string serializerVersion) serializerData)
+    {
+        if (serialized is not byte[] bytes)
+        {
+            original = null!;
+            return false;
+        }
+
+        ByteExtractor extractor = new(bytes);
+        original = TryDeserialize(extractor) as TexturePaintable;
+
+        return true;
+    }
+
+    public Paintable TryDeserialize(ByteExtractor extractor)
+    {
+        return TryDeserialize(extractor, default);
+    }
+
+    public Paintable TryDeserialize(ByteExtractor extractor,
+        (string serializerName, string serializerVersion) serializerData)
+    {
+        textureFactory.Config = Config;
+        textureFactory.ResourceLocator = ResourceLocator;
+
+        int length = extractor.GetInt();
+        var textureData = extractor.GetByteSpan(length).ToArray();
+        if (textureFactory.TryDeserialize(textureData, out var tex, serializerData))
+        {
+            return new TexturePaintable(tex);
+        }
+
+        return null!;
+    }
+
+    public void Serialize(Paintable paintable, ByteBuilder builder)
+    {
+        if (paintable is not TexturePaintable texturePaintable)
+        {
+            throw new ArgumentException("Paintable is not a TexturePaintable", nameof(paintable));
+        }
+
+        textureFactory.Config = Config;
+        textureFactory.ResourceLocator = ResourceLocator;
+
+        var array = textureFactory.Serialize(texturePaintable.Image);
+        builder.AddInt(array.Length);
+        builder.AddByteArray(array);
+    }
+}

+ 17 - 1
src/PixiEditor/ViewModels/Document/Nodes/Brushes/BrushOutputNodeViewModel.cs

@@ -1,5 +1,9 @@
-using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Brushes;
+using Drawie.Backend.Core.Bridge;
+using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Brushes;
+using PixiEditor.Models.Events;
+using PixiEditor.Models.Handlers;
 using PixiEditor.ViewModels.Nodes;
+using PixiEditor.ViewModels.Nodes.Properties;
 
 namespace PixiEditor.ViewModels.Document.Nodes.Brushes;
 
@@ -10,5 +14,17 @@ internal class BrushOutputNodeViewModel : NodeViewModel<BrushOutputNode>
     {
         InputPropertyMap[BrushOutputNode.BrushNameProperty].SocketEnabled = false;
         InputPropertyMap[BrushOutputNode.FitToStrokeSizeProperty].SocketEnabled = false;
+        InputPropertyMap[BrushOutputNode.UseCustomStampBlenderProperty].ValueChanged += OnValueChanged;
+        if(InputPropertyMap[BrushOutputNode.CustomStampBlenderCodeProperty] is StringPropertyViewModel codeProperty)
+        {
+            codeProperty.IsVisible = (bool)InputPropertyMap[BrushOutputNode.UseCustomStampBlenderProperty].Value;
+            codeProperty.Kind = DrawingBackendApi.Current.ShaderImplementation.ShaderLanguageExtension;
+        }
+    }
+
+    private void OnValueChanged(INodePropertyHandler property, NodePropertyValueChangedArgs args)
+    {
+        InputPropertyMap[BrushOutputNode.CustomStampBlenderCodeProperty].IsVisible = (bool)args.NewValue;
+        InputPropertyMap[BrushOutputNode.StampBlendModeProperty].IsVisible = !(bool)args.NewValue;
     }
 }

+ 6 - 0
src/PixiEditor/ViewModels/Tools/Tools/BrushBasedToolViewModel.cs

@@ -148,6 +148,12 @@ internal class BrushBasedToolViewModel : ToolViewModel, IBrushToolHandler
         }
     }
 
+    protected override void OnSelectedLayersChanged(IStructureMemberHandler[] layers)
+    {
+        OnDeselecting(false);
+        OnToolSelected(false);
+    }
+
     private void AddBrushShapeSettings()
     {
         foreach (var setting in brushShapeSettings)

+ 6 - 2
src/PixiEditor/Views/Input/BrushItem.axaml.cs

@@ -4,6 +4,7 @@ using Avalonia.Controls;
 using Avalonia.Controls.Metadata;
 using Avalonia.Input;
 using Avalonia.Markup.Xaml;
+using Avalonia.Rendering.Composition;
 using Avalonia.Threading;
 using ChunkyImageLib;
 using ChunkyImageLib.DataHolders;
@@ -11,6 +12,7 @@ using Drawie.Backend.Core;
 using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Backend.Core.Surfaces.ImageData;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
 using Drawie.Numerics;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Nodes.Brushes;
 using PixiEditor.ChangeableDocument.Rendering;
@@ -128,7 +130,8 @@ internal partial class BrushItem : UserControl
         DrawingStrokeTexture = previewTexture;
 
         previewTexture.DrawingSurface.Canvas.Clear();
-        previewImage.CancelChanges();
+        previewImage.EnqueueClear();
+        previewImage.CommitChanges();
         enumerator = brushNode.DrawStrokePreviewEnumerable(previewImage, CreateContext(),
             BrushOutputNode.StrokePreviewSizeY / 2,
             new VecD(0, BrushOutputNode.YOffsetInPreview)).GetEnumerator();
@@ -158,11 +161,12 @@ internal partial class BrushItem : UserControl
                     return false;
                 }
 
+                using Paint srcOver = new() { BlendMode = BlendMode.Src, Style = PaintStyle.Fill };
                 previewImage.DrawMostUpToDateRegionOn(
                     new RectI(0, 0, previewImage.CommittedSize.X, previewImage.CommittedSize.Y),
                     ChunkResolution.Full,
                     previewTexture.DrawingSurface.Canvas,
-                    VecI.Zero, null, SamplingOptions.Bilinear);
+                    VecI.Zero, srcOver);
 
                 StrokePreviewControl.QueueNextFrame();
             }

+ 1 - 1
tests/ChunkyImageLibTest/ChunkyImageTests.cs

@@ -21,7 +21,7 @@ public class ChunkyImageTests : PixiEditorTest
         image.EnqueueDrawRectangle(new(new(5, 5), new(80, 80), 0, 0, 2, Colors.AliceBlue, Colors.Snow));
         using (Chunk target = Chunk.Create(ColorSpace.CreateSrgb()))
         {
-            image.DrawMostUpToDateChunkOn(new(0, 0), ChunkResolution.Full, target.Surface.DrawingSurface, VecI.Zero);
+            image.DrawMostUpToDateChunkOn(new(0, 0), ChunkResolution.Full, target.Surface.DrawingSurface.Canvas, VecI.Zero);
             image.CancelChanges();
             image.EnqueueResize(new(ChunkyImage.FullChunkSize * 4, ChunkyImage.FullChunkSize * 4));
             image.EnqueueDrawRectangle(new(VecD.Zero, image.CommittedSize, 0, 0, 2, Colors.AliceBlue, Colors.Snow,

+ 1 - 1
tests/Directory.Build.props

@@ -1,7 +1,7 @@
 <Project>
     <PropertyGroup>
         <CodeAnalysisRuleSet>../Custom.ruleset</CodeAnalysisRuleSet>
-		<AvaloniaVersion>11.3.6</AvaloniaVersion>
+		<AvaloniaVersion>11.3.9-cibuild0004033-alpha</AvaloniaVersion>
     </PropertyGroup>
     <ItemGroup>
         <PackageReference Include="StyleCop.Analyzers" Version="1.1.118" />

+ 22 - 0
tests/PixiEditor.Backend.Tests/MockDocument.cs

@@ -1,5 +1,6 @@
 using Drawie.Backend.Core.Surfaces.ImageData;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Changeables.Graph;
 using PixiEditor.ChangeableDocument.Changeables.Graph.Interfaces;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
 using PixiEditor.ChangeableDocument.Rendering;
@@ -74,6 +75,7 @@ public class MockDocument : IReadOnlyDocument
 
     public IReadOnlyReferenceLayer? ReferenceLayer { get; }
     public DocumentRenderer Renderer { get; }
+    public IReadOnlyBlackboard Blackboard { get; }
     public ColorSpace ProcessingColorSpace { get; }
     public void InitProcessingColorSpace(ColorSpace processingColorSpace)
     {
@@ -89,4 +91,24 @@ public class MockDocument : IReadOnlyDocument
     {
         throw new NotImplementedException();
     }
+
+    public ICrossDocumentPipe<IReadOnlyNodeGraph> CreateGraphPipe()
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyDocument Clone(bool preserveDocumentId = false)
+    {
+        throw new NotImplementedException();
+    }
+
+    public IReadOnlyStructureNode[] GetStructureTreeInOrder()
+    {
+        throw new NotImplementedException();
+    }
+
+    public object Clone()
+    {
+        throw new NotImplementedException();
+    }
 }

+ 3 - 2
tests/PixiEditor.Backend.Tests/NodeSystemTests.cs

@@ -24,7 +24,8 @@ public class NodeSystemTests : PixiEditorTest
     private Type[] knownNonSerializableTypes = new[]
     {
         typeof(Filter),
-        typeof(Painter)
+        typeof(Painter),
+        typeof(Object) // Objects are assumed to be only passed from other node and not serialized directly
     };
 
     public NodeSystemTests(ITestOutputHelper output)
@@ -98,7 +99,7 @@ public class NodeSystemTests : PixiEditorTest
             Assert.NotNull(node);
 
             Dictionary<string, object> data = new Dictionary<string, object>();
-            node.SerializeAdditionalData(data);
+            node.SerializeAdditionalData(target, data);
             Assert.NotNull(data);
         }
     }

+ 1 - 1
tests/PixiEditor.Tests/BlendingTests.cs

@@ -75,7 +75,7 @@ public class BlendingTests : PixiEditorTest
         secondImageLayer.BlendMode.NonOverridenValue = blendMode;
 
         Surface output = Surface.ForProcessing(VecI.One, ColorSpace.CreateSrgbLinear());
-        graph.Execute(new RenderContext(output.DrawingSurface, 0, ChunkResolution.Full, VecI.One, VecI.One, ColorSpace.CreateSrgbLinear(), SamplingOptions.Default, 1));
+        graph.Execute(new RenderContext(output.DrawingSurface.Canvas, 0, ChunkResolution.Full, VecI.One, VecI.One, ColorSpace.CreateSrgbLinear(), SamplingOptions.Default, new NodeGraph(), 1));
 
         Color result = output.GetSrgbPixel(VecI.Zero);
         Assert.Equal(expectedColor, result.ToRgbHex());

+ 1 - 1
tests/PixiEditor.Tests/PixiEditor.Tests.csproj

@@ -12,7 +12,7 @@
 
     <ItemGroup>
         <PackageReference Include="Avalonia" Version="$(AvaloniaVersion)" />
-        <PackageReference Include="Avalonia.Headless.XUnit" Version="$(AvaloniaVersion)" />
+        <PackageReference Include="Avalonia.Headless.XUnit" Version="11.3.8" />
         <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
         <PackageReference Include="xunit" Version="2.9.2"/>
         <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">

+ 7 - 2
tests/PixiEditor.Tests/RenderTests.cs

@@ -8,6 +8,7 @@ using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.Numerics;
 using Drawie.Backend.Core.Surfaces;
 using Drawie.Numerics;
+using PixiEditor.ChangeableDocument.Rendering.ContextData;
 using PixiEditor.Models.IO;
 using PixiEditor.Models.Position;
 using Xunit.Abstractions;
@@ -96,9 +97,13 @@ public class RenderTests : FullPixiEditorTest
             0,
             document.SizeBindable / 2f,
             document.SizeBindable,
-            Matrix3X3.Identity, null, "DEFAULT", SamplingOptions.Default, document.SizeBindable, ChunkResolution.Half,
+            new ViewportData(),
+            new PointerInfo(),
+            new KeyboardInfo(),
+            new EditorData(),
+            null, "DEFAULT", SamplingOptions.Default, document.SizeBindable, ChunkResolution.Half,
             Guid.NewGuid(), false, false, () => { });
-        using var output = document.SceneRenderer.RenderScene(info, new AffectedArea(), null);
+        using var output = document.SceneRenderer.RenderScene(info, new AffectedArea());
 
         Color expectedColor = Colors.Yellow;
 

+ 31 - 0
tests/PixiEditor.Tests/SerializationTests.cs

@@ -1,7 +1,11 @@
 using Avalonia.Headless.XUnit;
+using Drawie.Backend.Core;
 using Drawie.Backend.Core.Bridge;
+using Drawie.Backend.Core.ColorsImpl;
 using Drawie.Backend.Core.ColorsImpl.Paintables;
 using Drawie.Backend.Core.Surfaces.ImageData;
+using Drawie.Backend.Core.Surfaces.PaintImpl;
+using Drawie.Numerics;
 using Drawie.Skia;
 using DrawiEngine;
 using PixiEditor.ChangeableDocument.Changeables.Interfaces;
@@ -11,6 +15,7 @@ using PixiEditor.Models.Serialization;
 using PixiEditor.Models.Serialization.Factories;
 using PixiEditor.Models.Serialization.Factories.Paintables;
 using PixiEditor.Parser.Skia.Encoders;
+using PixiEditor.ViewModels.Document;
 
 namespace PixiEditor.Tests;
 
@@ -50,6 +55,32 @@ public class SerializationTests : PixiEditorTest
         }
     }
 
+    [Fact]
+    public void TestTexturePaintableFactory()
+    {
+        Texture texture = new Texture(new VecI(32, 32));
+        texture.DrawingSurface.Canvas.DrawCircle(16, 16, 16, new Paint() { Color = Colors.Red, BlendMode = Drawie.Backend.Core.Surfaces.BlendMode.Src });
+        TexturePaintable paintable = new TexturePaintable(texture);
+        TexturePaintableSerializationFactory factory = new TexturePaintableSerializationFactory();
+        factory.Config = new SerializationConfig(new QoiEncoder(), ColorSpace.CreateSrgbLinear());
+        var serialized = factory.Serialize(paintable);
+        var deserialized = (TexturePaintable)factory.Deserialize(serialized, default);
+
+        Assert.NotNull(deserialized);
+        var deserializedImage = deserialized.Image;
+        Assert.NotNull(deserializedImage);
+        Assert.Equal(texture.Size, deserializedImage.Size);
+        for (int y = 0; y < texture.Size.Y; y++)
+        {
+            for (int x = 0; x < texture.Size.X; x++)
+            {
+                Color originalPixel = texture.GetSrgbPixel(new VecI(x, y));
+                Color deserializedPixel = deserializedImage.GetSrgbPixel(new VecI(x, y));
+                Assert.Equal(originalPixel, deserializedPixel);
+            }
+        }
+    }
+
     [AvaloniaTheory]
     [InlineData("Fibi")]
     [InlineData("Pond")]