diff --git a/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKColorFConvertBenchmark.cs b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKColorFConvertBenchmark.cs new file mode 100644 index 00000000000..798968333c0 --- /dev/null +++ b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKColorFConvertBenchmark.cs @@ -0,0 +1,69 @@ +using System; +using BenchmarkDotNet.Attributes; + +namespace SkiaSharp.Benchmarks; + +// The implicit SKColor -> SKColorF conversion turns a packed 8-bit BGRA color into four normalized +// floats. It sits on hot paths for anyone feeding colors into the float pipeline: setting an +// SKRuntimeEffect color uniform (SKRuntimeEffect.cs converts SKColor -> SKColorF via this implicit +// operator) does it every frame for animated shaders, and gradient/paint code converts batches of +// colors. Because it is an implicit operator it is trivially hit inside per-frame / per-item loops. +// +// The shipped implementation used to round-trip through the native sk_color4f_from_color P/Invoke +// (an extra managed->native transition, plus an output-pointer pin, per single color). This +// benchmark compares that previous native path (Old, baseline) against the current managed port +// (New) which is a plain `byte * (1f/255f)` per channel - proven bit-identical to the native result +// by SKColorFConvertEquivalenceTest. +// +// Both variants convert the same representative batch of colors so the per-call difference is +// amortized over a realistic loop rather than lost in harness overhead. +[MemoryDiagnoser] +public unsafe class SKColorFConvertBenchmark +{ + private SKColor[] colors; + + [Params(1024)] + public int Count { get; set; } + + [GlobalSetup] + public void Setup() + { + colors = new SKColor[Count]; + // Spread across the full 32-bit color space so no channel is constant-folded. + var seed = 0x9E3779B9u; + for (var i = 0; i < colors.Length; i++) + { + seed = seed * 1664525u + 1013904223u; + colors[i] = (SKColor)seed; + } + } + + // New: the shipped managed conversion (implicit operator, pure managed math). + [Benchmark] + public float New() + { + var sink = 0f; + var local = colors; + for (var i = 0; i < local.Length; i++) + { + SKColorF f = local[i]; + sink += f.Red + f.Green + f.Blue + f.Alpha; + } + return sink; + } + + // Baseline: the previous implementation that round-tripped through the native P/Invoke. + [Benchmark(Baseline = true)] + public float Old() + { + var sink = 0f; + var local = colors; + for (var i = 0; i < local.Length; i++) + { + SKColorF f; + SkiaApi.sk_color4f_from_color((uint)local[i], &f); + sink += f.Red + f.Green + f.Blue + f.Alpha; + } + return sink; + } +} diff --git a/binding/SkiaSharp/SKColorF.cs b/binding/SkiaSharp/SKColorF.cs index 76d3edcc835..8857ec01517 100644 --- a/binding/SkiaSharp/SKColorF.cs +++ b/binding/SkiaSharp/SKColorF.cs @@ -1,6 +1,7 @@ #nullable disable using System; +using System.Runtime.CompilerServices; namespace SkiaSharp { @@ -250,11 +251,19 @@ public readonly void ToHsv (out float h, out float s, out float v) public readonly override string ToString () => ((SKColor)this).ToString (); + [MethodImpl (MethodImplOptions.AggressiveInlining)] public static implicit operator SKColorF (SKColor color) { - SKColorF colorF; - SkiaApi.sk_color4f_from_color ((uint)color, &colorF); - return colorF; + // Ported from the native sk_color4f_from_color (SkColor4f::FromColor): + // each 8-bit channel is scaled by 1/255 with no gamma applied. This is a + // single multiply per channel, so the managed result is bit-identical to the + // native path on every runtime (including x87) and avoids a P/Invoke per call. + const float scale = 1f / 255f; + return new SKColorF ( + color.Red * scale, + color.Green * scale, + color.Blue * scale, + color.Alpha * scale); } public static explicit operator SKColor (SKColorF color) => diff --git a/tests/Tests/SkiaSharp/SKColorFConvertEquivalenceTest.cs b/tests/Tests/SkiaSharp/SKColorFConvertEquivalenceTest.cs new file mode 100644 index 00000000000..5621b255889 --- /dev/null +++ b/tests/Tests/SkiaSharp/SKColorFConvertEquivalenceTest.cs @@ -0,0 +1,96 @@ +using System.Runtime.CompilerServices; +using Xunit; + +namespace SkiaSharp.Tests +{ + // Equivalence coverage for the managed port of the implicit SKColor -> SKColorF + // conversion operator (previously sk_color4f_from_color via P/Invoke). Every + // assertion compares the managed operator bit-for-bit against the native oracle. + public class SKColorFConvertEquivalenceTest : SKTest + { + // The original native path the managed operator replaces. + private static unsafe SKColorF NativeFromColor (SKColor color) + { + SKColorF f; + SkiaApi.sk_color4f_from_color ((uint)color, &f); + return f; + } + + // BitConverter.SingleToInt32Bits is not available on .NET Framework (net48), so + // reinterpret the raw float bits directly. This is bit-identical on every runtime + // and keeps the equivalence checks exact rather than tolerance-based. + private static unsafe int SingleToBits (float value) => *(int*)&value; + + private static void AssertBitExact (SKColor src) + { + var native = NativeFromColor (src); + SKColorF managed = src; // managed implicit operator under test + + Assert.True ( + SingleToBits (native.Red) == SingleToBits (managed.Red) && + SingleToBits (native.Green) == SingleToBits (managed.Green) && + SingleToBits (native.Blue) == SingleToBits (managed.Blue) && + SingleToBits (native.Alpha) == SingleToBits (managed.Alpha), + $"SKColor {src}: managed ({managed.Red:R},{managed.Green:R},{managed.Blue:R},{managed.Alpha:R}) " + + $"!= native ({native.Red:R},{native.Green:R},{native.Blue:R},{native.Alpha:R})"); + } + + [Fact] + public void ManagedFromColorMatchesNativeForAllGrayscale () + { + for (var c = 0; c <= 255; c++) + AssertBitExact (new SKColor ((byte)c, (byte)c, (byte)c, (byte)c)); + } + + [Fact] + public void ManagedFromColorMatchesNativeForEachChannelSweep () + { + // Vary one channel across its full range while holding the others at + // distinct values, so a swapped-channel port would diverge somewhere. + for (var v = 0; v <= 255; v++) { + AssertBitExact (new SKColor ((byte)v, 17, 200, 99)); + AssertBitExact (new SKColor (17, (byte)v, 200, 99)); + AssertBitExact (new SKColor (17, 200, (byte)v, 99)); + AssertBitExact (new SKColor (17, 200, 99, (byte)v)); + } + } + + [Fact] + public void ManagedFromColorMatchesNativeForDistinctChannels () + { + var samples = new (byte r, byte g, byte b, byte a)[] { + (0, 0, 0, 0), (255, 255, 255, 255), (255, 0, 0, 255), + (0, 255, 0, 255), (0, 0, 255, 255), (10, 20, 30, 40), + (1, 2, 3, 4), (128, 64, 32, 16), (127, 129, 126, 200), + (254, 1, 253, 2), (100, 150, 200, 250), + }; + foreach (var s in samples) + AssertBitExact (new SKColor (s.r, s.g, s.b, s.a)); + } + + [MethodImpl (MethodImplOptions.NoInlining)] + private static float NaiveDivide (int c) => c / 255f; + + [Fact] + public void EquivalenceComparisonDetectsAWrongPort () + { + // Guard: prove the bit-exact comparison the tests above rely on has teeth. + + // (a) A swapped R/B port (a plausible mistake given SkColor's BGRA memory + // order) produces different bits and is caught. + var src = new SKColor (10, 20, 30, 40); + var native = NativeFromColor (src); + var swapped = new SKColorF (native.Blue, native.Green, native.Red, native.Alpha); + Assert.NotEqual ( + SingleToBits (native.Red), + SingleToBits (swapped.Red)); + + // (b) A naive divide-by-255 differs from the native *(1/255) scale at some + // inputs (e.g. 127), confirming the exact formula matters. + var grayNative = NativeFromColor (new SKColor (127, 127, 127, 127)); + Assert.NotEqual ( + SingleToBits (grayNative.Red), + SingleToBits (NaiveDivide (127))); + } + } +}