diff --git a/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKMatrixBenchmark.cs b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKMatrixBenchmark.cs new file mode 100644 index 00000000000..aeeace2b4d1 --- /dev/null +++ b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKMatrixBenchmark.cs @@ -0,0 +1,296 @@ +using System.Runtime.InteropServices; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +namespace SkiaSharp.Benchmarks; + +// Before/after comparison for issue #2779: the "Native" methods call the Skia +// C API directly (the path the old SKMatrix used) and the "Managed" methods use +// the new managed SKMatrix math. They must produce identical results; the goal +// is to show the managed path avoids the P/Invoke transition cost. +// +// This class covers the scalar / single-value operations. The batch operations +// that take arrays are parameterised by item count in SKMatrixMapBatchBenchmark. +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public unsafe class SKMatrixBenchmark +{ + private const string SKIA = "libSkiaSharp"; + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern bool sk_matrix_try_invert(SKMatrix* matrix, SKMatrix* result); + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern void sk_matrix_concat(SKMatrix* result, SKMatrix* first, SKMatrix* second); + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern void sk_matrix_map_xy(SKMatrix* matrix, float x, float y, SKPoint* result); + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern void sk_matrix_map_vector(SKMatrix* matrix, float x, float y, SKPoint* result); + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern void sk_matrix_map_rect(SKMatrix* matrix, SKRect* dest, SKRect* source); + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern float sk_matrix_map_radius(SKMatrix* matrix, float radius); + + private SKMatrix affine; + private SKMatrix affine2; + private SKMatrix translate; + private SKMatrix perspective; + private readonly SKRect rect = new SKRect(1, 2, 300, 400); + + [GlobalSetup] + public void Setup() + { + affine = SKMatrix.CreateRotationDegrees(33); + affine.ScaleX *= 1.5f; + affine.TransX = 12; + affine.TransY = -7; + + affine2 = SKMatrix.CreateScaleTranslation(0.75f, 1.25f, 5, 9); + translate = SKMatrix.CreateTranslation(12, -7); + perspective = new SKMatrix(1.2f, 0.1f, 10, -0.2f, 0.9f, 20, 0.001f, 0.002f, 1); + } + + // ===== Invert ===== + + [BenchmarkCategory("Invert"), Benchmark(Baseline = true)] + public SKMatrix Invert_Native() + { + SKMatrix m = affine, result; + sk_matrix_try_invert(&m, &result); + return result; + } + + [BenchmarkCategory("Invert"), Benchmark] + public SKMatrix Invert_Managed() + { + affine.TryInvert(out var result); + return result; + } + + // ===== Concat ===== + + [BenchmarkCategory("Concat"), Benchmark(Baseline = true)] + public SKMatrix Concat_Native() + { + SKMatrix a = affine, b = affine2, result; + sk_matrix_concat(&result, &a, &b); + return result; + } + + [BenchmarkCategory("Concat"), Benchmark] + public SKMatrix Concat_Managed() => + SKMatrix.Concat(affine, affine2); + + // ===== MapPoint ===== + + [BenchmarkCategory("MapPoint"), Benchmark(Baseline = true)] + public SKPoint MapPoint_Native() + { + SKMatrix m = affine; + SKPoint result; + sk_matrix_map_xy(&m, 12.5f, -7.5f, &result); + return result; + } + + [BenchmarkCategory("MapPoint"), Benchmark] + public SKPoint MapPoint_Managed() => + affine.MapPoint(12.5f, -7.5f); + + // ===== MapVector ===== + + [BenchmarkCategory("MapVector"), Benchmark(Baseline = true)] + public SKPoint MapVector_Native() + { + SKMatrix m = affine; + SKPoint result; + sk_matrix_map_vector(&m, 12.5f, -7.5f, &result); + return result; + } + + [BenchmarkCategory("MapVector"), Benchmark] + public SKPoint MapVector_Managed() => + affine.MapVector(12.5f, -7.5f); + + // ===== MapRect (affine) ===== + + [BenchmarkCategory("MapRect"), Benchmark(Baseline = true)] + public SKRect MapRect_Native() + { + SKMatrix m = affine; + SKRect src = rect, result; + sk_matrix_map_rect(&m, &result, &src); + return result; + } + + [BenchmarkCategory("MapRect"), Benchmark] + public SKRect MapRect_Managed() => + affine.MapRect(rect); + + // ===== MapRect: scale and translate fast paths (managed SortAsRect) ===== + + [BenchmarkCategory("MapRectScale"), Benchmark(Baseline = true)] + public SKRect MapRectScale_Native() + { + SKMatrix m = affine2; + SKRect src = rect, result; + sk_matrix_map_rect(&m, &result, &src); + return result; + } + + [BenchmarkCategory("MapRectScale"), Benchmark] + public SKRect MapRectScale_Managed() => + affine2.MapRect(rect); + + [BenchmarkCategory("MapRectTranslate"), Benchmark(Baseline = true)] + public SKRect MapRectTranslate_Native() + { + SKMatrix m = translate; + SKRect src = rect, result; + sk_matrix_map_rect(&m, &result, &src); + return result; + } + + [BenchmarkCategory("MapRectTranslate"), Benchmark] + public SKRect MapRectTranslate_Managed() => + translate.MapRect(rect); + + // ===== MapRadius ===== + + [BenchmarkCategory("MapRadius"), Benchmark(Baseline = true)] + public float MapRadius_Native() + { + SKMatrix m = affine; + return sk_matrix_map_radius(&m, 25f); + } + + [BenchmarkCategory("MapRadius"), Benchmark] + public float MapRadius_Managed() => + affine.MapRadius(25f); + + // ===== MapPoint on a perspective matrix ===== + + [BenchmarkCategory("MapPointPersp"), Benchmark(Baseline = true)] + public SKPoint MapPointPersp_Native() + { + SKMatrix m = perspective; + SKPoint result; + sk_matrix_map_xy(&m, 12.5f, -7.5f, &result); + return result; + } + + [BenchmarkCategory("MapPointPersp"), Benchmark] + public SKPoint MapPointPersp_Managed() => + perspective.MapPoint(12.5f, -7.5f); +} + +// Batch map operations parameterised by item count so we can see how the managed +// SIMD path scales versus Skia's native procs from small arrays (where the +// per-call P/Invoke transition dominates) up to very large arrays (where it is +// fully amortised and it is pure SIMD-vs-SIMD). Each matrix type exercises a +// different native proc: affine -> Affine_vpts, scale -> Scale_pts, +// translate -> Trans_pts. +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public unsafe class SKMatrixMapBatchBenchmark +{ + private const string SKIA = "libSkiaSharp"; + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern void sk_matrix_map_points(SKMatrix* matrix, SKPoint* dst, SKPoint* src, int count); + + [DllImport(SKIA, CallingConvention = CallingConvention.Cdecl)] + private static extern void sk_matrix_map_vectors(SKMatrix* matrix, SKPoint* dst, SKPoint* src, int count); + + // low, typical/medium, high and very high item counts + [Params(4, 64, 1024, 1_000_000)] + public int Count; + + private SKMatrix affine; + private SKMatrix scale; + private SKMatrix translate; + private SKPoint[] src; + private SKPoint[] dst; + + [GlobalSetup] + public void Setup() + { + affine = SKMatrix.CreateRotationDegrees(33); + affine.ScaleX *= 1.5f; + affine.TransX = 12; + affine.TransY = -7; + + scale = SKMatrix.CreateScaleTranslation(0.75f, 1.25f, 5, 9); + translate = SKMatrix.CreateTranslation(12, -7); + + src = new SKPoint[Count]; + dst = new SKPoint[Count]; + for (var i = 0; i < src.Length; i++) + src[i] = new SKPoint(i * 0.5f, i * -0.25f); + } + + // ===== MapPoints: affine proc (Affine_vpts, uses the swizzle) ===== + + [BenchmarkCategory("MapPoints (affine)"), Benchmark(Baseline = true)] + public void MapPointsAffine_Native() + { + SKMatrix m = affine; + fixed (SKPoint* s = src) + fixed (SKPoint* d = dst) + sk_matrix_map_points(&m, d, s, src.Length); + } + + [BenchmarkCategory("MapPoints (affine)"), Benchmark] + public void MapPointsAffine_Managed() => + affine.MapPoints(dst, src); + + // ===== MapPoints: scale proc (Scale_pts) ===== + + [BenchmarkCategory("MapPoints (scale)"), Benchmark(Baseline = true)] + public void MapPointsScale_Native() + { + SKMatrix m = scale; + fixed (SKPoint* s = src) + fixed (SKPoint* d = dst) + sk_matrix_map_points(&m, d, s, src.Length); + } + + [BenchmarkCategory("MapPoints (scale)"), Benchmark] + public void MapPointsScale_Managed() => + scale.MapPoints(dst, src); + + // ===== MapPoints: translate proc (Trans_pts) ===== + + [BenchmarkCategory("MapPoints (translate)"), Benchmark(Baseline = true)] + public void MapPointsTranslate_Native() + { + SKMatrix m = translate; + fixed (SKPoint* s = src) + fixed (SKPoint* d = dst) + sk_matrix_map_points(&m, d, s, src.Length); + } + + [BenchmarkCategory("MapPoints (translate)"), Benchmark] + public void MapPointsTranslate_Managed() => + translate.MapPoints(dst, src); + + // ===== MapVectors: affine (drops translation, then maps) ===== + + [BenchmarkCategory("MapVectors (affine)"), Benchmark(Baseline = true)] + public void MapVectorsAffine_Native() + { + SKMatrix m = affine; + fixed (SKPoint* s = src) + fixed (SKPoint* d = dst) + sk_matrix_map_vectors(&m, d, s, src.Length); + } + + [BenchmarkCategory("MapVectors (affine)"), Benchmark] + public void MapVectorsAffine_Managed() => + affine.MapVectors(dst, src); +} diff --git a/binding/SkiaSharp/SKMatrix.cs b/binding/SkiaSharp/SKMatrix.cs index 836b58a15e5..60756ad643c 100644 --- a/binding/SkiaSharp/SKMatrix.cs +++ b/binding/SkiaSharp/SKMatrix.cs @@ -1,6 +1,12 @@ #nullable disable using System; +using System.Numerics; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +#if NET8_0_OR_GREATER +using System.Runtime.Intrinsics; +#endif namespace SkiaSharp { @@ -12,6 +18,19 @@ public unsafe partial struct SKMatrix public readonly static SKMatrix Identity = new SKMatrix { scaleX = 1, scaleY = 1, persp2 = 1 }; + // On x86 .NET Framework the runtime JIT targets the legacy x87 FPU, which + // evaluates intermediate float expressions with 80-bit extended precision + // (permitted by ECMA-334 §8.3.7). That makes the managed math below diverge + // from native Skia's per-operation SSE rounding by thousands of ULP in + // cancellation-prone paths, which would change rendering output. On that one + // legacy runtime we keep routing the math through the native C API to stay + // byte-for-byte identical with previous releases. Every other runtime (x64 + // .NET Framework and all .NET Core / .NET 5+ on any architecture) rounds each + // operation to float like native, so it takes the fast managed path. + private static readonly bool UseNativeMath = + RuntimeInformation.ProcessArchitecture == Architecture.X86 && + RuntimeInformation.FrameworkDescription.StartsWith (".NET Framework", StringComparison.Ordinal); + private class Indices { public const int ScaleX = 0; @@ -242,18 +261,31 @@ public static SKMatrix CreateScaleTranslation (float sx, float sy, float tx, flo public readonly bool IsInvertible { get { - fixed (SKMatrix* t = &this) { - return SkiaApi.sk_matrix_try_invert (t, null); + if (UseNativeMath) { + fixed (SKMatrix* t = &this) + return SkiaApi.sk_matrix_try_invert (t, null); } + + return TryInvertInternal (out _); } } public readonly bool TryInvert (out SKMatrix inverse) { - fixed (SKMatrix* i = &inverse) - fixed (SKMatrix* t = &this) { - return SkiaApi.sk_matrix_try_invert (t, i); + if (UseNativeMath) { + fixed (SKMatrix* i = &inverse) + fixed (SKMatrix* t = &this) + return SkiaApi.sk_matrix_try_invert (t, i); } + + if (TryInvertInternal (out inverse)) + return true; + + // Match the native shim, which leaves its out matrix as the identity it + // was default-constructed with when the matrix is non-invertible. (A + // default-constructed SKMatrix in C# is all-zero, so set it explicitly.) + inverse = Identity; + return false; } public readonly SKMatrix Invert () @@ -268,41 +300,86 @@ public readonly SKMatrix Invert () public static SKMatrix Concat (SKMatrix first, SKMatrix second) { - SKMatrix target; - SkiaApi.sk_matrix_concat (&target, &first, &second); - return target; + if (UseNativeMath) { + SKMatrix target; + SkiaApi.sk_matrix_concat (&target, &first, &second); + return target; + } + + return SetConcat (first, second); } public readonly SKMatrix PreConcat (SKMatrix matrix) { - var target = this; - SkiaApi.sk_matrix_pre_concat (&target, &matrix); - return target; + if (UseNativeMath) { + var target = this; + SkiaApi.sk_matrix_pre_concat (&target, &matrix); + return target; + } + + return matrix.GetMatrixType () == TypeMaskIdentity ? this : SetConcat (this, matrix); } public readonly SKMatrix PostConcat (SKMatrix matrix) { - var target = this; - SkiaApi.sk_matrix_post_concat (&target, &matrix); - return target; + if (UseNativeMath) { + var target = this; + SkiaApi.sk_matrix_post_concat (&target, &matrix); + return target; + } + + return matrix.GetMatrixType () == TypeMaskIdentity ? this : SetConcat (matrix, this); } public static void Concat (ref SKMatrix target, SKMatrix first, SKMatrix second) { - fixed (SKMatrix* t = &target) { - SkiaApi.sk_matrix_concat (t, &first, &second); + if (UseNativeMath) { + fixed (SKMatrix* t = &target) + SkiaApi.sk_matrix_concat (t, &first, &second); + return; } + + target = SetConcat (first, second); } // MapRect public readonly SKRect MapRect (SKRect source) { - SKRect dest; - fixed (SKMatrix* m = &this) { - SkiaApi.sk_matrix_map_rect (m, &dest, &source); + if (UseNativeMath) { + SKRect dest; + fixed (SKMatrix* m = &this) + SkiaApi.sk_matrix_map_rect (m, &dest, &source); + return dest; } - return dest; + + var type = GetMatrixType (); + + // identity or translate + if (type <= TypeMaskTranslate) { + return SortAsRect ( + source.Left + transX, source.Top + transY, + source.Right + transX, source.Bottom + transY); + } + + // scale and/or translate + if ((type & (TypeMaskAffine | TypeMaskPerspective)) == 0) { + return SortAsRect ( + source.Left * scaleX + transX, source.Top * scaleY + transY, + source.Right * scaleX + transX, source.Bottom * scaleY + transY); + } + + // perspective: fall back to the native path-clipping implementation + if ((type & TypeMaskPerspective) != 0) { + SKRect dest; + fixed (SKMatrix* m = &this) { + SkiaApi.sk_matrix_map_rect (m, &dest, &source); + } + return dest; + } + + // affine + return MapRectAffine (source); } // MapPoints @@ -312,11 +389,20 @@ public readonly SKPoint MapPoint (SKPoint point) => public readonly SKPoint MapPoint (float x, float y) { - SKPoint result; - fixed (SKMatrix* t = &this) { - SkiaApi.sk_matrix_map_xy (t, x, y, &result); + if (UseNativeMath) { + SKPoint result; + fixed (SKMatrix* t = &this) + SkiaApi.sk_matrix_map_xy (t, x, y, &result); + return result; } - return result; + + // Mirrors SkMatrix::mapPoint: perspective uses the perspective proc, + // everything else uses the inlined affine math (mapPointAffine) so a + // pure scale/translate still maps through the same expression. + if (HasPerspective) + return MapPerspective (x, y); + + return new SKPoint ((x * scaleX + y * skewX) + transX, (x * skewY + y * scaleY) + transY); } public readonly void MapPoints (Span result, ReadOnlySpan points) @@ -324,11 +410,7 @@ public readonly void MapPoints (Span result, ReadOnlySpan poin if (result.Length != points.Length) throw new ArgumentException ("Buffers must be the same size."); - fixed (SKMatrix* t = &this) - fixed (SKPoint* rp = result) - fixed (SKPoint* pp = points) { - SkiaApi.sk_matrix_map_points (t, rp, pp, result.Length); - } + MapPointsInternal (result, points); } public readonly void MapPoints (SKPoint[] result, SKPoint[] points) @@ -340,11 +422,7 @@ public readonly void MapPoints (SKPoint[] result, SKPoint[] points) if (result.Length != points.Length) throw new ArgumentException ("Buffers must be the same size."); - fixed (SKMatrix* t = &this) - fixed (SKPoint* rp = result) - fixed (SKPoint* pp = points) { - SkiaApi.sk_matrix_map_points (t, rp, pp, result.Length); - } + MapPointsInternal (result, points); } public readonly SKPoint[] MapPoints (SKPoint[] points) @@ -364,11 +442,25 @@ public readonly SKPoint MapVector (SKPoint vector) => public readonly SKPoint MapVector (float x, float y) { - SKPoint result; - fixed (SKMatrix* t = &this) { - SkiaApi.sk_matrix_map_vector (t, x, y, &result); + if (UseNativeMath) { + SKPoint result; + fixed (SKMatrix* t = &this) + SkiaApi.sk_matrix_map_vector (t, x, y, &result); + return result; + } + + if (HasPerspective) { + var v = MapPerspective (x, y); + var o = MapPerspective (0, 0); + return new SKPoint (v.X - o.X, v.Y - o.Y); } - return result; + + // Drop translation, then map as a point through the proc-table math + // (matches SkMatrix::mapVectors, which routes through mapPoints). + var tmp = this; + tmp.transX = 0; + tmp.transY = 0; + return tmp.MapPointByType (x, y); } public readonly void MapVectors (Span result, ReadOnlySpan vectors) @@ -376,11 +468,7 @@ public readonly void MapVectors (Span result, ReadOnlySpan vec if (result.Length != vectors.Length) throw new ArgumentException ("Buffers must be the same size."); - fixed (SKMatrix* t = &this) - fixed (SKPoint* rp = result) - fixed (SKPoint* pp = vectors) { - SkiaApi.sk_matrix_map_vectors (t, rp, pp, result.Length); - } + MapVectorsInternal (result, vectors); } public readonly void MapVectors (SKPoint[] result, SKPoint[] vectors) @@ -392,11 +480,7 @@ public readonly void MapVectors (SKPoint[] result, SKPoint[] vectors) if (result.Length != vectors.Length) throw new ArgumentException ("Buffers must be the same size."); - fixed (SKMatrix* t = &this) - fixed (SKPoint* rp = result) - fixed (SKPoint* pp = vectors) { - SkiaApi.sk_matrix_map_vectors (t, rp, pp, result.Length); - } + MapVectorsInternal (result, vectors); } public readonly SKPoint[] MapVectors (SKPoint[] vectors) @@ -413,9 +497,19 @@ public readonly SKPoint[] MapVectors (SKPoint[] vectors) public readonly float MapRadius (float radius) { - fixed (SKMatrix* t = &this) { - return SkiaApi.sk_matrix_map_radius (t, radius); + if (UseNativeMath) { + fixed (SKMatrix* t = &this) + return SkiaApi.sk_matrix_map_radius (t, radius); } + + var v0 = MapVector (radius, 0); + var v1 = MapVector (0, radius); + + var d0 = PointLength (v0.X, v0.Y); + var d1 = PointLength (v1.X, v1.Y); + + // geometric mean + return (float)Math.Sqrt (d0 * d1); } // private @@ -453,5 +547,558 @@ private static float Dot (float a, float b, float c, float d) => private static float Cross (float a, float b, float c, float d) => a * b - c * d; + + // Managed re-implementation of the SkMatrix math (kept bit-for-bit + // compatible with the native C API so there is no behavioural change). + + // SkMatrix::TypeMask values. + private const int TypeMaskIdentity = 0; + private const int TypeMaskTranslate = 0x01; + private const int TypeMaskScale = 0x02; + private const int TypeMaskAffine = 0x04; + private const int TypeMaskPerspective = 0x08; + private const int TypeMaskRectStaysRect = 0x10; + + // SK_ScalarNearlyZero == SK_Scalar1 / (1 << 12) + private const float ScalarNearlyZero = 1.0f / (1 << 12); + + private readonly bool HasPerspective => + persp0 != 0 || persp1 != 0 || persp2 != 1; + + // Mirrors SkMatrix::computeTypeMask (including the kRectStaysRect bit). + private readonly int GetTypeMask () + { + if (persp0 != 0 || persp1 != 0 || persp2 != 1) { + // Perspective implies every other transform flag (conservative). + return TypeMaskTranslate | TypeMaskScale | TypeMaskAffine | TypeMaskPerspective; + } + + var mask = 0; + + if (transX != 0 || transY != 0) + mask |= TypeMaskTranslate; + + if (skewX != 0 || skewY != 0) { + // Skew always implies scale + affine (matches Skia's conservative rule). + mask |= TypeMaskAffine | TypeMaskScale; + + // rectStaysRect: primary diagonal all zero and secondary diagonal all non-zero. + if (scaleX == 0 && scaleY == 0 && skewX != 0 && skewY != 0) + mask |= TypeMaskRectStaysRect; + } else { + if (scaleX != 1 || scaleY != 1) + mask |= TypeMaskScale; + + if (scaleX != 0 && scaleY != 0) + mask |= TypeMaskRectStaysRect; + } + + return mask; + } + + private readonly int GetMatrixType () => + GetTypeMask () & 0x0F; + + // Invert + + private readonly bool TryInvertInternal (out SKMatrix inverse) + { + var type = GetMatrixType (); + + if (type == TypeMaskIdentity) { + inverse = this; + return true; + } + + // Scale and/or translation only. + if ((type & ~(TypeMaskScale | TypeMaskTranslate)) == 0) { + if ((type & TypeMaskScale) != 0) { + var invSX = 1.0f / scaleX; + var invSY = 1.0f / scaleY; + // Denormalized (non-zero) scale factors overflow when inverted. + if (!IsFinite (invSX, invSY)) { + inverse = default; + return false; + } + + var invTX = -transX * invSX; + var invTY = -transY * invSY; + if (!IsFinite (invTX, invTY)) { + inverse = default; + return false; + } + + inverse = new SKMatrix { + scaleX = invSX, + skewX = 0, + transX = invTX, + skewY = 0, + scaleY = invSY, + transY = invTY, + persp0 = 0, + persp1 = 0, + persp2 = 1, + }; + return true; + } + + // Translate only. + if (!IsFinite (transX, transY)) { + inverse = default; + return false; + } + + inverse = CreateTranslation (-transX, -transY); + return true; + } + + var isPersp = (type & TypeMaskPerspective) != 0; + var invDet = InverseDeterminant (isPersp); + if (invDet == 0) { + inverse = default; + return false; + } + + inverse = ComputeInverse (invDet, isPersp); + if (!inverse.IsFiniteInternal ()) { + inverse = default; + return false; + } + + return true; + } + + private readonly double Determinant (bool isPerspective) + { + if (isPerspective) { + return scaleX * DCross (scaleY, persp2, transY, persp1) + + skewX * DCross (transY, persp0, skewY, persp2) + + transX * DCross (skewY, persp1, scaleY, persp0); + } + + return DCross (scaleX, scaleY, skewX, skewY); + } + + private readonly double InverseDeterminant (bool isPerspective) + { + var det = Determinant (isPerspective); + + // Compare against the cube of the nearly-zero constant since the + // determinant scales with the cube of the matrix members. + var tolerance = ScalarNearlyZero * ScalarNearlyZero * ScalarNearlyZero; + if (Math.Abs ((float)det) <= tolerance) + return 0; + + return 1.0 / det; + } + + private readonly SKMatrix ComputeInverse (double invDet, bool isPersp) + { + SKMatrix inv; + if (isPersp) { + inv.scaleX = ScrossDscale (scaleY, persp2, transY, persp1, invDet); + inv.skewX = ScrossDscale (transX, persp1, skewX, persp2, invDet); + inv.transX = ScrossDscale (skewX, transY, transX, scaleY, invDet); + + inv.skewY = ScrossDscale (transY, persp0, skewY, persp2, invDet); + inv.scaleY = ScrossDscale (scaleX, persp2, transX, persp0, invDet); + inv.transY = ScrossDscale (transX, skewY, scaleX, transY, invDet); + + inv.persp0 = ScrossDscale (skewY, persp1, scaleY, persp0, invDet); + inv.persp1 = ScrossDscale (skewX, persp0, scaleX, persp1, invDet); + inv.persp2 = ScrossDscale (scaleX, scaleY, skewX, skewY, invDet); + } else { + inv.scaleX = (float)(scaleY * invDet); + inv.skewX = (float)(-skewX * invDet); + inv.transX = DcrossDscale (skewX, transY, scaleY, transX, invDet); + + inv.skewY = (float)(-skewY * invDet); + inv.scaleY = (float)(scaleX * invDet); + inv.transY = DcrossDscale (skewY, transX, scaleX, transY, invDet); + + inv.persp0 = 0; + inv.persp1 = 0; + inv.persp2 = 1; + } + return inv; + } + + // *Concat + + private static SKMatrix SetConcat (SKMatrix a, SKMatrix b) + { + var aType = a.GetMatrixType (); + var bType = b.GetMatrixType (); + + if (aType == TypeMaskIdentity) + return b; + if (bType == TypeMaskIdentity) + return a; + + // Scale and/or translation only. + if (((aType | bType) & (TypeMaskAffine | TypeMaskPerspective)) == 0) { + return new SKMatrix { + scaleX = a.scaleX * b.scaleX, + skewX = 0, + transX = a.scaleX * b.transX + a.transX, + skewY = 0, + scaleY = a.scaleY * b.scaleY, + transY = a.scaleY * b.transY + a.transY, + persp0 = 0, + persp1 = 0, + persp2 = 1, + }; + } + + SKMatrix tmp; + if (((aType | bType) & TypeMaskPerspective) != 0) { + tmp.scaleX = RowCol3 (a.scaleX, a.skewX, a.transX, b.scaleX, b.skewY, b.persp0); + tmp.skewX = RowCol3 (a.scaleX, a.skewX, a.transX, b.skewX, b.scaleY, b.persp1); + tmp.transX = RowCol3 (a.scaleX, a.skewX, a.transX, b.transX, b.transY, b.persp2); + tmp.skewY = RowCol3 (a.skewY, a.scaleY, a.transY, b.scaleX, b.skewY, b.persp0); + tmp.scaleY = RowCol3 (a.skewY, a.scaleY, a.transY, b.skewX, b.scaleY, b.persp1); + tmp.transY = RowCol3 (a.skewY, a.scaleY, a.transY, b.transX, b.transY, b.persp2); + tmp.persp0 = RowCol3 (a.persp0, a.persp1, a.persp2, b.scaleX, b.skewY, b.persp0); + tmp.persp1 = RowCol3 (a.persp0, a.persp1, a.persp2, b.skewX, b.scaleY, b.persp1); + tmp.persp2 = RowCol3 (a.persp0, a.persp1, a.persp2, b.transX, b.transY, b.persp2); + } else { + tmp.scaleX = MulAddMul (a.scaleX, b.scaleX, a.skewX, b.skewY); + tmp.skewX = MulAddMul (a.scaleX, b.skewX, a.skewX, b.scaleY); + tmp.transX = MulAddMul (a.scaleX, b.transX, a.skewX, b.transY) + a.transX; + tmp.skewY = MulAddMul (a.skewY, b.scaleX, a.scaleY, b.skewY); + tmp.scaleY = MulAddMul (a.skewY, b.skewX, a.scaleY, b.scaleY); + tmp.transY = MulAddMul (a.skewY, b.transX, a.scaleY, b.transY) + a.transY; + tmp.persp0 = 0; + tmp.persp1 = 0; + tmp.persp2 = 1; + } + return tmp; + } + + // Map points / vectors + + private readonly SKPoint MapPerspective (float x, float y) + { + var px = (x * scaleX + y * skewX) + transX; + var py = (x * skewY + y * scaleY) + transY; + var pz = (x * persp0 + y * persp1) + persp2; + if (pz != 0) + pz = 1.0f / pz; + return new SKPoint (px * pz, py * pz); + } + + // Single-point map that mirrors getMapPtsProc()/the proc table (used by + // mapVectors). Unlike mapPoint, a pure scale/translate avoids the y*kx term. + // The affine term order matches Skia's Affine_vpts proc (the y lane is + // y*scaleY + x*skewY), which differs from mapPointAffine's ordering; the + // distinction only affects NaN-payload propagation but keeps us bit-exact + // with the native mapVectors path. + private readonly SKPoint MapPointByType (float x, float y) + { + var type = GetMatrixType (); + + if ((type & TypeMaskPerspective) != 0) + return MapPerspective (x, y); + if ((type & TypeMaskAffine) != 0) + return new SKPoint ((x * scaleX + y * skewX) + transX, (y * scaleY + x * skewY) + transY); + if ((type & TypeMaskScale) != 0) + return new SKPoint (x * scaleX + transX, y * scaleY + transY); + if ((type & TypeMaskTranslate) != 0) + return new SKPoint (x + transX, y + transY); + return new SKPoint (x, y); + } + + private readonly void MapPointsInternal (Span dst, ReadOnlySpan src) + { + var count = src.Length; + if (count == 0) + return; + + if (UseNativeMath) { + fixed (SKMatrix* m = &this) + fixed (SKPoint* d = dst) + fixed (SKPoint* s = src) + SkiaApi.sk_matrix_map_points (m, d, s, count); + return; + } + + var type = GetMatrixType (); + + if ((type & TypeMaskPerspective) != 0) { + for (var i = 0; i < count; i++) { + var p = src[i]; + dst[i] = MapPerspective (p.X, p.Y); + } + } else if ((type & TypeMaskAffine) != 0) { + MapAffineBatch (dst, src); + } else if ((type & TypeMaskScale) != 0) { + MapScaleBatch (dst, src); + } else if ((type & TypeMaskTranslate) != 0) { + MapTranslateBatch (dst, src); + } else { + src.CopyTo (dst); + } + } + + // SIMD batch map procs. Each Vector4 holds two points (x0, y0, x1, y1) + // which mirrors Skia's skvx::float4 procs (Trans_pts/Scale_pts/Affine_vpts) + // exactly, so the result is bit-for-bit identical to the native path. We + // process two points per iteration and handle a trailing odd point with + // the scalar formula (IEEE addition is commutative, so it is bit-identical). + // The SKPoint buffers carry no 16-byte alignment guarantee, so the vector + // reads/writes go through Unsafe.ReadUnaligned/WriteUnaligned (which lower to + // the same unaligned moves the JIT already emits, so there is no perf cost). + + // Swaps the X/Y lanes of each point pair: (x0,y0,x1,y1) -> (y0,x0,y1,x1). +#if NET8_0_OR_GREATER + [System.Runtime.CompilerServices.MethodImpl (System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] + private static Vector4 SwapXY (Vector4 v) => + Vector128.Shuffle (v.AsVector128 (), Vector128.Create (1, 0, 3, 2)).AsVector4 (); +#else + private static Vector4 SwapXY (Vector4 v) => + new Vector4 (v.Y, v.X, v.W, v.Z); +#endif + + private readonly void MapTranslateBatch (Span dst, ReadOnlySpan src) + { + var count = src.Length; + float tx = transX, ty = transY; + var trans = new Vector4 (tx, ty, tx, ty); + + // Mirrors Skia's Trans_pts: a scalar lead-in for the odd point, then + // one pair, then the main loop of two float4 (four points) per iteration. + fixed (SKPoint* sp = src) + fixed (SKPoint* dp = dst) { + var s = (float*)sp; + var d = (float*)dp; + var n = count; + if ((n & 1) != 0) { + d[0] = s[0] + tx; + d[1] = s[1] + ty; + s += 2; d += 2; + } + n >>= 1; + if ((n & 1) != 0) { + Unsafe.WriteUnaligned (d, Unsafe.ReadUnaligned (s) + trans); + s += 4; d += 4; + } + n >>= 1; + for (var i = 0; i < n; i++) { + Unsafe.WriteUnaligned (d + 0, Unsafe.ReadUnaligned (s + 0) + trans); + Unsafe.WriteUnaligned (d + 4, Unsafe.ReadUnaligned (s + 4) + trans); + s += 8; d += 8; + } + } + } + + private readonly void MapScaleBatch (Span dst, ReadOnlySpan src) + { + var count = src.Length; + float sx = scaleX, sy = scaleY, tx = transX, ty = transY; + var scale = new Vector4 (sx, sy, sx, sy); + var trans = new Vector4 (tx, ty, tx, ty); + + // Mirrors Skia's Scale_pts: scalar lead-in, then one pair, then four + // points (two float4) per iteration so the multiplies pipeline. + fixed (SKPoint* sp = src) + fixed (SKPoint* dp = dst) { + var s = (float*)sp; + var d = (float*)dp; + var n = count; + if ((n & 1) != 0) { + d[0] = s[0] * sx + tx; + d[1] = s[1] * sy + ty; + s += 2; d += 2; + } + n >>= 1; + if ((n & 1) != 0) { + Unsafe.WriteUnaligned (d, Unsafe.ReadUnaligned (s) * scale + trans); + s += 4; d += 4; + } + n >>= 1; + for (var i = 0; i < n; i++) { + Unsafe.WriteUnaligned (d + 0, Unsafe.ReadUnaligned (s + 0) * scale + trans); + Unsafe.WriteUnaligned (d + 4, Unsafe.ReadUnaligned (s + 4) * scale + trans); + s += 8; d += 8; + } + } + } + + private readonly void MapAffineBatch (Span dst, ReadOnlySpan src) + { + var count = src.Length; + float sx = scaleX, sy = scaleY, kx = skewX, ky = skewY, tx = transX, ty = transY; + var scale = new Vector4 (sx, sy, sx, sy); + var skew = new Vector4 (kx, ky, kx, ky); + var trans = new Vector4 (tx, ty, tx, ty); + var pairs = count >> 1; + + fixed (SKPoint* sp = src) + fixed (SKPoint* dp = dst) { + var s = (float*)sp; + var d = (float*)dp; + for (var i = 0; i < pairs; i++) { + var o = i << 2; + var v = Unsafe.ReadUnaligned (s + o); + Unsafe.WriteUnaligned (d + o, v * scale + SwapXY (v) * skew + trans); + } + } + + if ((count & 1) != 0) { + // Term order matches Affine_vpts' trailing-element lane: x*sx + y*kx + // for the x lane and y*sy + x*ky for the y lane. + var p = src[count - 1]; + dst[count - 1] = new SKPoint ((p.X * sx + p.Y * kx) + tx, (p.Y * sy + p.X * ky) + ty); + } + } + + private readonly void MapVectorsInternal (Span dst, ReadOnlySpan src) + { + if (UseNativeMath) { + var count = src.Length; + if (count == 0) + return; + fixed (SKMatrix* m = &this) + fixed (SKPoint* d = dst) + fixed (SKPoint* s = src) + SkiaApi.sk_matrix_map_vectors (m, d, s, count); + return; + } + + if (HasPerspective) { + // Iterate back-to-front to match SkMatrix::mapVectors, which walks + // the perspective case in reverse so overlapping dst/src spans + // (dst ahead of src) produce the same result as the native path. + var origin = MapPerspective (0, 0); + for (var i = src.Length - 1; i >= 0; i--) { + var v = MapPerspective (src[i].X, src[i].Y); + dst[i] = new SKPoint (v.X - origin.X, v.Y - origin.Y); + } + return; + } + + // Drop translation, then map as points. + var tmp = this; + tmp.transX = 0; + tmp.transY = 0; + tmp.MapPointsInternal (dst, src); + } + + // MapRect + + private readonly SKRect MapRectAffine (SKRect src) + { + float sx = scaleX, sy = scaleY, kx = skewX, ky = skewY, tx = transX, ty = transY; + float l = src.Left, t = src.Top, r = src.Right, b = src.Bottom; + + // Map the four corners using the affine procs. + var x0 = (l * sx + t * kx) + tx; + var y0 = (l * ky + t * sy) + ty; + var x1 = (r * sx + t * kx) + tx; + var y1 = (r * ky + t * sy) + ty; + var x2 = (r * sx + b * kx) + tx; + var y2 = (r * ky + b * sy) + ty; + var x3 = (l * sx + b * kx) + tx; + var y3 = (l * ky + b * sy) + ty; + + // SkRect::Bounds (64-bit variant): min/max plus a finiteness probe. + var minX = x0; var minY = y0; var maxX = x0; var maxY = y0; + minX = Math.Min (x1, minX); minY = Math.Min (y1, minY); maxX = Math.Max (x1, maxX); maxY = Math.Max (y1, maxY); + minX = Math.Min (x2, minX); minY = Math.Min (y2, minY); maxX = Math.Max (x2, maxX); maxY = Math.Max (y2, maxY); + minX = Math.Min (x3, minX); minY = Math.Min (y3, minY); maxX = Math.Max (x3, maxX); maxY = Math.Max (y3, maxY); + + float nx = 0, ny = 0; + nx *= x0; ny *= y0; + nx *= x1; ny *= y1; + nx *= x2; ny *= y2; + nx *= x3; ny *= y3; + + if (nx == 0 && ny == 0) + return new SKRect (minX, minY, maxX, maxY); + + return new SKRect (float.NaN, float.NaN, float.NaN, float.NaN); + } + + // Mirrors the skvx sort_as_rect helper used by SkMatrix::mapRect for the + // translate and scale/translate fast paths. skvx::min/max keep the FIRST + // operand when either side is NaN, which differs from both Vector4.Min/Max + // and MathF.Min/Max (IEEE NaN-propagating) and from SKRect.Standardized + // (which compares with '>' and so keeps a different operand on NaN). Those + // paths have no finiteness probe, so a non-finite lane (e.g. Inf*0 = NaN + // from an infinite scale) flows straight into the result and the choice is + // value-visible; hence the hand-rolled scalar form below, which is also the + // only one available on every target (MathF is absent on net462/net48/ + // netstandard2.0). ltrb = (l, t, r, b), rblt = (r, b, l, t); result is + // (min[2], min[3], max[0], max[1]). + private static SKRect SortAsRect (float l, float t, float r, float b) => + new SKRect (SkvxMin (r, l), SkvxMin (b, t), SkvxMax (l, r), SkvxMax (t, b)); + + // skvx::min(x, y) == (y < x) ? y : x + private static float SkvxMin (float x, float y) => + y < x ? y : x; + + // skvx::max(x, y) == (x < y) ? y : x + private static float SkvxMax (float x, float y) => + x < y ? y : x; + + // MapRadius + + private static float PointLength (float dx, float dy) + { + var mag2 = dx * dx + dy * dy; + if (IsFinite (mag2)) + return (float)Math.Sqrt (mag2); + + double xx = dx; + double yy = dy; + return (float)Math.Sqrt (xx * xx + yy * yy); + } + + // Math helpers (kept bit-identical to the SkMatrix C++ helpers). + + private readonly bool IsFiniteInternal () + { + var prod = scaleX - scaleX; + prod *= skewX; + prod *= transX; + prod *= skewY; + prod *= scaleY; + prod *= transY; + prod *= persp0; + prod *= persp1; + prod *= persp2; + return !float.IsNaN (prod); + } + + private static bool IsFinite (float x) + { + var prod = x - x; + return !float.IsNaN (prod); + } + + private static bool IsFinite (float a, float b) + { + var prod = (a - a) * b; + return !float.IsNaN (prod); + } + + private static double DCross (double a, double b, double c, double d) => + a * b - c * d; + + private static float ScrossDscale (float a, float b, float c, float d, double scale) + { + // scross is computed in float, then scaled in double. + var scross = a * b - c * d; + return (float)(scross * scale); + } + + private static float DcrossDscale (double a, double b, double c, double d, double scale) => + (float)((a * b - c * d) * scale); + + private static float MulAddMul (float a, float b, float c, float d) => + (float)((double)a * b + (double)c * d); + + private static float RowCol3 (float r0, float r1, float r2, float c0, float c3, float c6) => + r0 * c0 + r1 * c3 + r2 * c6; } } diff --git a/tests/Tests/SkiaSharp/SKMatrixManagedTests.cs b/tests/Tests/SkiaSharp/SKMatrixManagedTests.cs new file mode 100644 index 00000000000..d9be3ec6cde --- /dev/null +++ b/tests/Tests/SkiaSharp/SKMatrixManagedTests.cs @@ -0,0 +1,488 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace SkiaSharp.Tests +{ + // Verifies that the managed SKMatrix math (which replaced the native interop + // calls) matches the native Skia C API across a wide range of matrices, points, + // rectangles and edge cases: bit-for-bit on finite results, and a NaN wherever + // the native path yields a NaN (see BitEqual for the NaN-payload caveat). + public unsafe class SKMatrixManagedTest : SKTest + { + // ===== native helpers (call the C API directly) ===== + + private static bool NativeTryInvert (SKMatrix m, out SKMatrix inverse) + { + // The native shim default-constructs its out SkMatrix (which is identity + // in C++) and leaves it untouched when the matrix is non-invertible, so it + // always writes a value. Initialise to Identity anyway so the test never + // depends on the shim writing on the failure path. + var result = SKMatrix.Identity; + var ok = SkiaApi.sk_matrix_try_invert (&m, &result); + inverse = result; + return ok; + } + + private static bool NativeIsInvertible (SKMatrix m) => + SkiaApi.sk_matrix_try_invert (&m, null); + + private static SKMatrix NativeConcat (SKMatrix a, SKMatrix b) + { + SKMatrix result = default; + SkiaApi.sk_matrix_concat (&result, &a, &b); + return result; + } + + private static SKMatrix NativePreConcat (SKMatrix target, SKMatrix m) + { + SkiaApi.sk_matrix_pre_concat (&target, &m); + return target; + } + + private static SKMatrix NativePostConcat (SKMatrix target, SKMatrix m) + { + SkiaApi.sk_matrix_post_concat (&target, &m); + return target; + } + + private static SKPoint NativeMapPoint (SKMatrix m, float x, float y) + { + SKPoint result; + SkiaApi.sk_matrix_map_xy (&m, x, y, &result); + return result; + } + + private static SKPoint NativeMapVector (SKMatrix m, float x, float y) + { + SKPoint result; + SkiaApi.sk_matrix_map_vector (&m, x, y, &result); + return result; + } + + private static SKPoint[] NativeMapPoints (SKMatrix m, SKPoint[] src) + { + var dst = new SKPoint[src.Length]; + fixed (SKPoint* s = src) + fixed (SKPoint* d = dst) + SkiaApi.sk_matrix_map_points (&m, d, s, src.Length); + return dst; + } + + private static SKPoint[] NativeMapVectors (SKMatrix m, SKPoint[] src) + { + var dst = new SKPoint[src.Length]; + fixed (SKPoint* s = src) + fixed (SKPoint* d = dst) + SkiaApi.sk_matrix_map_vectors (&m, d, s, src.Length); + return dst; + } + + private static SKPoint[] NativeMapPointsInPlace (SKMatrix m, SKPoint[] src) + { + var buf = (SKPoint[])src.Clone (); + fixed (SKPoint* p = buf) + SkiaApi.sk_matrix_map_points (&m, p, p, buf.Length); + return buf; + } + + private static SKPoint[] NativeMapVectorsInPlace (SKMatrix m, SKPoint[] src) + { + var buf = (SKPoint[])src.Clone (); + fixed (SKPoint* p = buf) + SkiaApi.sk_matrix_map_vectors (&m, p, p, buf.Length); + return buf; + } + + private static SKRect NativeMapRect (SKMatrix m, SKRect src) + { + SKRect dst; + SkiaApi.sk_matrix_map_rect (&m, &dst, &src); + return dst; + } + + private static float NativeMapRadius (SKMatrix m, float radius) => + SkiaApi.sk_matrix_map_radius (&m, radius); + + // ===== bit-exact comparison helpers ===== + + private static int FloatBits (float f) => + *(int*)&f; + + // Finite results must be bit-identical. For non-finite results we require a + // NaN wherever the native path produces a NaN (a NaN-vs-finite mismatch is a + // real divergence and still fails below), but we do NOT compare NaN payloads: + // the two paths legitimately reach NaN by different routes and can disagree on + // the sign/payload bits (e.g. MapRect of a rotation over an infinite rect gives + // native 0x7FC00000 vs managed 0xFFC00000 — same quiet NaN, opposite sign). Skia + // never inspects NaN payloads, so that difference is not significant. + private static bool BitEqual (float a, float b) => + (float.IsNaN (a) && float.IsNaN (b)) || + FloatBits (a) == FloatBits (b); + + private static void AssertFloat (float native, float managed, string what) + { + if (!BitEqual (native, managed)) { + var nb = FloatBits (native); + var mb = FloatBits (managed); + Assert.Fail ($"{what}: native={native} (0x{nb:X8}) != managed={managed} (0x{mb:X8})"); + } + } + + private static void AssertMatrix (SKMatrix native, SKMatrix managed, string what) + { + var n = native.Values; + var m = managed.Values; + for (var i = 0; i < 9; i++) + AssertFloat (n[i], m[i], $"{what}[{i}]"); + } + + private static void AssertPoint (SKPoint native, SKPoint managed, string what) + { + AssertFloat (native.X, managed.X, $"{what}.X"); + AssertFloat (native.Y, managed.Y, $"{what}.Y"); + } + + private static void AssertRect (SKRect native, SKRect managed, string what) + { + AssertFloat (native.Left, managed.Left, $"{what}.Left"); + AssertFloat (native.Top, managed.Top, $"{what}.Top"); + AssertFloat (native.Right, managed.Right, $"{what}.Right"); + AssertFloat (native.Bottom, managed.Bottom, $"{what}.Bottom"); + } + + // ===== test data ===== + + private static IEnumerable GetTestMatrices () + { + // identity, translate, scale, scale + translate + yield return SKMatrix.Identity; + yield return SKMatrix.CreateTranslation (10, 20); + yield return SKMatrix.CreateTranslation (-3.5f, 7.25f); + yield return SKMatrix.CreateScale (2, 3); + yield return SKMatrix.CreateScale (-2, 0.5f); + yield return SKMatrix.CreateScale (2, 3, 4, 5); + yield return SKMatrix.CreateScaleTranslation (1.5f, -2.5f, 10, -20); + + // rotation / skew / affine + yield return SKMatrix.CreateRotationDegrees (30); + yield return SKMatrix.CreateRotationDegrees (90); + yield return SKMatrix.CreateRotationDegrees (-123.4f, 5, 6); + yield return SKMatrix.CreateSkew (0.3f, -0.7f); + yield return new SKMatrix (1, 0.5f, 10, -0.25f, 2, -5, 0, 0, 1); + + // degenerate (non-invertible) + yield return SKMatrix.CreateScale (0, 1); + yield return SKMatrix.CreateScale (1, 0); + yield return new SKMatrix (0, 0, 0, 0, 0, 0, 0, 0, 1); + yield return new SKMatrix (1, 1, 0, 1, 1, 0, 0, 0, 1); // collinear rows + + // perspective + yield return new SKMatrix (1, 0, 0, 0, 1, 0, 0.001f, 0.002f, 1); + yield return new SKMatrix (2, 0.3f, 10, -0.4f, 1.5f, -20, 0.0005f, -0.0011f, 0.9f); + yield return new SKMatrix (1, 0, 5, 0, 1, 7, 0, 0, 2); + + // negative zero translation / scale (sign edge cases) + yield return new SKMatrix (1, 0, -0.0f, 0, 1, -0.0f, 0, 0, 1); + yield return new SKMatrix (-0.0f, 0, 3, 0, -0.0f, 4, 0, 0, 1); + + // non-finite members + yield return new SKMatrix (float.NaN, 0, 0, 0, 1, 0, 0, 0, 1); + yield return new SKMatrix (1, 0, float.PositiveInfinity, 0, 1, 0, 0, 0, 1); + yield return new SKMatrix (float.MaxValue, 0, 0, 0, float.MaxValue, 0, 0, 0, 1); + yield return new SKMatrix (float.Epsilon, 0, 0, 0, float.Epsilon, 0, 0, 0, 1); + + // scale-by-infinity: exercises sort_as_rect NaN semantics (Inf*0 = NaN) + yield return SKMatrix.CreateScale (float.PositiveInfinity, 2); + yield return SKMatrix.CreateScale (2, float.PositiveInfinity); + yield return SKMatrix.CreateScale (float.NegativeInfinity, 3); + yield return SKMatrix.CreateScale (float.PositiveInfinity, float.NegativeInfinity); + yield return new SKMatrix (float.PositiveInfinity, 0, 5, 0, float.PositiveInfinity, 7, 0, 0, 1); + // affine with an infinite skew lane + yield return new SKMatrix (1, float.PositiveInfinity, 0, 0.5f, 2, 0, 0, 0, 1); + + // extreme-magnitude / precision-boundary matrices. These stress the + // float-vs-double intermediate widths in Determinant/Invert/Concat: + // native computes some cross-products in float (scross, rowcol3) and + // others in double (dcross, muladdmul). A cofactor that overflows in + // float but not double (or vice versa) only diverges near float.MaxValue, + // which the random matrices above never reach. They also straddle the + // cubed nearly-zero determinant threshold. The managed port must pick the + // same width per path; these lock that contract. + yield return new SKMatrix (3.8124249e-06f, 1, 0, 0, 3.8169710e-06f, 0, 0, 0, 1); // det ~= nearly-zero^3 + yield return new SKMatrix (1, float.MaxValue, float.MaxValue, 0, 1, 2, 0, 0, 1); // affine cofactor: double cross avoids overflow + yield return new SKMatrix (1, 0, 0, 0, float.MaxValue, float.MaxValue, 0, 1, 2); // perspective cofactor: float cross overflows + yield return new SKMatrix (float.MaxValue, -float.MaxValue, 0, 0, 1, 0, 0, 0, 1); // affine concat: muladdmul (double) + yield return new SKMatrix (float.MaxValue, -float.MaxValue, 0, 0, 1, 0, 1, 0, 1); // perspective concat: rowcol3 (float) + + // deterministic random matrices + var rnd = new Random (12345); + for (var i = 0; i < 200; i++) + yield return RandomMatrix (rnd); + } + + private static SKMatrix RandomMatrix (Random rnd) + { + float F () => (float)((rnd.NextDouble () - 0.5) * 200.0); + float P () => (float)((rnd.NextDouble () - 0.5) * 0.01); + + // roughly 1 in 4 matrices get a perspective row + var persp = rnd.Next (4) == 0; + return new SKMatrix ( + F (), F (), F (), + F (), F (), F (), + persp ? P () : 0, persp ? P () : 0, persp ? (float)(rnd.NextDouble () + 0.5) : 1); + } + + private static readonly SKPoint[] TestPoints = + { + new SKPoint (0, 0), + new SKPoint (1, 0), + new SKPoint (0, 1), + new SKPoint (-3.5f, 7.25f), + new SKPoint (100, -200), + new SKPoint (0.001f, -0.002f), + new SKPoint (-0.0f, 0.0f), + new SKPoint (1e20f, -1e20f), + new SKPoint (12345.678f, -98765.43f), + new SKPoint (float.PositiveInfinity, 1), + new SKPoint (2, float.NegativeInfinity), + new SKPoint (float.NaN, 3), + }; + + private static readonly SKRect[] TestRects = + { + new SKRect (0, 0, 10, 20), + new SKRect (-5, -5, 5, 5), + new SKRect (10, 20, 1, 2), // unsorted + new SKRect (-100.5f, 33.3f, 200.25f, -44.4f), + new SKRect (0, 0, 0, 0), + new SKRect (1.5f, -2.5f, 1.5f, -2.5f), + new SKRect (0, 0, float.PositiveInfinity, float.PositiveInfinity), + new SKRect (float.NegativeInfinity, -1, 1, float.PositiveInfinity), + new SKRect (float.NaN, 0, 10, 20), + new SKRect (-10, float.NaN, 10, 20), + }; + + // ===== tests ===== + + [Fact] + public void InvertMatchesNative () + { + foreach (var m in GetTestMatrices ()) { + var nativeOk = NativeTryInvert (m, out var nativeInv); + var managedOk = m.TryInvert (out var managedInv); + + Assert.Equal (nativeOk, managedOk); + AssertMatrix (nativeInv, managedInv, $"TryInvert({Describe (m)})"); + + Assert.Equal (NativeIsInvertible (m), m.IsInvertible); + } + } + + [Fact] + public void ConcatMatchesNative () + { + var matrices = new List (GetTestMatrices ()); + var rnd = new Random (999); + + foreach (var a in matrices) { + // pair each matrix with a handful of others + for (var k = 0; k < 5; k++) { + var b = matrices[rnd.Next (matrices.Count)]; + + AssertMatrix (NativeConcat (a, b), SKMatrix.Concat (a, b), "Concat"); + AssertMatrix (NativePreConcat (a, b), a.PreConcat (b), "PreConcat"); + AssertMatrix (NativePostConcat (a, b), a.PostConcat (b), "PostConcat"); + + SKMatrix refTarget = default; + SKMatrix.Concat (ref refTarget, a, b); + AssertMatrix (NativeConcat (a, b), refTarget, "Concat(ref)"); + } + } + } + + [Fact] + public void MapPointMatchesNative () + { + foreach (var m in GetTestMatrices ()) { + foreach (var p in TestPoints) { + AssertPoint (NativeMapPoint (m, p.X, p.Y), m.MapPoint (p.X, p.Y), "MapPoint"); + AssertPoint (NativeMapPoint (m, p.X, p.Y), m.MapPoint (p), "MapPoint(SKPoint)"); + } + } + } + + [Fact] + public void MapVectorMatchesNative () + { + foreach (var m in GetTestMatrices ()) { + foreach (var p in TestPoints) { + AssertPoint (NativeMapVector (m, p.X, p.Y), m.MapVector (p.X, p.Y), "MapVector"); + AssertPoint (NativeMapVector (m, p.X, p.Y), m.MapVector (p), "MapVector(SKPoint)"); + } + } + } + + [Fact] + public void MapPointsBatchMatchesNative () + { + // vary the count so the SIMD pair loop and the odd-element scalar tail are both hit; + // 6 and 10 additionally exercise the single-pair lead-in together with the unrolled + // 4-point loop in the same call, and 11 adds an odd tail on top of the unrolled loop + foreach (var count in new[] { 0, 1, 2, 3, 5, 6, 8, 10, 11, TestPoints.Length }) { + var pts = TestPoints.Take (count).ToArray (); + foreach (var m in GetTestMatrices ()) { + var native = NativeMapPoints (m, pts); + var managed = m.MapPoints (pts); + Assert.Equal (native.Length, managed.Length); + for (var i = 0; i < native.Length; i++) + AssertPoint (native[i], managed[i], $"MapPoints(n={count})[{i}]"); + } + } + } + + [Fact] + public void MapVectorsBatchMatchesNative () + { + foreach (var count in new[] { 0, 1, 2, 3, 5, 6, 8, 10, 11, TestPoints.Length }) { + var pts = TestPoints.Take (count).ToArray (); + foreach (var m in GetTestMatrices ()) { + if (count == 0) { + // native helper would marshal an empty array; skip the trivial empty case + Assert.Empty (m.MapVectors (pts)); + continue; + } + var native = NativeMapVectors (m, pts); + var managed = m.MapVectors (pts); + Assert.Equal (native.Length, managed.Length); + for (var i = 0; i < native.Length; i++) + AssertPoint (native[i], managed[i], $"MapVectors(n={count})[{i}]"); + } + } + } + + [Fact] + public void MapPointsInPlaceMatchesNative () + { + // dst == src: exercises the in-place aliasing path of the SIMD batch loops + foreach (var count in new[] { 1, 2, 3, 5, 6, 8, 10, 11, TestPoints.Length }) { + var pts = TestPoints.Take (count).ToArray (); + foreach (var m in GetTestMatrices ()) { + var native = NativeMapPointsInPlace (m, pts); + var managed = (SKPoint[])pts.Clone (); + m.MapPoints (managed, managed); + for (var i = 0; i < native.Length; i++) + AssertPoint (native[i], managed[i], $"MapPointsInPlace(n={count})[{i}]"); + } + } + } + + [Fact] + public void MapVectorsInPlaceMatchesNative () + { + foreach (var count in new[] { 1, 2, 3, 5, 6, 8, 10, 11, TestPoints.Length }) { + var pts = TestPoints.Take (count).ToArray (); + foreach (var m in GetTestMatrices ()) { + var native = NativeMapVectorsInPlace (m, pts); + var managed = (SKPoint[])pts.Clone (); + m.MapVectors (managed, managed); + for (var i = 0; i < native.Length; i++) + AssertPoint (native[i], managed[i], $"MapVectorsInPlace(n={count})[{i}]"); + } + } + } + + [Fact] + public void MapVectorsOverlappingSpansMatchNative () + { + // Overlapping, shifted spans in a shared buffer. This is the only aliasing + // topology that makes iteration direction observable: disjoint buffers and + // same-offset in-place (dst == src) give identical results regardless of + // direction, so they cannot catch a wrong loop order. SkMatrix::mapVectors + // walks the perspective case back-to-front; the managed path must mirror + // that to match native for every matrix and both shift directions. + foreach (var count in new[] { 2, 3, 5, 8, 11 }) { + var seed = TestPoints.Take (count).ToArray (); + // shift = +1: dst one element ahead of src; shift = -1: dst one behind + foreach (var dstAhead in new[] { true, false }) { + foreach (var m in GetTestMatrices ()) { + var nativeBuf = new SKPoint[count + 1]; + var managedBuf = new SKPoint[count + 1]; + var srcOff = dstAhead ? 0 : 1; + var dstOff = dstAhead ? 1 : 0; + Array.Copy (seed, 0, nativeBuf, srcOff, count); + Array.Copy (seed, 0, managedBuf, srcOff, count); + + fixed (SKPoint* p = nativeBuf) + SkiaApi.sk_matrix_map_vectors (&m, p + dstOff, p + srcOff, count); + + m.MapVectors (managedBuf.AsSpan (dstOff, count), managedBuf.AsSpan (srcOff, count)); + + for (var i = 0; i < count + 1; i++) + AssertPoint (nativeBuf[i], managedBuf[i], $"MapVectorsOverlap(n={count}, dstAhead={dstAhead})[{i}]"); + } + } + } + } + + [Fact] + public void MapPointsOverlappingSpansMatchNative () + { + // Same shifted-overlap topology as the MapVectors test, applied to + // MapPoints. Every native mapPoints proc iterates front-to-back (only + // mapVectors special-cases perspective with a backward walk), so the + // managed batch path must stay forward for all matrix types. Disjoint + // and same-offset in-place buffers cannot observe this; the shifted + // overlap can. This guards against a future "optimize to backward" + // regression that would silently diverge from native on aliased spans. + foreach (var count in new[] { 2, 3, 5, 8, 11 }) { + var seed = TestPoints.Take (count).ToArray (); + foreach (var dstAhead in new[] { true, false }) { + foreach (var m in GetTestMatrices ()) { + var nativeBuf = new SKPoint[count + 1]; + var managedBuf = new SKPoint[count + 1]; + var srcOff = dstAhead ? 0 : 1; + var dstOff = dstAhead ? 1 : 0; + Array.Copy (seed, 0, nativeBuf, srcOff, count); + Array.Copy (seed, 0, managedBuf, srcOff, count); + + fixed (SKPoint* p = nativeBuf) + SkiaApi.sk_matrix_map_points (&m, p + dstOff, p + srcOff, count); + + m.MapPoints (managedBuf.AsSpan (dstOff, count), managedBuf.AsSpan (srcOff, count)); + + for (var i = 0; i < count + 1; i++) + AssertPoint (nativeBuf[i], managedBuf[i], $"MapPointsOverlap(n={count}, dstAhead={dstAhead})[{i}]"); + } + } + } + } + + [Fact] + public void MapRectMatchesNative () + { + foreach (var m in GetTestMatrices ()) { + foreach (var r in TestRects) + AssertRect (NativeMapRect (m, r), m.MapRect (r), $"MapRect({Describe (m)})"); + } + } + + [Fact] + public void MapRadiusMatchesNative () + { + var radii = new[] { 0f, 1f, 10f, -5f, 0.001f, 1e10f, 12345.6f }; + foreach (var m in GetTestMatrices ()) { + foreach (var radius in radii) + AssertFloat (NativeMapRadius (m, radius), m.MapRadius (radius), "MapRadius"); + } + } + + private static string Describe (SKMatrix m) + { + var v = m.Values; + return $"[{v[0]},{v[1]},{v[2]};{v[3]},{v[4]},{v[5]};{v[6]},{v[7]},{v[8]}]"; + } + } +}