From 44e572208cc8b363b8b01681e5b4710317b60929 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 12:06:45 +1000 Subject: [PATCH 1/3] Compose gradient transforms on the brush Transforming a gradient brush projected its geometry point by point, which reduces any affine transform to a similarity. A COLRv1 radial paint squashed by its PaintTransform into a flat ellipse became a circle, and the pad region of that circle painted the toned thumbs-up opaque dark. GradientBrush carries a GradientTransform from the gradient's space to the drawing, every brush has a constructor overload that takes it, and Transform() composes it exactly for affine matrices. The old point projection remains for projective matrices, which have no affine inverse. Renderers fold the inverse: the linear brush into its axis, the two-circle radial brush into its canonical transform, and the others map each sample once when a transform is set. The glyph renderer builds paint-space brushes with the paint's transform composed after the glyph's. Interpolation is unchanged: premultiplied, as the CSS Color 4 gradient rules require. --- .../Processing/EllipticGradientBrush.cs | 119 ++++---- .../Processing/GradientBrush.cs | 75 ++++- .../Processing/LinearGradientBrush.cs | 113 ++++++-- .../Processing/RadialGradientBrush.cs | 138 +++++---- .../RichTextGlyphRenderer.Brushes.cs | 64 ++--- .../Processing/SweepGradientBrush.cs | 151 +++++----- ...sWithDrawingCanvasTests.GradientBrushes.cs | 264 ++++++++++++++---- .../Processing/RichTextGlyphRendererTests.cs | 71 +++++ .../DrawText_EmojiGrid_NotoColorEmoji.png | 4 +- ...d492x360_(255,255,255,255)_ColrV1-draw.png | 4 +- ...olid492x360_(255,255,255,255)_Svg-draw.png | 4 +- 11 files changed, 701 insertions(+), 306 deletions(-) diff --git a/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs b/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs index 665a820c..d7649f45 100644 --- a/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs +++ b/src/ImageSharp.Drawing/Processing/EllipticGradientBrush.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; +using SixLabors.ImageSharp.Drawing.Helpers; using SixLabors.ImageSharp.Memory; namespace SixLabors.ImageSharp.Drawing.Processing; @@ -31,7 +32,32 @@ public EllipticGradientBrush( float axisRatio, GradientRepetitionMode repetitionMode, params ColorStop[] colorStops) - : base(repetitionMode, colorStops) + : this(center, referenceAxisEnd, axisRatio, repetitionMode, Matrix4x4.Identity, colorStops) + { + } + + /// + /// Initializes a new instance of the class with the ellipse + /// defined in the gradient's own coordinate space. + /// + /// The center of the elliptical gradient and 0 for the color stops. + /// The end point of the reference axis of the ellipse. + /// + /// The ratio of the axis widths. + /// The second axis is perpendicular to the reference axis and its length is the reference axis length + /// multiplied by this factor. + /// + /// Defines how the colors of the gradients are repeated. + /// The transform from the gradient's coordinate space to the drawing. + /// The color stops. + public EllipticGradientBrush( + PointF center, + PointF referenceAxisEnd, + float axisRatio, + GradientRepetitionMode repetitionMode, + Matrix4x4 gradientTransform, + params ColorStop[] colorStops) + : base(repetitionMode, gradientTransform, colorStops) { this.Center = center; this.ReferenceAxisEnd = referenceAxisEnd; @@ -56,29 +82,32 @@ public EllipticGradientBrush( /// public override Brush Transform(Matrix4x4 matrix, Rectangle sourceInterest, Rectangle preparedInterest) { - PointF tc = PointF.Transform(this.Center, matrix); - PointF tRef = PointF.Transform(this.ReferenceAxisEnd, matrix); - - // Compute a point on the perpendicular (secondary) axis and transform it. - float refDx = this.ReferenceAxisEnd.X - this.Center.X; - float refDy = this.ReferenceAxisEnd.Y - this.Center.Y; - float refLen = MathF.Sqrt((refDx * refDx) + (refDy * refDy)); - float secondLen = refLen * this.AxisRatio; - - // Perpendicular direction (rotated 90 degrees). - PointF secondEnd = new( - this.Center.X + (-refDy / refLen * secondLen), - this.Center.Y + (refDx / refLen * secondLen)); - PointF tSec = PointF.Transform(secondEnd, matrix); - - // Derive new ratio from transformed lengths. - float newRefLen = MathF.Sqrt( - ((tRef.X - tc.X) * (tRef.X - tc.X)) + ((tRef.Y - tc.Y) * (tRef.Y - tc.Y))); - float newSecLen = MathF.Sqrt( - ((tSec.X - tc.X) * (tSec.X - tc.X)) + ((tSec.Y - tc.Y) * (tSec.Y - tc.Y))); - float newRatio = newRefLen > 0f ? newSecLen / newRefLen : this.AxisRatio; - - return new EllipticGradientBrush(tc, tRef, newRatio, this.RepetitionMode, this.ColorStopsArray); + Matrix4x4 gradientTransform = this.GradientTransform * matrix; + if (!MatrixUtilities.IsAffine(in gradientTransform)) + { + // Perspective has no affine inverse: the center and both axis ends project point by + // point and the ratio follows the projected axis lengths. + PointF tc = PointF.Transform(this.Center, gradientTransform); + PointF tRef = PointF.Transform(this.ReferenceAxisEnd, gradientTransform); + + // Compute a point on the perpendicular (secondary) axis and transform it. + float refDx = this.ReferenceAxisEnd.X - this.Center.X; + float refDy = this.ReferenceAxisEnd.Y - this.Center.Y; + float refLen = MathF.Sqrt((refDx * refDx) + (refDy * refDy)); + float secondLen = refLen * this.AxisRatio; + + // Perpendicular direction (rotated 90 degrees). + PointF secondEnd = new(this.Center.X + (-refDy / refLen * secondLen), this.Center.Y + (refDx / refLen * secondLen)); + PointF tSec = PointF.Transform(secondEnd, gradientTransform); + + // Derive new ratio from transformed lengths. + float newRefLen = MathF.Sqrt(((tRef.X - tc.X) * (tRef.X - tc.X)) + ((tRef.Y - tc.Y) * (tRef.Y - tc.Y))); + float newSecLen = MathF.Sqrt(((tSec.X - tc.X) * (tSec.X - tc.X)) + ((tSec.Y - tc.Y) * (tSec.Y - tc.Y))); + float newRatio = newRefLen > 0f ? newSecLen / newRefLen : this.AxisRatio; + return new EllipticGradientBrush(tc, tRef, newRatio, this.RepetitionMode, this.ColorStopsArray); + } + + return new EllipticGradientBrush(this.Center, this.ReferenceAxisEnd, this.AxisRatio, this.RepetitionMode, gradientTransform, this.ColorStopsArray); } /// @@ -90,22 +119,10 @@ public override BrushRenderer CreateRenderer( { if (TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Associated) { - return new EllipticGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this, - this.ColorStopsArray, - this.RepetitionMode); + return new EllipticGradientBrushRenderer>(configuration, options, canvasWidth, this); } - return new EllipticGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this, - this.ColorStopsArray, - this.RepetitionMode); + return new EllipticGradientBrushRenderer>(configuration, options, canvasWidth, this); } /// @@ -134,16 +151,12 @@ private sealed class EllipticGradientBrushRenderer : GradientB /// The graphics options. /// The canvas width for the current render pass. /// The elliptic gradient brush. - /// Definition of colors. - /// Defines how the gradient colors are repeated. public EllipticGradientBrushRenderer( Configuration configuration, GraphicsOptions options, int canvasWidth, - EllipticGradientBrush brush, - ColorStop[] colorStops, - GradientRepetitionMode repetitionMode) - : base(configuration, options, canvasWidth, colorStops, repetitionMode) + EllipticGradientBrush brush) + : base(configuration, options, canvasWidth, brush) { this.center = brush.Center; @@ -162,12 +175,24 @@ public EllipticGradientBrushRenderer( /// protected override float PositionOnGradient(float x, float y) { - // Translate the sample into center-relative coordinates, then rotate it by the + // Map the sample into the gradient's space when the brush is transformed, translate + // it into center-relative coordinates, then rotate it by the // negated reference-axis angle so the reference axis aligns with local x before // measuring against the axis radii. Rotating by the positive angle instead would // mirror the ellipse for any orientation that is not a multiple of 90 degrees. - float x0 = x - this.center.X; - float y0 = y - this.center.Y; + float x0; + float y0; + if (this.IsTransformed) + { + Vector2 p = Vector2.Transform(new Vector2(x, y), this.InverseGradientTransform); + x0 = p.X - this.center.X; + y0 = p.Y - this.center.Y; + } + else + { + x0 = x - this.center.X; + y0 = y - this.center.Y; + } float xR = (x0 * this.cosRotation) + (y0 * this.sinRotation); float yR = (y0 * this.cosRotation) - (x0 * this.sinRotation); diff --git a/src/ImageSharp.Drawing/Processing/GradientBrush.cs b/src/ImageSharp.Drawing/Processing/GradientBrush.cs index 4070d1dd..838e027f 100644 --- a/src/ImageSharp.Drawing/Processing/GradientBrush.cs +++ b/src/ImageSharp.Drawing/Processing/GradientBrush.cs @@ -19,8 +19,20 @@ public abstract class GradientBrush : Brush /// Defines how the colors are repeated beyond the interval [0..1]. /// The gradient colors. protected GradientBrush(GradientRepetitionMode repetitionMode, params ColorStop[] colorStops) + : this(repetitionMode, Matrix4x4.Identity, colorStops) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// Defines how the colors are repeated beyond the interval [0..1]. + /// The transform from the gradient's coordinate space to the drawing. + /// The gradient colors. + protected GradientBrush(GradientRepetitionMode repetitionMode, Matrix4x4 gradientTransform, params ColorStop[] colorStops) { this.RepetitionMode = repetitionMode; + this.GradientTransform = gradientTransform; InsertionSort(colorStops, (a, b) => a.Ratio.CompareTo(b.Ratio)); this.ColorStopsArray = colorStops; @@ -31,6 +43,16 @@ protected GradientBrush(GradientRepetitionMode repetitionMode, params ColorStop[ /// public GradientRepetitionMode RepetitionMode { get; } + /// + /// Gets the transform from the gradient's coordinate space to the drawing. The geometry of + /// the gradient is defined in its own space and every sample is mapped through the inverse + /// of this transform, so a skew or a non-uniform scale changes the shape of the gradient. + /// The transform is affine: a projective transform applied through + /// projects the geometry + /// point by point instead, because an affine gradient cannot express perspective. + /// + public Matrix4x4 GradientTransform { get; } + /// /// Gets the color stops for this gradient. /// @@ -47,6 +69,7 @@ public override bool Equals(Brush? other) if (other is GradientBrush brush) { return this.RepetitionMode == brush.RepetitionMode + && this.GradientTransform.Equals(brush.GradientTransform) && this.ColorStopsArray?.SequenceEqual(brush.ColorStopsArray) == true; } @@ -55,7 +78,22 @@ public override bool Equals(Brush? other) /// public override int GetHashCode() - => HashCode.Combine(this.RepetitionMode, this.ColorStopsArray); + => HashCode.Combine(this.RepetitionMode, this.GradientTransform, this.ColorStopsArray); + + /// + /// Inverts the affine part of , which maps drawing + /// coordinates into the gradient's coordinate space. + /// + /// The inverted affine transform. + /// + /// if the transform can be inverted; otherwise . + /// + internal bool TryGetInverseTransform(out Matrix3x2 drawingToGradient) + { + Matrix4x4 m = this.GradientTransform; + Matrix3x2 affine = new(m.M11, m.M12, m.M21, m.M22, m.M41, m.M42); + return Matrix3x2.Invert(affine, out drawingToGradient); + } /// /// Sorts the collection in place using a stable insertion sort. @@ -103,16 +141,15 @@ internal abstract class GradientBrushRenderer : BrushRenderer< /// The configuration instance to use when performing operations. /// The graphics options. /// The canvas width for the current render pass. - /// An array of color stops sorted by their position. - /// Defines if and how the gradient should be repeated. + /// The gradient brush. protected GradientBrushRenderer( Configuration configuration, GraphicsOptions options, int canvasWidth, - ColorStop[] colorStops, - GradientRepetitionMode repetitionMode) + GradientBrush brush) : base(configuration, options, canvasWidth) { + ColorStop[] colorStops = brush.ColorStopsArray; this.colorStops = new GradientColorStop[colorStops.Length]; // CSS Color 4 requires alpha to be premultiplied before color interpolation. @@ -125,9 +162,35 @@ protected GradientBrushRenderer( this.colorStops[i] = new GradientColorStop(stop.Ratio, stop.Color.ToScaledVector4(PixelAlphaRepresentation.Associated)); } - this.repetitionMode = repetitionMode; + this.repetitionMode = brush.RepetitionMode; + + // The inverse of the affine part maps drawing coordinates into the gradient's space. + // Derived renderers fold it into their own per-sample mapping. A transform that + // cannot be inverted yields NaN positions, which the sampler already paints as + // transparent. + this.IsTransformed = !brush.GradientTransform.IsIdentity; + if (this.IsTransformed) + { + brush.TryGetInverseTransform(out Matrix3x2 drawingToGradient); + this.InverseGradientTransform = drawingToGradient; + } + else + { + this.InverseGradientTransform = Matrix3x2.Identity; + } } + /// + /// Gets a value indicating whether the brush has a gradient transform, so samples must be + /// mapped through before the gradient is evaluated. + /// + protected bool IsTransformed { get; } + + /// + /// Gets the transform from drawing coordinates to the gradient's coordinate space. + /// + protected Matrix3x2 InverseGradientTransform { get; } + /// /// Gets the gradient color for the pixel at the given device coordinate. /// diff --git a/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs b/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs index b59dd789..2c7e3148 100644 --- a/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs +++ b/src/ImageSharp.Drawing/Processing/LinearGradientBrush.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; +using SixLabors.ImageSharp.Drawing.Helpers; namespace SixLabors.ImageSharp.Drawing.Processing; @@ -24,7 +25,26 @@ public LinearGradientBrush( PointF p1, GradientRepetitionMode repetitionMode, params ColorStop[] colorStops) - : base(repetitionMode, colorStops) + : this(p0, p1, repetitionMode, Matrix4x4.Identity, colorStops) + { + } + + /// + /// Initializes a new instance of the class using + /// a start and end point defined in the gradient's own coordinate space. + /// + /// The start point of the gradient. + /// The end point of the gradient. + /// Defines how the colors are repeated. + /// The transform from the gradient's coordinate space to the drawing. + /// The ordered color stops of the gradient. + public LinearGradientBrush( + PointF p0, + PointF p1, + GradientRepetitionMode repetitionMode, + Matrix4x4 gradientTransform, + params ColorStop[] colorStops) + : base(repetitionMode, gradientTransform, colorStops) { this.StartPoint = p0; this.EndPoint = p1; @@ -47,7 +67,30 @@ public LinearGradientBrush( PointF rotationPoint, GradientRepetitionMode repetitionMode, params ColorStop[] colorStops) - : base(repetitionMode, colorStops) + : this(p0, p1, rotationPoint, repetitionMode, Matrix4x4.Identity, colorStops) + { + } + + /// + /// Initializes a new instance of the class using + /// three points, defined in the gradient's own coordinate space, to define a rotated gradient axis. + /// + /// The first point (start of the gradient). + /// The second point (gradient vector endpoint). + /// + /// The rotation reference point. This defines the rotation of the gradient axis. + /// + /// Defines how the colors are repeated. + /// The transform from the gradient's coordinate space to the drawing. + /// The ordered color stops of the gradient. + public LinearGradientBrush( + PointF p0, + PointF p1, + PointF rotationPoint, + GradientRepetitionMode repetitionMode, + Matrix4x4 gradientTransform, + params ColorStop[] colorStops) + : base(repetitionMode, gradientTransform, colorStops) { ResolveAxis(p0, p1, rotationPoint, out PointF start, out PointF end); this.StartPoint = start; @@ -66,11 +109,19 @@ public LinearGradientBrush( /// public override Brush Transform(Matrix4x4 matrix, Rectangle sourceInterest, Rectangle preparedInterest) - => new LinearGradientBrush( - PointF.Transform(this.StartPoint, matrix), - PointF.Transform(this.EndPoint, matrix), - this.RepetitionMode, - this.ColorStopsArray); + { + Matrix4x4 gradientTransform = this.GradientTransform * matrix; + if (!MatrixUtilities.IsAffine(in gradientTransform)) + { + return new LinearGradientBrush( + PointF.Transform(this.StartPoint, gradientTransform), + PointF.Transform(this.EndPoint, gradientTransform), + this.RepetitionMode, + this.ColorStopsArray); + } + + return new LinearGradientBrush(this.StartPoint, this.EndPoint, this.RepetitionMode, gradientTransform, this.ColorStopsArray); + } /// public override bool Equals(Brush? other) @@ -139,22 +190,10 @@ public override BrushRenderer CreateRenderer( { if (TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Associated) { - return new LinearGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this, - this.ColorStopsArray, - this.RepetitionMode); + return new LinearGradientBrushRenderer>(configuration, options, canvasWidth, this); } - return new LinearGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this, - this.ColorStopsArray, - this.RepetitionMode); + return new LinearGradientBrushRenderer>(configuration, options, canvasWidth, this); } /// @@ -178,22 +217,34 @@ private sealed class LinearGradientBrushRenderer : GradientBru /// The graphics options. /// The canvas width for the current render pass. /// The linear gradient brush. - /// The gradient color stops. - /// Defines how the gradient repeats. public LinearGradientBrushRenderer( Configuration configuration, GraphicsOptions options, int canvasWidth, - LinearGradientBrush brush, - ColorStop[] colorStops, - GradientRepetitionMode repetitionMode) - : base(configuration, options, canvasWidth, colorStops, repetitionMode) + LinearGradientBrush brush) + : base(configuration, options, canvasWidth, brush) { - this.start = brush.StartPoint; + PointF start = brush.StartPoint; + float alongX = brush.EndPoint.X - start.X; + float alongY = brush.EndPoint.Y - start.Y; + this.alongsSquared = (alongX * alongX) + (alongY * alongY); + + if (this.IsTransformed) + { + // The projection onto the axis is linear, so the axis folds through the linear + // part of the inverse transform and the start point moves to its position in the + // drawing. A transformed brush then costs the same per sample as a plain one. + Matrix3x2 inverse = this.InverseGradientTransform; + float foldedX = (inverse.M11 * alongX) + (inverse.M12 * alongY); + float foldedY = (inverse.M21 * alongX) + (inverse.M22 * alongY); + start = PointF.Transform(start, brush.GradientTransform); + alongX = foldedX; + alongY = foldedY; + } - this.alongX = brush.EndPoint.X - this.start.X; - this.alongY = brush.EndPoint.Y - this.start.Y; - this.alongsSquared = (this.alongX * this.alongX) + (this.alongY * this.alongY); + this.start = start; + this.alongX = alongX; + this.alongY = alongY; } /// diff --git a/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs b/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs index 0e030721..31d45fc7 100644 --- a/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs +++ b/src/ImageSharp.Drawing/Processing/RadialGradientBrush.cs @@ -26,7 +26,26 @@ public RadialGradientBrush( float radius, GradientRepetitionMode repetitionMode, params ColorStop[] colorStops) - : base(repetitionMode, colorStops) + : this(center, radius, repetitionMode, Matrix4x4.Identity, colorStops) + { + } + + /// + /// Initializes a new instance of the class using a single circle + /// defined in the gradient's own coordinate space. + /// + /// The center of the circular gradient. + /// The radius of the circular gradient. + /// Defines how the colors in the gradient are repeated. + /// The transform from the gradient's coordinate space to the drawing. + /// The ordered gradient stops. + public RadialGradientBrush( + PointF center, + float radius, + GradientRepetitionMode repetitionMode, + Matrix4x4 gradientTransform, + params ColorStop[] colorStops) + : base(repetitionMode, gradientTransform, colorStops) { this.Center0 = center; this.Radius0 = radius; @@ -50,7 +69,30 @@ public RadialGradientBrush( float endRadius, GradientRepetitionMode repetitionMode, params ColorStop[] colorStops) - : base(repetitionMode, colorStops) + : this(startCenter, startRadius, endCenter, endRadius, repetitionMode, Matrix4x4.Identity, colorStops) + { + } + + /// + /// Initializes a new instance of the class using two circles + /// defined in the gradient's own coordinate space. + /// + /// The center of the starting circle. + /// The radius of the starting circle. + /// The center of the ending circle. + /// The radius of the ending circle. + /// Defines how the colors in the gradient are repeated. + /// The transform from the gradient's coordinate space to the drawing. + /// The ordered gradient stops. + public RadialGradientBrush( + PointF startCenter, + float startRadius, + PointF endCenter, + float endRadius, + GradientRepetitionMode repetitionMode, + Matrix4x4 gradientTransform, + params ColorStop[] colorStops) + : base(repetitionMode, gradientTransform, colorStops) { this.Center0 = startCenter; this.Radius0 = startRadius; @@ -86,15 +128,28 @@ public RadialGradientBrush( /// public override Brush Transform(Matrix4x4 matrix, Rectangle sourceInterest, Rectangle preparedInterest) { - PointF tc0 = PointF.Transform(this.Center0, matrix); - float scale = MatrixUtilities.GetAverageScale(in matrix); + Matrix4x4 gradientTransform = this.GradientTransform * matrix; + if (!MatrixUtilities.IsAffine(in gradientTransform)) + { + // Perspective has no affine inverse: the centers project point by point and the + // radii scale by the average scale of the linear part. + PointF tc0 = PointF.Transform(this.Center0, gradientTransform); + float scale = MatrixUtilities.GetAverageScale(in gradientTransform); + if (this.IsTwoCircle) + { + PointF tc1 = PointF.Transform(this.Center1!.Value, gradientTransform); + return new RadialGradientBrush(tc0, this.Radius0 * scale, tc1, this.Radius1!.Value * scale, this.RepetitionMode, this.ColorStopsArray); + } + + return new RadialGradientBrush(tc0, this.Radius0 * scale, this.RepetitionMode, this.ColorStopsArray); + } + if (this.IsTwoCircle) { - PointF tc1 = PointF.Transform(this.Center1!.Value, matrix); - return new RadialGradientBrush(tc0, this.Radius0 * scale, tc1, this.Radius1!.Value * scale, this.RepetitionMode, this.ColorStopsArray); + return new RadialGradientBrush(this.Center0, this.Radius0, this.Center1!.Value, this.Radius1!.Value, this.RepetitionMode, gradientTransform, this.ColorStopsArray); } - return new RadialGradientBrush(tc0, this.Radius0 * scale, this.RepetitionMode, this.ColorStopsArray); + return new RadialGradientBrush(this.Center0, this.Radius0, this.RepetitionMode, gradientTransform, this.ColorStopsArray); } /// @@ -125,28 +180,10 @@ public override BrushRenderer CreateRenderer( { if (TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Associated) { - return new RadialGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this.Center0, - this.Radius0, - this.Center1, - this.Radius1, - this.ColorStopsArray, - this.RepetitionMode); + return new RadialGradientBrushRenderer>(configuration, options, canvasWidth, this); } - return new RadialGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this.Center0, - this.Radius0, - this.Center1, - this.Radius1, - this.ColorStopsArray, - this.RepetitionMode); + return new RadialGradientBrushRenderer>(configuration, options, canvasWidth, this); } /// @@ -185,39 +222,33 @@ private sealed class RadialGradientBrushRenderer : GradientBru /// The configuration instance to use when performing operations. /// The graphics options. /// The canvas width for the current render pass. - /// Center of the starting circle. - /// Radius of the starting circle. - /// Center of the ending circle, or null to use single-circle form. - /// Radius of the ending circle, or null to use single-circle form. - /// Definition of colors. - /// How the colors are repeated beyond the first gradient. + /// The radial gradient brush. public RadialGradientBrushRenderer( Configuration configuration, GraphicsOptions options, int canvasWidth, - PointF center0, - float radius0, - PointF? center1, - float? radius1, - ColorStop[] colorStops, - GradientRepetitionMode repetitionMode) - : base(configuration, options, canvasWidth, colorStops, repetitionMode) + RadialGradientBrush brush) + : base(configuration, options, canvasWidth, brush) { + PointF center0 = brush.Center0; + float radius0 = brush.Radius0; this.c0x = center0.X; this.c0y = center0.Y; this.r0 = radius0; - this.isTwoCircle = center1.HasValue && radius1.HasValue; + this.isTwoCircle = brush.IsTwoCircle; if (this.isTwoCircle) { ConicalGradientParameters parameters = CreateConicalGradientParameters( center0, radius0, - center1!.Value, - radius1!.Value); + brush.Center1!.Value, + brush.Radius1!.Value); - this.radialTransform = parameters.Transform; + // The inverse gradient transform folds into the canonical transform, so a + // transformed brush costs the same per sample as a plain one. + this.radialTransform = this.InverseGradientTransform * parameters.Transform; this.focalX = parameters.FocalX; this.radius = parameters.Radius; this.isStrip = parameters.IsStrip; @@ -242,9 +273,22 @@ protected override float PositionOnGradient(float x, float y) { if (!this.isTwoCircle) { - // Single-circle form: the parameter is simply distance from the - // center divided by the radius. - float ux = x - this.c0x, uy = y - this.c0y; + // Single-circle form: the parameter is simply distance from the center divided + // by the radius, measured in the gradient's space. + float ux; + float uy; + if (this.IsTransformed) + { + Vector2 p = Vector2.Transform(new Vector2(x, y), this.InverseGradientTransform); + ux = p.X - this.c0x; + uy = p.Y - this.c0y; + } + else + { + ux = x - this.c0x; + uy = y - this.c0y; + } + return MathF.Sqrt((ux * ux) + (uy * uy)) / this.r0; } diff --git a/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.Brushes.cs b/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.Brushes.cs index 41bed0c5..930c3080 100644 --- a/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.Brushes.cs +++ b/src/ImageSharp.Drawing/Processing/RichTextGlyphRenderer.Brushes.cs @@ -18,7 +18,7 @@ internal sealed partial class RichTextGlyphRenderer /// Attempts to create an ImageSharp.Drawing from a . /// /// The paint definition coming from the interpreter. - /// A transform to apply to the brush coordinates. + /// The transform from the glyph's space to the drawing, applied after the paint's own transform. /// The resulting brush, or if the paint is unsupported. /// /// if a brush could be created; otherwise, . @@ -53,7 +53,7 @@ public static bool TryCreateBrush([NotNullWhen(true)] Paint? paint, Matrix4x4 tr /// Creates a from a . /// /// The linear gradient paint. - /// The transform to apply to the gradient points. + /// The transform applied after the paint's own transform. /// The resulting brush. /// /// if created; otherwise, . @@ -66,29 +66,19 @@ private static bool TryCreateLinearGradientBrush(LinearGradientPaint paint, Matr // Map spread method. GradientRepetitionMode mode = MapSpread(paint.Spread); + // The geometry stays in the paint's space. The brush maps every sample through the + // inverse of the composed transform, so a skew or a non-uniform scale in the paint's + // transform keeps its shape. + Matrix4x4 gradientTransform = new Matrix4x4(paint.Transform) * transform; PointF p0 = paint.P0; PointF p1 = paint.P1; - PointF? p2 = paint.P2; - - // Apply any transform defined on the paint. - if (!transform.IsIdentity) - { - p0 = PointF.Transform(p0, transform); - p1 = PointF.Transform(p1, transform); - - if (p2.HasValue) - { - p2 = PointF.Transform(p2.Value, transform); - } - } - - if (p2.HasValue) + if (paint.P2.HasValue) { - brush = new LinearGradientBrush(p0, p1, p2.Value, mode, stops); + brush = new LinearGradientBrush(p0, p1, paint.P2.Value, mode, gradientTransform, stops); return true; } - brush = new LinearGradientBrush(p0, p1, mode, stops); + brush = new LinearGradientBrush(p0, p1, mode, gradientTransform, stops); return true; } @@ -96,7 +86,7 @@ private static bool TryCreateLinearGradientBrush(LinearGradientPaint paint, Matr /// Creates a from a . /// /// The radial gradient paint. - /// The transform to apply to the gradient center point. + /// The transform applied after the paint's own transform. /// The resulting brush. /// /// if created; otherwise, . @@ -109,21 +99,11 @@ private static bool TryCreateRadialGradientBrush(RadialGradientPaint paint, Matr // Map spread method. GradientRepetitionMode mode = MapSpread(paint.Spread); - // Apply any transform defined on the paint. - PointF center0 = paint.Center0; - PointF center1 = paint.Center1; - float radius0 = paint.Radius0; - float radius1 = paint.Radius1; - if (!transform.IsIdentity) - { - center0 = PointF.Transform(center0, transform); - center1 = PointF.Transform(center1, transform); - float scale = MatrixUtilities.GetAverageScale(in transform); - radius0 *= scale; - radius1 *= scale; - } - - brush = new RadialGradientBrush(center0, radius0, center1, radius1, mode, stops); + // The circles stay in the paint's space. The brush maps every sample through the + // inverse of the composed transform, so a skew or a non-uniform scale in the paint's + // transform draws an ellipse instead of a circle. + Matrix4x4 gradientTransform = new Matrix4x4(paint.Transform) * transform; + brush = new RadialGradientBrush(paint.Center0, paint.Radius0, paint.Center1, paint.Radius1, mode, gradientTransform, stops); return true; } @@ -131,7 +111,7 @@ private static bool TryCreateRadialGradientBrush(RadialGradientPaint paint, Matr /// Creates a from a . /// /// The sweep gradient paint. - /// The transform to apply to the gradient center point. + /// The transform applied after the paint's own transform. /// The resulting brush. /// /// if created; otherwise, . @@ -144,14 +124,10 @@ private static bool TryCreateSweepGradientBrush(SweepGradientPaint paint, Matrix // Map spread method. GradientRepetitionMode mode = MapSpread(paint.Spread); - // Apply any transform defined on the paint. - PointF center = paint.Center; - if (!transform.IsIdentity) - { - center = PointF.Transform(center, transform); - } - - brush = new SweepGradientBrush(center, paint.StartAngle, paint.EndAngle, mode, stops); + // The center and angles stay in the paint's space. The brush maps every sample through + // the inverse of the composed transform. + Matrix4x4 gradientTransform = new Matrix4x4(paint.Transform) * transform; + brush = new SweepGradientBrush(paint.Center, paint.StartAngle, paint.EndAngle, mode, gradientTransform, stops); return true; } diff --git a/src/ImageSharp.Drawing/Processing/SweepGradientBrush.cs b/src/ImageSharp.Drawing/Processing/SweepGradientBrush.cs index 7e4a4c5d..96f45987 100644 --- a/src/ImageSharp.Drawing/Processing/SweepGradientBrush.cs +++ b/src/ImageSharp.Drawing/Processing/SweepGradientBrush.cs @@ -2,6 +2,7 @@ // Licensed under the Six Labors Split License. using System.Numerics; +using SixLabors.ImageSharp.Drawing.Helpers; namespace SixLabors.ImageSharp.Drawing.Processing; @@ -32,7 +33,28 @@ public SweepGradientBrush( float endAngleDegrees, GradientRepetitionMode repetitionMode, params ColorStop[] colorStops) - : base(repetitionMode, colorStops) + : this(center, startAngleDegrees, endAngleDegrees, repetitionMode, Matrix4x4.Identity, colorStops) + { + } + + /// + /// Initializes a new instance of the class with the center and + /// angles defined in the gradient's own coordinate space. + /// + /// The center point of the sweep gradient. + /// The starting angle, in degrees, measured counter-clockwise from +X on the design grid. + /// The ending angle, in degrees, measured counter-clockwise from +X on the design grid. + /// Defines how the gradient colors are repeated beyond the interval [0..1]. + /// The transform from the gradient's coordinate space to the drawing. + /// The gradient color stops. Ratios must be in [0..1] and are interpreted along the angular sweep. + public SweepGradientBrush( + PointF center, + float startAngleDegrees, + float endAngleDegrees, + GradientRepetitionMode repetitionMode, + Matrix4x4 gradientTransform, + params ColorStop[] colorStops) + : base(repetitionMode, gradientTransform, colorStops) { this.Center = center; this.StartAngleDegrees = startAngleDegrees; @@ -57,49 +79,57 @@ public SweepGradientBrush( /// public override Brush Transform(Matrix4x4 matrix, Rectangle sourceInterest, Rectangle preparedInterest) { - PointF tc = PointF.Transform(this.Center, matrix); - - // Treat the brush as two rays starting at the center: - // one ray for the start angle and one ray for the end angle. - // The important value is the signed angular distance between those rays. - // We keep that sign so a reflected transform can turn a counter-clockwise - // sweep into a clockwise sweep instead of silently "fixing" it. - float sweepDegrees = GetEffectiveSweepDegrees(this.StartAngleDegrees, this.EndAngleDegrees); - float startRad = GeometryUtilities.DegreeToRadian(this.StartAngleDegrees); - float endRad = GeometryUtilities.DegreeToRadian(this.StartAngleDegrees + sweepDegrees); - - // The public API uses the design-grid convention, which is y-up. - // Screen pixels are y-down, so a positive mathematical rotation uses - // `center.Y - sin(theta)` rather than `center.Y + sin(theta)`. - PointF startDir = PointF.Transform(new PointF(this.Center.X + MathF.Cos(startRad), this.Center.Y - MathF.Sin(startRad)), matrix); - PointF endDir = PointF.Transform(new PointF(this.Center.X + MathF.Cos(endRad), this.Center.Y - MathF.Sin(endRad)), matrix); - - // Convert the transformed rays back into brush angles in the same public convention: - // counter-clockwise from +X on the design grid. - float newStart = NormalizeDirectionDegrees(MathF.Atan2(-(startDir.Y - tc.Y), startDir.X - tc.X) * (180f / MathF.PI)); - float newEnd = NormalizeDirectionDegrees(MathF.Atan2(-(endDir.Y - tc.Y), endDir.X - tc.X) * (180f / MathF.PI)); - - // A negative determinant means the transform flips orientation. - // That flips the direction of the sweep, so we use it to decide whether - // the end angle should unwrap forwards or backwards from the new start. - float determinant = (matrix.M11 * matrix.M22) - (matrix.M12 * matrix.M21); - float directionHint = MathF.Sign(sweepDegrees); - if (directionHint == 0F) + Matrix4x4 gradientTransform = this.GradientTransform * matrix; + if (!MatrixUtilities.IsAffine(in gradientTransform)) { - directionHint = 1F; - } + // Perspective has no affine inverse: the center and the two rays project point by + // point, and the sign of the sweep follows the orientation of the transform. + PointF tc = PointF.Transform(this.Center, gradientTransform); + + // Treat the brush as two rays starting at the center: + // one ray for the start angle and one ray for the end angle. + // The important value is the signed angular distance between those rays. + // We keep that sign so a reflected transform can turn a counter-clockwise + // sweep into a clockwise sweep instead of silently "fixing" it. + float sweepDegrees = GetEffectiveSweepDegrees(this.StartAngleDegrees, this.EndAngleDegrees); + float startRad = GeometryUtilities.DegreeToRadian(this.StartAngleDegrees); + float endRad = GeometryUtilities.DegreeToRadian(this.StartAngleDegrees + sweepDegrees); + + // The public API uses the design-grid convention, which is y-up. + // Screen pixels are y-down, so a positive mathematical rotation uses + // `center.Y - sin(theta)` rather than `center.Y + sin(theta)`. + PointF startDir = PointF.Transform(new PointF(this.Center.X + MathF.Cos(startRad), this.Center.Y - MathF.Sin(startRad)), gradientTransform); + PointF endDir = PointF.Transform(new PointF(this.Center.X + MathF.Cos(endRad), this.Center.Y - MathF.Sin(endRad)), gradientTransform); + + // Convert the transformed rays back into brush angles in the same public convention: + // counter-clockwise from +X on the design grid. + float newStart = NormalizeDirectionDegrees(MathF.Atan2(-(startDir.Y - tc.Y), startDir.X - tc.X) * (180f / MathF.PI)); + float newEnd = NormalizeDirectionDegrees(MathF.Atan2(-(endDir.Y - tc.Y), endDir.X - tc.X) * (180f / MathF.PI)); + + // A negative determinant means the transform flips orientation. + // That flips the direction of the sweep, so we use it to decide whether + // the end angle should unwrap forwards or backwards from the new start. + float determinant = (gradientTransform.M11 * gradientTransform.M22) - (gradientTransform.M12 * gradientTransform.M21); + float directionHint = MathF.Sign(sweepDegrees); + if (directionHint == 0F) + { + directionHint = 1F; + } - if (determinant < 0F) - { - directionHint = -directionHint; + if (determinant < 0F) + { + directionHint = -directionHint; + } + + return new SweepGradientBrush( + tc, + newStart, + UnwrapSweepEndDegrees(newStart, newEnd, directionHint, MathF.Abs(sweepDegrees)), + this.RepetitionMode, + this.ColorStopsArray); } - return new SweepGradientBrush( - tc, - newStart, - UnwrapSweepEndDegrees(newStart, newEnd, directionHint, MathF.Abs(sweepDegrees)), - this.RepetitionMode, - this.ColorStopsArray); + return new SweepGradientBrush(this.Center, this.StartAngleDegrees, this.EndAngleDegrees, this.RepetitionMode, gradientTransform, this.ColorStopsArray); } /// @@ -221,22 +251,10 @@ public override BrushRenderer CreateRenderer( { if (TPixel.GetPixelTypeInfo().AlphaRepresentation == PixelAlphaRepresentation.Associated) { - return new SweepGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this, - this.ColorStopsArray, - this.RepetitionMode); + return new SweepGradientBrushRenderer>(configuration, options, canvasWidth, this); } - return new SweepGradientBrushRenderer>( - configuration, - options, - canvasWidth, - this, - this.ColorStopsArray, - this.RepetitionMode); + return new SweepGradientBrushRenderer>(configuration, options, canvasWidth, this); } /// @@ -265,16 +283,12 @@ private sealed class SweepGradientBrushRenderer : GradientBrus /// The graphics options. /// The canvas width for the current render pass. /// The sweep gradient brush. - /// The gradient color stops (ratios in [0..1]). - /// Defines how gradient colors are repeated outside [0..1]. public SweepGradientBrushRenderer( Configuration configuration, GraphicsOptions options, int canvasWidth, - SweepGradientBrush brush, - ColorStop[] colorStops, - GradientRepetitionMode repetitionMode) - : base(configuration, options, canvasWidth, colorStops, repetitionMode) + SweepGradientBrush brush) + : base(configuration, options, canvasWidth, brush) { this.cx = brush.Center.X; this.cy = brush.Center.Y; @@ -288,9 +302,20 @@ public SweepGradientBrushRenderer( /// protected override float PositionOnGradient(float x, float y) { - // Move the sample into center-relative coordinates. - float dx = x - this.cx; - float dy = y - this.cy; + // Move the sample into center-relative coordinates in the gradient's space. + float dx; + float dy; + if (this.IsTransformed) + { + Vector2 p = Vector2.Transform(new Vector2(x, y), this.InverseGradientTransform); + dx = p.X - this.cx; + dy = p.Y - this.cy; + } + else + { + dx = x - this.cx; + dy = y - this.cy; + } if (dx == 0f && dy == 0f) { diff --git a/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs b/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs index 17bd31b3..7549b118 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/ProcessWithDrawingCanvasTests.GradientBrushes.cs @@ -101,113 +101,253 @@ public void FillRadialGradientBrushWithDifferentCentersReturnsImage( false); [Fact] - public void SweepGradientBrush_Transform_TranslationMovesCenter() + public void SweepGradientBrush_Transform_ComposesTheGradientTransform() { SweepGradientBrush brush = new( new PointF(100, 100), 0F, - 360F, + 90F, GradientRepetitionMode.None, + Matrix4x4.CreateRotationZ(MathF.PI / 4F), new ColorStop(0, Color.Red), new ColorStop(1, Color.Blue)); - Matrix4x4 matrix = Matrix4x4.CreateTranslation(50F, 30F, 0F); + Matrix4x4 matrix = Matrix4x4.CreateScale(2F, 1F, 1F) * Matrix4x4.CreateTranslation(50F, 30F, 0F); SweepGradientBrush transformed = Assert.IsType(brush.Transform(matrix, default, default)); - Assert.Equal(150F, transformed.Center.X, 0.01F); - Assert.Equal(130F, transformed.Center.Y, 0.01F); + // The center and angles stay in the gradient's space; only the transform changes. + Assert.Equal(brush.Center, transformed.Center); + Assert.Equal(brush.StartAngleDegrees, transformed.StartAngleDegrees); + Assert.Equal(brush.EndAngleDegrees, transformed.EndAngleDegrees); + Assert.Equal(brush.GradientTransform * matrix, transformed.GradientTransform); } [Fact] - public void SweepGradientBrush_Transform_RotationRotatesAngles() + public void SweepGradientBrush_GradientTransform_ReflectionMirrorsTheSweep() { - SweepGradientBrush brush = new( - new PointF(100, 100), - 0F, - 90F, - GradientRepetitionMode.None, - new ColorStop(0, Color.Red), - new ColorStop(1, Color.Blue)); + // A reflection about x = 100 maps the pixel center x + 0.5 onto 199.5 - x, the center of + // pixel 199 - x, so the reflected brush repeats the upright brush mirrored. + static SweepGradientBrush Create(Matrix4x4 gradientTransform) + => new( + new PointF(100F, 100F), + 0F, + 360F, + GradientRepetitionMode.None, + gradientTransform, + new ColorStop(0, Color.Red), + new ColorStop(1, Color.Blue)); - // Rotate 90 degrees counter-clockwise in design grid (y-up). - // In screen space (y-down), Matrix4x4.CreateRotationZ(pi/2) rotates clockwise, - // which corresponds to counter-clockwise on the design grid. - Matrix4x4 matrix = Matrix4x4.CreateRotationZ(MathF.PI / 2F); + Matrix4x4 reflect = Matrix4x4.CreateScale(-1F, 1F, 1F) * Matrix4x4.CreateTranslation(200F, 0F, 0F); + using Image upright = new(200, 200, Color.White.ToPixel()); + upright.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(Matrix4x4.Identity)))); - SweepGradientBrush transformed = Assert.IsType(brush.Transform(matrix, default, default)); + using Image reflected = new(200, 200, Color.White.ToPixel()); + reflected.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(reflect)))); + + for (int y = 0; y < 200; y += 7) + { + for (int x = 0; x < 200; x += 5) + { + Assert.Equal(upright[199 - x, y], reflected[x, y]); + } + } - // The 90-degree sweep should be preserved. - float sweep = transformed.EndAngleDegrees - transformed.StartAngleDegrees; - Assert.Equal(90F, sweep, 0.5F); + // The sweep really runs the other way: at the same pixel the colors differ. + Assert.NotEqual(upright[150, 60], reflected[150, 60]); } [Fact] - public void SweepGradientBrush_Transform_ReflectionFlipsSweepDirection() + public void RadialGradientBrush_Transform_ComposesTheGradientTransform() { - SweepGradientBrush brush = new( - new PointF(100, 100), - 0F, - 90F, + RadialGradientBrush brush = new( + new PointF(10, 20), + 4F, + new PointF(30, 40), + 8F, GradientRepetitionMode.None, + Matrix4x4.CreateRotationZ(MathF.PI / 4F), new ColorStop(0, Color.Red), new ColorStop(1, Color.Blue)); - // Reflect across Y axis (negative determinant). - Matrix4x4 matrix = Matrix4x4.CreateScale(-1F, 1F, 1F); + Matrix4x4 matrix = Matrix4x4.CreateScale(2F, 4F, 1F) * Matrix4x4.CreateTranslation(5F, 7F, 0F); - SweepGradientBrush transformed = Assert.IsType(brush.Transform(matrix, default, default)); + RadialGradientBrush transformed = Assert.IsType(brush.Transform(matrix, default, default)); - // Reflection should flip the sweep direction: positive 90 becomes negative 90. - float sweep = transformed.EndAngleDegrees - transformed.StartAngleDegrees; - Assert.Equal(-90F, sweep, 0.5F); + // The circles stay in the gradient's space; only the transform changes. + Assert.Equal(brush.Center0, transformed.Center0); + Assert.Equal(brush.Radius0, transformed.Radius0); + Assert.Equal(brush.Center1, transformed.Center1); + Assert.Equal(brush.Radius1, transformed.Radius1); + Assert.Equal(brush.GradientTransform * matrix, transformed.GradientTransform); } [Fact] - public void SweepGradientBrush_Transform_FullSweepPreserved() + public void RadialGradientBrush_GradientTransform_ScalesTheAxesIndependently() { - // Equal start/end = full 360 sweep. - SweepGradientBrush brush = new( - new PointF(50, 50), - 45F, - 45F, - GradientRepetitionMode.None, - new ColorStop(0, Color.Red), - new ColorStop(1, Color.Blue)); + // Doubling x about the pixel center 100.5 maps the center of pixel 100 + d onto the + // center of pixel 100 + d / 2, so the stretched brush equals the round brush sampled at + // half the horizontal distance while the vertical axis is untouched. + static RadialGradientBrush Create(Matrix4x4 gradientTransform) + => new( + new PointF(100.5F, 100.5F), + 8F, + GradientRepetitionMode.Repeat, + gradientTransform, + new ColorStop(0, Color.Red), + new ColorStop(1, Color.Blue)); - Matrix4x4 matrix = - Matrix4x4.CreateScale(2F) - * Matrix4x4.CreateTranslation(10F, 20F, 0F); + Matrix4x4 stretch = Matrix4x4.CreateScale(2F, 1F, 1F) * Matrix4x4.CreateTranslation(-100.5F, 0F, 0F); + using Image round = new(200, 200, Color.White.ToPixel()); + round.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(Matrix4x4.Identity)))); - SweepGradientBrush transformed = Assert.IsType(brush.Transform(matrix, default, default)); + using Image stretched = new(200, 200, Color.White.ToPixel()); + stretched.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(stretch)))); + + for (int d = -60; d <= 60; d += 2) + { + Assert.Equal(round[100 + (d / 2), 100], stretched[100 + d, 100]); + Assert.Equal(round[100, 100 + d], stretched[100, 100 + d]); + } - // Full sweep should remain a full 360 degrees. - float sweep = MathF.Abs(transformed.EndAngleDegrees - transformed.StartAngleDegrees); - Assert.Equal(360F, sweep, 0.5F); + // The stretch really widens the rings: at the same pixel the colors differ. + Assert.NotEqual(round[110, 100], stretched[110, 100]); } [Fact] - public void RadialGradientBrush_Transform_UsesAverageScaleForRadii() + public void RadialGradientBrush_GradientTransform_SkewsTheCircles() { - RadialGradientBrush brush = new( - new PointF(10, 20), - 4F, - new PointF(30, 40), - 8F, + // The skew x' = x + y - 0.5 maps the center of pixel (x - y, y) onto the center of + // pixel (x, y), so the skewed two-circle brush equals the upright brush sampled one + // pixel further left per row. The canonical transform is composed once per brush, so + // the sample can round differently by one level in a channel. + static RadialGradientBrush Create(Matrix4x4 gradientTransform) + => new( + new PointF(90F, 100F), + 10F, + new PointF(110F, 100F), + 40F, + GradientRepetitionMode.Repeat, + gradientTransform, + new ColorStop(0, Color.Red), + new ColorStop(1, Color.Blue)); + + Matrix4x4 skew = new(1F, 0F, 0F, 0F, 1F, 1F, 0F, 0F, 0F, 0F, 1F, 0F, -0.5F, 0F, 0F, 1F); + using Image upright = new(400, 200, Color.White.ToPixel()); + upright.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(Matrix4x4.Identity)))); + + using Image skewed = new(400, 200, Color.White.ToPixel()); + skewed.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(skew)))); + + for (int y = 0; y < 200; y += 7) + { + for (int x = 200; x < 400; x += 5) + { + Rgba32 expected = upright[x - y, y]; + Rgba32 actual = skewed[x, y]; + Assert.InRange(actual.R, expected.R - 1, expected.R + 1); + Assert.InRange(actual.G, expected.G - 1, expected.G + 1); + Assert.InRange(actual.B, expected.B - 1, expected.B + 1); + Assert.Equal(expected.A, actual.A); + } + } + + // The skew really slants the rings: at the same pixel the colors differ. + Assert.NotEqual(upright[250, 150], skewed[250, 150]); + } + + [Fact] + public void LinearGradientBrush_GradientTransform_SkewsTheIsoLines() + { + // The skew x' = x + y - 0.5 maps the center of pixel (x - y, y) onto the center of + // pixel (x, y), so the skewed brush equals the upright brush sampled one pixel further + // left per row. The axis folds through the inverse transform once per brush, so a + // channel can round one level away. + static LinearGradientBrush Create(Matrix4x4 gradientTransform) + => new( + new PointF(0F, 0F), + new PointF(100F, 0F), + GradientRepetitionMode.Repeat, + gradientTransform, + new ColorStop(0, Color.Red), + new ColorStop(1, Color.Blue)); + + Matrix4x4 skew = new(1F, 0F, 0F, 0F, 1F, 1F, 0F, 0F, 0F, 0F, 1F, 0F, -0.5F, 0F, 0F, 1F); + using Image upright = new(400, 200, Color.White.ToPixel()); + upright.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(Matrix4x4.Identity)))); + + using Image skewed = new(400, 200, Color.White.ToPixel()); + skewed.Mutate(ctx => ctx.Paint(canvas => canvas.Fill(Create(skew)))); + + for (int y = 0; y < 200; y += 7) + { + for (int x = 200; x < 400; x += 5) + { + Rgba32 expected = upright[x - y, y]; + Rgba32 actual = skewed[x, y]; + Assert.InRange(actual.R, expected.R - 1, expected.R + 1); + Assert.InRange(actual.G, expected.G - 1, expected.G + 1); + Assert.InRange(actual.B, expected.B - 1, expected.B + 1); + Assert.Equal(expected.A, actual.A); + } + } + + // The skew really tilts the bands: at the same pixel the colors differ. + Assert.NotEqual(upright[250, 150], skewed[250, 150]); + } + + [Fact] + public void EllipticGradientBrush_Transform_ComposesTheGradientTransform() + { + EllipticGradientBrush brush = new( + new PointF(50, 50), + new PointF(90, 50), + 0.5F, GradientRepetitionMode.None, + Matrix4x4.CreateRotationZ(MathF.PI / 4F), new ColorStop(0, Color.Red), new ColorStop(1, Color.Blue)); - Matrix4x4 matrix = - Matrix4x4.CreateScale(2F, 4F, 1F) - * Matrix4x4.CreateTranslation(5F, 7F, 0F); + Matrix4x4 matrix = Matrix4x4.CreateScale(2F, 4F, 1F) * Matrix4x4.CreateTranslation(5F, 7F, 0F); - RadialGradientBrush transformed = Assert.IsType(brush.Transform(matrix, default, default)); + EllipticGradientBrush transformed = Assert.IsType(brush.Transform(matrix, default, default)); + + // The ellipse stays in the gradient's space; only the transform changes. + Assert.Equal(brush.Center, transformed.Center); + Assert.Equal(brush.ReferenceAxisEnd, transformed.ReferenceAxisEnd); + Assert.Equal(brush.AxisRatio, transformed.AxisRatio); + Assert.Equal(brush.GradientTransform * matrix, transformed.GradientTransform); + } + + [Fact] + public void GradientBrush_DrawingTransform_MatchesTheGradientTransform() + { + // A drawing transform reaches the brush through Brush.Transform, so drawing a path with + // the transform in the options must equal drawing the transformed path with a brush + // that carries the same gradient transform. + Matrix4x4 transform = new(1.4F, 0F, 0F, 0F, 0.5F, 0.7F, 0F, 0F, 0F, 0F, 1F, 0F, 30F, 20F, 0F, 1F); + RectanglePolygon upper = new(20F, 20F, 160F, 160F); + RectanglePolygon lower = new(20F, 120F, 160F, 160F); + ColorStop[] stops = [new ColorStop(0, Color.Red), new ColorStop(0.5F, Color.Lime), new ColorStop(1, Color.Blue)]; + + RadialGradientBrush radial = new(new PointF(80F, 100F), 10F, new PointF(100F, 100F), 70F, GradientRepetitionMode.Reflect, stops); + SweepGradientBrush sweep = new(new PointF(100F, 100F), 30F, 300F, GradientRepetitionMode.None, stops); + + using Image viaOptions = new(300, 300, Color.White.ToPixel()); + viaOptions.Mutate(ctx => ctx.Paint(new DrawingOptions { Transform = transform }, canvas => + { + canvas.Fill(radial, upper); + canvas.Fill(sweep, lower); + })); + + using Image viaBrush = new(300, 300, Color.White.ToPixel()); + viaBrush.Mutate(ctx => ctx.Paint(canvas => + { + canvas.Fill(radial.Transform(transform, default, default), upper.Transform(transform)); + canvas.Fill(sweep.Transform(transform, default, default), lower.Transform(transform)); + })); - Assert.Equal(PointF.Transform(brush.Center0, matrix), transformed.Center0); - Assert.Equal(PointF.Transform(brush.Center1.Value, matrix), transformed.Center1.Value); - Assert.Equal(12F, transformed.Radius0, 5); - Assert.Equal(24F, transformed.Radius1.Value, 5); + ImageComparer.Exact.VerifySimilarity(viaBrush, viaOptions); } [Theory] diff --git a/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs index 54f7554b..6cb8a782 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/RichTextGlyphRendererTests.cs @@ -14,6 +14,77 @@ namespace SixLabors.ImageSharp.Drawing.Tests.Processing; public class RichTextGlyphRendererTests { + [Fact] + public void TryCreateBrush_RadialGradient_KeepsThePaintSpaceCirclesAndComposesTheTransforms() + { + // The paint's own transform carries the skew and squash of a COLR PaintTransform, and + // the glyph transform follows it. Neither is baked into the circles. + Matrix3x2 paintTransform = new(0.99F, 0.03F, -0.02F, 0.75F, 4F, 5F); + RadialGradientPaint paint = new() + { + Center0 = new Vector2(10F, 20F), + Radius0 = 0F, + Center1 = new Vector2(10F, 20F), + Radius1 = 30F, + Stops = [new GradientStop(0F, new GlyphColor(255, 0, 0, 255)), new GradientStop(1F, new GlyphColor(0, 0, 255, 255))], + Transform = paintTransform + }; + + Matrix4x4 glyphTransform = Matrix4x4.CreateRotationZ(0.5F) * Matrix4x4.CreateTranslation(100F, 50F, 0F); + + Assert.True(RichTextGlyphRenderer.TryCreateBrush(paint, glyphTransform, out Brush? brush)); + RadialGradientBrush radial = Assert.IsType(brush); + Assert.Equal(new PointF(10F, 20F), radial.Center0); + Assert.Equal(0F, radial.Radius0); + Assert.Equal(new PointF(10F, 20F), radial.Center1); + Assert.Equal(30F, radial.Radius1); + Assert.Equal(new Matrix4x4(paintTransform) * glyphTransform, radial.GradientTransform); + } + + [Fact] + public void TryCreateBrush_SweepGradient_KeepsThePaintSpaceCenterAndComposesTheTransforms() + { + Matrix3x2 paintTransform = new(0.5F, 0.86F, -0.86F, 0.5F, 7F, 9F); + SweepGradientPaint paint = new() + { + Center = new Vector2(15F, 25F), + StartAngle = 30F, + EndAngle = 300F, + Stops = [new GradientStop(0F, new GlyphColor(255, 0, 0, 255)), new GradientStop(1F, new GlyphColor(0, 0, 255, 255))], + Transform = paintTransform + }; + + Matrix4x4 glyphTransform = Matrix4x4.CreateScale(2F, 1F, 1F) * Matrix4x4.CreateTranslation(100F, 50F, 0F); + + Assert.True(RichTextGlyphRenderer.TryCreateBrush(paint, glyphTransform, out Brush? brush)); + SweepGradientBrush sweep = Assert.IsType(brush); + Assert.Equal(new PointF(15F, 25F), sweep.Center); + Assert.Equal(30F, sweep.StartAngleDegrees); + Assert.Equal(300F, sweep.EndAngleDegrees); + Assert.Equal(new Matrix4x4(paintTransform) * glyphTransform, sweep.GradientTransform); + } + + [Fact] + public void TryCreateBrush_LinearGradient_KeepsThePaintSpacePointsAndComposesTheTransforms() + { + Matrix3x2 paintTransform = new(1F, 0F, 0.5F, 1F, 3F, 4F); + LinearGradientPaint paint = new() + { + P0 = new Vector2(0F, 0F), + P1 = new Vector2(100F, 0F), + Stops = [new GradientStop(0F, new GlyphColor(255, 0, 0, 255)), new GradientStop(1F, new GlyphColor(0, 0, 255, 255))], + Transform = paintTransform + }; + + Matrix4x4 glyphTransform = Matrix4x4.CreateTranslation(100F, 50F, 0F); + + Assert.True(RichTextGlyphRenderer.TryCreateBrush(paint, glyphTransform, out Brush? brush)); + LinearGradientBrush linear = Assert.IsType(brush); + Assert.Equal(new PointF(0F, 0F), linear.StartPoint); + Assert.Equal(new PointF(100F, 0F), linear.EndPoint); + Assert.Equal(new Matrix4x4(paintTransform) * glyphTransform, linear.GradientTransform); + } + /// /// Verifies moved text retains cached outline identity and updates its destination. /// diff --git a/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/DrawText_EmojiGrid_NotoColorEmoji.png b/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/DrawText_EmojiGrid_NotoColorEmoji.png index 469c0023..8b3d9f71 100644 --- a/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/DrawText_EmojiGrid_NotoColorEmoji.png +++ b/tests/Images/ReferenceOutput/Drawing/ProcessWithDrawingCanvasTests/DrawText_EmojiGrid_NotoColorEmoji.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15c7c4c18a28d2d66b81b7f9c97eb366fa7eadc54a953876e1cec51795a9022e -size 705004 +oid sha256:4aa48f6a1b9070f7dbd3b83e367a6dae421239783a820d4330b0d96107c34224 +size 708231 diff --git a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-draw.png b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-draw.png index d515d67d..99028eeb 100644 --- a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-draw.png +++ b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_ColrV1-draw.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:154cdeaf698de95a44d51cd604f7c809585e7d81ed1926bc9e494bb13bf887b1 -size 33453 +oid sha256:94b3516313c0559cecbaaaf9991fb3ec8595ae1749480923b95fca02931d3041 +size 33401 diff --git a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-draw.png b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-draw.png index d4461be9..8b403cca 100644 --- a/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-draw.png +++ b/tests/Images/ReferenceOutput/Issue_462/CanDrawEmojiFont_Rgba32_Solid492x360_(255,255,255,255)_Svg-draw.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fa5a25544ae7ddb9aa9452c336697054d1751c8776eecb51cd6fde3d7131bd6d -size 33539 +oid sha256:e1743c3d3e01708f2538968b40a8d88d5833524f376df60b97fa0678597075b2 +size 33395 From 135b1a9be8e7c28f2563769dad59b3b87df246ed Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 12:06:46 +1000 Subject: [PATCH 2/3] Carry the gradient transform through the WebGPU scene Every gradient payload starts with the six words of the inverse affine transform. The draw tag's scene-size field widens to four bits to hold the larger payloads. draw_leaf reads the transform once and composes it per gradient kind: the linear brush folds it into its line equation, the radial and elliptic brushes multiply it into their canonical transforms, and the sweep brush carries the matrix and translation. Adds a CPU and GPU parity test over the four transformed brushes. --- .../GpuSceneDrawTag.cs | 13 +-- .../Shaders/WgslSource/Shared/drawtag.wgsl | 15 ++-- .../Shaders/WgslSource/draw_leaf.wgsl | 81 +++++++++++++------ .../WebGPUSceneEncoder.cs | 35 +++++++- .../Backends/WebGPUDrawingBackendTests.cs | 81 +++++++++++++++++++ ...ntBrushes_MatchesDefaultOutput_Default.png | 3 + ...chesDefaultOutput_WebGPU_NativeSurface.png | 3 + 7 files changed, 189 insertions(+), 42 deletions(-) create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png create mode 100644 tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png diff --git a/src/ImageSharp.Drawing.WebGPU/GpuSceneDrawTag.cs b/src/ImageSharp.Drawing.WebGPU/GpuSceneDrawTag.cs index 1777be77..f7ff3469 100644 --- a/src/ImageSharp.Drawing.WebGPU/GpuSceneDrawTag.cs +++ b/src/ImageSharp.Drawing.WebGPU/GpuSceneDrawTag.cs @@ -13,17 +13,18 @@ internal static class GpuSceneDrawTag // These values are not a plain enum because each word also encodes the path-count, clip-count, // scene-word-count, and info-word-count increments consumed by the scan/reduction stages. // Visible-fill tags carry five extra info words: coverage data plus raster interest. Must match drawtag.wgsl. + // Every gradient payload starts with the six words of the drawing-to-gradient transform. public const uint Nop = 0U; public const uint FillColor = 0x188U; public const uint FillRecolor = 0x184U; - public const uint FillLinGradient = 0x254U; - public const uint FillRadGradient = 0x3DCU; - public const uint FillEllipticGradient = 0x35CU; - public const uint FillSweepGradient = 0x394U; + public const uint FillLinGradient = 0x26CU; + public const uint FillRadGradient = 0x3F4U; + public const uint FillEllipticGradient = 0x374U; + public const uint FillSweepGradient = 0x3ACU; public const uint FillPathGradient = 0x190U; public const uint FillImage = 0x3D4U; public const uint BeginClip = 0x49U; - public const uint EndClip = 0x21U; + public const uint EndClip = 0x401U; public const uint FillInfoFlagsFillRuleBit = 1U; public const uint FillInfoFlagsAliasedBit = 0x40000000U; @@ -37,6 +38,6 @@ public static GpuSceneDrawMonoid Map(uint tagWord) => new( tagWord != Nop ? 1U : 0U, tagWord & 1U, - (tagWord >> 2) & 0x07U, + (tagWord >> 2) & 0x0FU, (tagWord >> 6) & 0x0FU); } diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/drawtag.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/drawtag.wgsl index 7a63e98b..d0577511 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/drawtag.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/Shared/drawtag.wgsl @@ -26,20 +26,21 @@ struct DrawMonoid { } // Each draw object has a 32-bit draw tag, which is a bit-packed -// version of the draw monoid: bit 0 = clip count, bits 2..4 = scene words, +// version of the draw monoid: bit 0 = clip count, bits 2..5 = scene words, // bits 6..9 = info words (see map_draw_tag). // Visible-fill draw tags carry five extra info words: coverage data plus raster interest. +// Every gradient payload starts with the six words of the drawing-to-gradient transform. const DRAWTAG_NOP = 0u; const DRAWTAG_FILL_COLOR = 0x188u; const DRAWTAG_FILL_RECOLOR = 0x184u; -const DRAWTAG_FILL_LIN_GRADIENT = 0x254u; -const DRAWTAG_FILL_RAD_GRADIENT = 0x3dcu; -const DRAWTAG_FILL_ELLIPTIC_GRADIENT = 0x35cu; -const DRAWTAG_FILL_SWEEP_GRADIENT = 0x394u; +const DRAWTAG_FILL_LIN_GRADIENT = 0x26cu; +const DRAWTAG_FILL_RAD_GRADIENT = 0x3f4u; +const DRAWTAG_FILL_ELLIPTIC_GRADIENT = 0x374u; +const DRAWTAG_FILL_SWEEP_GRADIENT = 0x3acu; const DRAWTAG_FILL_PATH_GRADIENT = 0x190u; const DRAWTAG_FILL_IMAGE = 0x3d4u; const DRAWTAG_BEGIN_CLIP = 0x49u; -const DRAWTAG_END_CLIP = 0x21u; +const DRAWTAG_END_CLIP = 0x401u; // The first word of each draw info stream entry contains the flags. This is not part of the // draw object stream but is used after the draw objects have been reduced on the GPU. @@ -88,7 +89,7 @@ fn map_draw_tag(tag_word: u32) -> DrawMonoid { var c: DrawMonoid; c.path_ix = u32(tag_word != DRAWTAG_NOP); c.clip_ix = tag_word & 1u; - c.scene_offset = (tag_word >> 2u) & 0x07u; + c.scene_offset = (tag_word >> 2u) & 0x0fu; c.info_offset = (tag_word >> 6u) & 0x0fu; return c; } diff --git a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl index 94ee250b..e48c70d0 100644 --- a/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl +++ b/src/ImageSharp.Drawing.WebGPU/Shaders/WgslSource/draw_leaf.wgsl @@ -147,6 +147,18 @@ fn main( { let bbox = path_bbox[m.path_ix]; let draw_flags = bbox.draw_flags; + // Every gradient payload starts with the drawing-to-gradient transform, the inverse + // of the brush's gradient transform, so the geometry that follows is in the + // gradient's own space. + var user_to_gradient = transform_identity(); + if tag_word == DRAWTAG_FILL_LIN_GRADIENT || tag_word == DRAWTAG_FILL_RAD_GRADIENT || + tag_word == DRAWTAG_FILL_ELLIPTIC_GRADIENT || tag_word == DRAWTAG_FILL_SWEEP_GRADIENT + { + user_to_gradient = Transform( + bitcast>(vec4(scene[dd + 1u], scene[dd + 2u], scene[dd + 3u], scene[dd + 4u])), + bitcast>(vec2(scene[dd + 5u], scene[dd + 6u])), + vec3(0.0, 0.0, 1.0)); + } switch tag_word { case DRAWTAG_FILL_COLOR: { info[di] = draw_flags; @@ -159,8 +171,8 @@ fn main( } case DRAWTAG_FILL_LIN_GRADIENT: { info[di] = draw_flags; - let p0 = bitcast>(vec2(scene[dd + 1u], scene[dd + 2u])); - let p1 = bitcast>(vec2(scene[dd + 3u], scene[dd + 4u])); + let p0 = bitcast>(vec2(scene[dd + 7u], scene[dd + 8u])); + let p1 = bitcast>(vec2(scene[dd + 9u], scene[dd + 10u])); // Encode the gradient as a line equation so fine can // evaluate the parameter as t = dot(p, line_xy) + line_c. let dxy = p1 - p0; @@ -173,6 +185,15 @@ fn main( line_c = -dot(p0, line_xy); } + // The projection is linear, so the drawing-to-gradient transform folds into + // the line equation and fine evaluates it directly in drawing space. + let u = user_to_gradient; + let folded_xy = vec2( + u.matrx.x * line_xy.x + u.matrx.y * line_xy.y, + u.matrx.z * line_xy.x + u.matrx.w * line_xy.y); + line_c = line_c + dot(u.translate, line_xy); + line_xy = folded_xy; + // The CPU brush defines a zero-length axis as the gradient end. The // initialized equation therefore evaluates t=1 everywhere without NaN. info[di + 1u] = bitcast(line_xy.x); @@ -185,11 +206,10 @@ fn main( // This epsilon matches what Skia uses let GRADIENT_EPSILON = 1.0 / f32(1u << 12u); info[di] = draw_flags; - var p0 = bitcast>(vec2(scene[dd + 1u], scene[dd + 2u])); - var p1 = bitcast>(vec2(scene[dd + 3u], scene[dd + 4u])); - var r0 = bitcast(scene[dd + 5u]); - var r1 = bitcast(scene[dd + 6u]); - let user_to_gradient = transform_identity(); + var p0 = bitcast>(vec2(scene[dd + 7u], scene[dd + 8u])); + var p1 = bitcast>(vec2(scene[dd + 9u], scene[dd + 10u])); + var r0 = bitcast(scene[dd + 11u]); + var r1 = bitcast(scene[dd + 12u]); var xform = transform_identity(); var focal_x = 0.0; var radius = 0.0; @@ -257,9 +277,9 @@ fn main( } case DRAWTAG_FILL_ELLIPTIC_GRADIENT: { info[di] = draw_flags; - let center = bitcast>(vec2(scene[dd + 1u], scene[dd + 2u])); - let axis_end = bitcast>(vec2(scene[dd + 3u], scene[dd + 4u])); - let second_end = bitcast>(vec2(scene[dd + 5u], scene[dd + 6u])); + let center = bitcast>(vec2(scene[dd + 7u], scene[dd + 8u])); + let axis_end = bitcast>(vec2(scene[dd + 9u], scene[dd + 10u])); + let second_end = bitcast>(vec2(scene[dd + 11u], scene[dd + 12u])); let dxy = axis_end - center; let axis = length(dxy); let second_axis_len = length(second_end - center); @@ -305,25 +325,36 @@ fn main( xlat_y = -(m1 * center.x + m3 * center.y); } - info[di + 1u] = bitcast(m0); - info[di + 2u] = bitcast(m1); - info[di + 3u] = bitcast(m2); - info[di + 4u] = bitcast(m3); - info[di + 5u] = bitcast(xlat_x); - info[di + 6u] = bitcast(xlat_y); + // Samples pass through the drawing-to-gradient transform before the ellipse + // mapping. Degenerate kinds keep the center as their translation, so it moves + // to its drawing position and the transform folds into the matrix only. + let ellipse = Transform(vec4(m0, m1, m2, m3), vec2(xlat_x, xlat_y), vec3(0.0, 0.0, 1.0)); + var composed = transform_mul(ellipse, user_to_gradient); + if kind != ELLIPTIC_GRAD_KIND_NORMAL { + composed.translate = transform_apply(transform_inverse(user_to_gradient), center); + } + + info[di + 1u] = bitcast(composed.matrx.x); + info[di + 2u] = bitcast(composed.matrx.y); + info[di + 3u] = bitcast(composed.matrx.z); + info[di + 4u] = bitcast(composed.matrx.w); + info[di + 5u] = bitcast(composed.translate.x); + info[di + 6u] = bitcast(composed.translate.y); info[di + 7u] = kind; } case DRAWTAG_FILL_SWEEP_GRADIENT: { info[di] = draw_flags; - let p0 = bitcast>(vec2(scene[dd + 1u], scene[dd + 2u])); - info[di + 1u] = bitcast(1.0); - info[di + 2u] = bitcast(0.0); - info[di + 3u] = bitcast(0.0); - info[di + 4u] = bitcast(1.0); - info[di + 5u] = bitcast(-p0.x); - info[di + 6u] = bitcast(-p0.y); - info[di + 7u] = scene[dd + 3u]; - info[di + 8u] = scene[dd + 4u]; + let p0 = bitcast>(vec2(scene[dd + 7u], scene[dd + 8u])); + // The sample maps into the gradient's space and then becomes center-relative. + let u = user_to_gradient; + info[di + 1u] = bitcast(u.matrx.x); + info[di + 2u] = bitcast(u.matrx.y); + info[di + 3u] = bitcast(u.matrx.z); + info[di + 4u] = bitcast(u.matrx.w); + info[di + 5u] = bitcast(u.translate.x - p0.x); + info[di + 6u] = bitcast(u.translate.y - p0.y); + info[di + 7u] = scene[dd + 9u]; + info[di + 8u] = scene[dd + 10u]; } case DRAWTAG_FILL_PATH_GRADIENT: { info[di] = draw_flags; diff --git a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs index 68485d2b..ac1394c7 100644 --- a/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs +++ b/src/ImageSharp.Drawing.WebGPU/WebGPUSceneEncoder.cs @@ -6358,10 +6358,10 @@ private static int GetDrawDataWordCount(uint drawTag) GpuSceneDrawTag.BeginClip => 2, GpuSceneDrawTag.FillColor => 2, GpuSceneDrawTag.FillRecolor => 1, - GpuSceneDrawTag.FillLinGradient => 5, - GpuSceneDrawTag.FillRadGradient => 7, - GpuSceneDrawTag.FillEllipticGradient => 7, - GpuSceneDrawTag.FillSweepGradient => 5, + GpuSceneDrawTag.FillLinGradient => 11, + GpuSceneDrawTag.FillRadGradient => 13, + GpuSceneDrawTag.FillEllipticGradient => 13, + GpuSceneDrawTag.FillSweepGradient => 11, GpuSceneDrawTag.FillPathGradient => 4, GpuSceneDrawTag.FillImage => 5, GpuSceneDrawTag.EndClip => 0, @@ -6948,12 +6948,36 @@ private static void AppendLinearGradientData( gradientRowCount++; drawData.Add(indexMode); + AppendGradientTransform(brush, ref drawData); drawData.Add(BitcastSingle(brush.StartPoint.X)); drawData.Add(BitcastSingle(brush.StartPoint.Y)); drawData.Add(BitcastSingle(brush.EndPoint.X)); drawData.Add(BitcastSingle(brush.EndPoint.Y)); } + /// + /// Appends the transform from drawing coordinates to the gradient's coordinate space, which + /// is the affine part of the brush's gradient transform inverted. + /// + /// The gradient brush. + /// The draw-data stream. + private static void AppendGradientTransform(GradientBrush brush, ref OwnedStream drawData) + { + // A transform that cannot be inverted has no gradient space. The zero matrix maps every + // sample to the origin, so the brush paints one color where the CPU renderer paints nothing. + if (!brush.TryGetInverseTransform(out Matrix3x2 m)) + { + m = default; + } + + drawData.Add(BitcastSingle(m.M11)); + drawData.Add(BitcastSingle(m.M12)); + drawData.Add(BitcastSingle(m.M21)); + drawData.Add(BitcastSingle(m.M22)); + drawData.Add(BitcastSingle(m.M31)); + drawData.Add(BitcastSingle(m.M32)); + } + /// /// Appends the radial gradient payload and its packed ramp row. /// @@ -6995,6 +7019,7 @@ private static void AppendRadialGradientData( } drawData.Add(indexMode); + AppendGradientTransform(brush, ref drawData); drawData.Add(BitcastSingle(center0.X)); drawData.Add(BitcastSingle(center0.Y)); drawData.Add(BitcastSingle(center1.X)); @@ -7033,6 +7058,7 @@ private static void AppendEllipticGradientData( PointF localSecondEnd = new(localCenter.X + localPerpendicular.X, localCenter.Y + localPerpendicular.Y); drawData.Add(indexMode); + AppendGradientTransform(brush, ref drawData); drawData.Add(BitcastSingle(localCenter.X)); drawData.Add(BitcastSingle(localCenter.Y)); drawData.Add(BitcastSingle(localAxisEnd.X)); @@ -7074,6 +7100,7 @@ private static void AppendSweepGradientData( float t1 = t0 + (sweepDegrees / 360F); drawData.Add(indexMode); + AppendGradientTransform(brush, ref drawData); drawData.Add(BitcastSingle(brush.Center.X)); drawData.Add(BitcastSingle(brush.Center.Y)); drawData.Add(BitcastSingle(t0)); diff --git a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs index 670ed01a..b7a052f7 100644 --- a/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs +++ b/tests/ImageSharp.Drawing.Tests/Processing/Backends/WebGPUDrawingBackendTests.cs @@ -2841,6 +2841,87 @@ public void FillPath_WithRadialGradientBrush_TwoCircle_MatchesDefaultOutput(TestImageProvider provider) + where TPixel : unmanaged, IPixel + { + DrawingOptions drawingOptions = new() + { + GraphicsOptions = new GraphicsOptions { Antialias = true }, + Transform = Matrix4x4.CreateRotationZ(0.2F) * Matrix4x4.CreateTranslation(20F, -10F, 0F) + }; + + // Squashed, skewed and reflected gradient transforms with a transparent middle stop, + // the shapes color emoji fonts produce, under a rotated drawing transform. + Matrix4x4 squash = new(1.4F, 0.1F, 0F, 0F, -0.3F, 0.7F, 0F, 0F, 0F, 0F, 1F, 0F, 60F, 70F, 0F, 1F); + Matrix4x4 flip = Matrix4x4.CreateScale(-1F, 1F, 1F) * Matrix4x4.CreateTranslation(300F, 0F, 0F); + Matrix4x4 skew = new(1F, 0F, 0F, 0F, 0.6F, 1F, 0F, 0F, 0F, 0F, 1F, 0F, 0F, 0F, 0F, 1F); + ColorStop[] stops = + [ + new ColorStop(0, Color.Red), + new ColorStop(0.5F, Color.Lime.WithAlpha(0F)), + new ColorStop(1, Color.Blue) + ]; + + RectanglePolygon first = new(10, 10, 95, 180); + RectanglePolygon second = new(112, 10, 95, 180); + RectanglePolygon third = new(215, 10, 95, 180); + RectanglePolygon fourth = new(318, 10, 95, 180); + Brush twoCircle = new RadialGradientBrush( + new PointF(20F, 40F), + 10F, + new PointF(40F, 40F), + 60F, + GradientRepetitionMode.Reflect, + squash, + stops); + Brush singleCircle = new RadialGradientBrush( + new PointF(78F, 32F), + 40F, + GradientRepetitionMode.None, + squash, + stops); + Brush sweep = new SweepGradientBrush( + new PointF(60F, 100F), + 20F, + 340F, + GradientRepetitionMode.None, + flip, + stops); + Brush linear = new LinearGradientBrush( + new PointF(318F, 0F), + new PointF(413F, 0F), + GradientRepetitionMode.Repeat, + skew, + stops); + + void DrawAction(DrawingCanvas canvas) + { + canvas.Fill(twoCircle, first); + canvas.Fill(singleCircle, second); + canvas.Fill(sweep, third); + canvas.Fill(linear, fourth); + } + + using Image defaultImage = provider.GetImage(); + RenderWithDefaultBackend(defaultImage, drawingOptions, DrawAction); + + using WebGPUDrawingBackend nativeSurfaceBackend = new(); + using Image nativeSurfaceInitialImage = provider.GetImage(); + using Image nativeSurfaceImage = RenderWithNativeSurfaceWebGpuBackend( + defaultImage.Width, + defaultImage.Height, + nativeSurfaceBackend, + drawingOptions, + DrawAction, + nativeSurfaceInitialImage); + + DebugSaveBackendPair(provider, null, defaultImage, nativeSurfaceImage); + AssertBackendPairSimilarity(defaultImage, nativeSurfaceImage, 0.0267F); + AssertBackendPairReferenceOutputs(provider, null, defaultImage, nativeSurfaceImage); + } + [WebGPUFact] public void FillPath_WithSwappedRadialDontFill_ComposesTransparentOutsideGradient() { diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png new file mode 100644 index 00000000..bdc65683 --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_Default.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a02bc1012d5b6c5ca0b2fe348a043eec4d4ab7d1d42842d2e9d45fed17adce54 +size 19283 diff --git a/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png new file mode 100644 index 00000000..63e65b5b --- /dev/null +++ b/tests/Images/ReferenceOutput/Drawing/WebGPUDrawingBackendTests/FillPath_WithTransformedGradientBrushes_MatchesDefaultOutput_WebGPU_NativeSurface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c7b8eea469422124ee23f3307da8cffbec6a41bb47004441949f74cdff2b69a +size 19203 From 57892f9cd26eea841444e072593bbc3f8b036a69 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 17 Sep 2026 12:06:46 +1000 Subject: [PATCH 3/3] Reference SixLabors.Fonts 3.1.3 --- src/ImageSharp.Drawing/ImageSharp.Drawing.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj index c1795038..d19ef483 100644 --- a/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj +++ b/src/ImageSharp.Drawing/ImageSharp.Drawing.csproj @@ -44,7 +44,7 @@ - +