diff --git a/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKPMColorConvertBenchmark.cs b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKPMColorConvertBenchmark.cs new file mode 100644 index 00000000000..cc22d984777 --- /dev/null +++ b/benchmarks/SkiaSharp.Benchmarks/Benchmarks/SKPMColorConvertBenchmark.cs @@ -0,0 +1,108 @@ +using BenchmarkDotNet.Attributes; + +namespace SkiaSharp.Benchmarks; + +// SKPMColor.PreMultiply(SKColor) / UnPreMultiply(SKPMColor) convert a single colour to and from +// premultiplied form. Callers that composite or convert pixels one colour at a time (gradient +// ramps, palette expansion, per-pixel software blends) hit these in a tight loop, so the per-call +// cost dominates. +// +// The shipped implementation used to round-trip every single conversion through a native P/Invoke +// (SkiaApi.sk_color_premultiply / sk_color_unpremultiply) even though the work is a few integer +// ops. New is the managed port (integer-only, bit-exact with the native result — proven by +// SKPMColorEquivalenceTest); Old is the previous native call, reproduced verbatim as the baseline +// so both are measured in the same process/TFM. +// +// [Params] covers a small palette (256) and a 64x64 pixel tile (4096); alpha varies across the +// batch so the a != 255 premultiply branch is exercised realistically. +[MemoryDiagnoser] +public class SKPMColorPreMultiplyBenchmark +{ + [Params(256, 4096)] + public int N { get; set; } + + private SKColor[] colors; + + [GlobalSetup] + public void GlobalSetup() + { + colors = new SKColor[N]; + for (var i = 0; i < N; i++) + { + // Spread alpha and channels so most colours take the a != 255 (real premultiply) path. + var a = (byte)((i * 7) & 0xFF); + var r = (byte)((i * 13) & 0xFF); + var g = (byte)((i * 29) & 0xFF); + var b = (byte)((i * 53) & 0xFF); + colors[i] = new SKColor(r, g, b, a); + } + } + + // New: the shipped managed integer implementation. + [Benchmark] + public uint New() + { + uint sink = 0; + var src = colors; + for (var i = 0; i < src.Length; i++) + sink ^= (uint)SKPMColor.PreMultiply(src[i]); + return sink; + } + + // Baseline: the previous path — one native P/Invoke per colour. + [Benchmark(Baseline = true)] + public uint Old() + { + uint sink = 0; + var src = colors; + for (var i = 0; i < src.Length; i++) + sink ^= SkiaApi.sk_color_premultiply((uint)src[i]); + return sink; + } +} + +[MemoryDiagnoser] +public class SKPMColorUnPreMultiplyBenchmark +{ + [Params(256, 4096)] + public int N { get; set; } + + private SKPMColor[] pmcolors; + + [GlobalSetup] + public void GlobalSetup() + { + pmcolors = new SKPMColor[N]; + for (var i = 0; i < N; i++) + { + var a = (byte)((i * 7) & 0xFF); + var r = (byte)((i * 13) & 0xFF); + var g = (byte)((i * 29) & 0xFF); + var b = (byte)((i * 53) & 0xFF); + // Build a valid premultiplied colour to unpremultiply back. + pmcolors[i] = SKPMColor.PreMultiply(new SKColor(r, g, b, a)); + } + } + + // New: the shipped managed integer implementation. + [Benchmark] + public uint New() + { + uint sink = 0; + var src = pmcolors; + for (var i = 0; i < src.Length; i++) + sink ^= (uint)(SKColor)SKPMColor.UnPreMultiply(src[i]); + return sink; + } + + // Baseline: the previous path — one native P/Invoke per colour. + [Benchmark(Baseline = true)] + public uint Old() + { + uint sink = 0; + var src = pmcolors; + for (var i = 0; i < src.Length; i++) + sink ^= SkiaApi.sk_color_unpremultiply((uint)src[i]); + return sink; + } +} diff --git a/binding/SkiaSharp/SKPMColor.cs b/binding/SkiaSharp/SKPMColor.cs index 4be0398ae4c..cc40731b543 100644 --- a/binding/SkiaSharp/SKPMColor.cs +++ b/binding/SkiaSharp/SKPMColor.cs @@ -1,6 +1,7 @@ #nullable disable using System; +using System.Runtime.CompilerServices; namespace SkiaSharp { @@ -20,8 +21,27 @@ public SKPMColor (uint value) // PreMultiply - public static SKPMColor PreMultiply (SKColor color) => - SkiaApi.sk_color_premultiply ((uint)color); + public static SKPMColor PreMultiply (SKColor color) + { + uint a = color.Alpha; + uint r = color.Red; + uint g = color.Green; + uint b = color.Blue; + + // Opaque colors are unchanged; MulDiv255Round (c, 255) == c for every c. + if (a != 255) { + r = MulDiv255Round (r, a); + g = MulDiv255Round (g, a); + b = MulDiv255Round (b, a); + } + + var pmcolor = + (a << SKImageInfo.PlatformColorAlphaShift) | + (r << SKImageInfo.PlatformColorRedShift) | + (g << SKImageInfo.PlatformColorGreenShift) | + (b << SKImageInfo.PlatformColorBlueShift); + return new SKPMColor (pmcolor); + } public static SKPMColor[] PreMultiply (SKColor[] colors) { @@ -35,8 +55,16 @@ public static SKPMColor[] PreMultiply (SKColor[] colors) // UnPreMultiply - public static SKColor UnPreMultiply (SKPMColor pmcolor) => - SkiaApi.sk_color_unpremultiply ((uint)pmcolor); + public static SKColor UnPreMultiply (SKPMColor pmcolor) + { + uint a = pmcolor.Alpha; + var scale = UnPreMultiplyScale (a); + return new SKColor ( + (byte)ApplyUnPreMultiplyScale (scale, pmcolor.Red), + (byte)ApplyUnPreMultiplyScale (scale, pmcolor.Green), + (byte)ApplyUnPreMultiplyScale (scale, pmcolor.Blue), + (byte)a); + } public static SKColor[] UnPreMultiply (SKPMColor[] pmcolors) { @@ -54,6 +82,37 @@ public static explicit operator SKPMColor (SKColor color) => public static explicit operator SKColor (SKPMColor color) => SKPMColor.UnPreMultiply (color); + // Managed replicas of Skia's SkMulDiv255Round / SkUnPreMultiply::ApplyScale + // (skia/src/core/SkColorData.h and skia/src/core/SkUnPreMultiply.cpp), + // kept bit-exact with the native sk_color_(un)premultiply for every input. + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + private static uint MulDiv255Round (uint value, uint alpha) + { + var prod = value * alpha + 128; + return (prod + (prod >> 8)) >> 8; + } + + private static readonly uint[] unpremultiplyScale = CreateUnPreMultiplyScaleTable (); + + private static uint[] CreateUnPreMultiplyScaleTable () + { + // Mirrors Skia's SkUnPreMultiply::gTable: round((255 << 24) / alpha), computed once. + // Replaces a per-call 32-bit divide with a table lookup, matching the native path. + var table = new uint[256]; + for (uint a = 1; a < 256; a++) + table[a] = ((255u << 24) + (a >> 1)) / a; + return table; + } + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + private static uint UnPreMultiplyScale (uint alpha) => + unpremultiplyScale[alpha & 0xff]; + + [MethodImpl (MethodImplOptions.AggressiveInlining)] + private static uint ApplyUnPreMultiplyScale (uint scale, uint component) => + unchecked ((scale * component + (1u << 23)) >> 24); + public readonly override string ToString () => $"#{Alpha:x2}{Red:x2}{Green:x2}{Blue:x2}"; diff --git a/tests/Tests/SkiaSharp/SKPMColorEquivalenceTest.cs b/tests/Tests/SkiaSharp/SKPMColorEquivalenceTest.cs new file mode 100644 index 00000000000..dc5b78519dc --- /dev/null +++ b/tests/Tests/SkiaSharp/SKPMColorEquivalenceTest.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using Xunit; + +namespace SkiaSharp.Tests +{ + // Equivalence proof for the managed port of the single-value SKPMColor premultiply / + // unpremultiply conversions (see SKPMColor.PreMultiply(SKColor) / UnPreMultiply(SKPMColor)). + // + // The native oracle is SkiaApi.sk_color_premultiply / sk_color_unpremultiply (the exact code + // the binding called before the port). Both conversions are per-channel maps parameterised + // only by alpha, so sweeping (alpha 0..255) x (channel 0..255) for each of the three colour + // channel positions is a COMPLETE, bit-exact enumeration of every reachable behaviour — with + // distinct sentinels in the other channels so a swapped/mis-shifted channel is also caught. + // The exhaustive tests drive the PUBLIC API (SKPMColor.PreMultiply / UnPreMultiply) so they + // guard the shipped managed implementation directly, not a copy of it. + public class SKPMColorEquivalenceTest : SKTest + { + // Distinct sentinels (all different) placed in the non-swept channels so the test also + // pins the packing order, not just the per-channel scale. + private const uint R0 = 0x11; + private const uint G0 = 0x77; + private const uint B0 = 0xCC; + + // ---- Helpers used only by the deliberately-wrong "teeth" guards below ---- + + private static uint UnPreMultiplyScale (uint alpha) => + // Skia stores round((255 << 24) / alpha), not the floor: the +(alpha >> 1) bias makes + // the port bit-exact even for out-of-gamut inputs where a colour channel exceeds alpha. + alpha == 0 ? 0u : ((255u << 24) + (alpha >> 1)) / alpha; + + private static uint ApplyUnPreMultiplyScale (uint scale, uint component) => + // 32-bit multiply wraps exactly like the native uint32_t path; the >> 24 of a 32-bit + // value is always <= 255, so the packing never bleeds across channels. + unchecked ((scale * component + (1u << 23)) >> 24); + + // ---- Exhaustive equivalence against the native oracle ---- + + [Fact] + public void ManagedPreMultiplyMatchesNativeForEveryAlphaAndChannel () + { + var failures = new List (); + + for (uint a = 0; a <= 255; a++) { + for (uint v = 0; v <= 255; v++) { + CheckPreMultiply (failures, (a << 24) | (v << 16) | (G0 << 8) | B0); // sweep R + CheckPreMultiply (failures, (a << 24) | (R0 << 16) | (v << 8) | B0); // sweep G + CheckPreMultiply (failures, (a << 24) | (R0 << 16) | (G0 << 8) | v); // sweep B + } + } + + Assert.True (failures.Count == 0, FormatFailures ("premultiply", failures)); + } + + [Fact] + public void ManagedUnPreMultiplyMatchesNativeForEveryAlphaAndChannel () + { + var aShift = SKImageInfo.PlatformColorAlphaShift; + var rShift = SKImageInfo.PlatformColorRedShift; + var gShift = SKImageInfo.PlatformColorGreenShift; + var bShift = SKImageInfo.PlatformColorBlueShift; + + var failures = new List (); + + for (uint a = 0; a <= 255; a++) { + for (uint v = 0; v <= 255; v++) { + CheckUnPreMultiply (failures, (a << aShift) | (v << rShift) | (G0 << gShift) | (B0 << bShift)); + CheckUnPreMultiply (failures, (a << aShift) | (R0 << rShift) | (v << gShift) | (B0 << bShift)); + CheckUnPreMultiply (failures, (a << aShift) | (R0 << rShift) | (G0 << gShift) | (v << bShift)); + } + } + + Assert.True (failures.Count == 0, FormatFailures ("unpremultiply", failures)); + } + + private static void CheckPreMultiply (List failures, uint color) + { + var expected = SkiaApi.sk_color_premultiply (color); + var actual = (uint)SKPMColor.PreMultiply ((SKColor)color); + if (expected != actual && failures.Count < 16) + failures.Add ($"color=0x{color:X8}: native=0x{expected:X8} managed=0x{actual:X8}"); + } + + private static void CheckUnPreMultiply (List failures, uint pmcolor) + { + var expected = SkiaApi.sk_color_unpremultiply (pmcolor); + var actual = (uint)(SKColor)SKPMColor.UnPreMultiply ((SKPMColor)pmcolor); + if (expected != actual && failures.Count < 16) + failures.Add ($"pm=0x{pmcolor:X8}: native=0x{expected:X8} managed=0x{actual:X8}"); + } + + private static string FormatFailures (string op, List failures) => + failures.Count == 0 + ? string.Empty + : $"{op} diverged from native on {failures.Count}+ inputs:\n " + string.Join ("\n ", failures); + + // ---- Round trip: premultiply then unpremultiply recovers the original (opaque) colour ---- + + [Fact] + public void PreMultiplyRoundTripsForOpaqueColors () + { + for (uint r = 0; r <= 255; r += 7) { + for (uint g = 0; g <= 255; g += 11) { + for (uint b = 0; b <= 255; b += 13) { + var color = new SKColor ((byte)r, (byte)g, (byte)b, 255); + var pm = SKPMColor.PreMultiply (color); + Assert.Equal (color, SKPMColor.UnPreMultiply (pm)); + } + } + } + } + + // ---- Guard: prove the exhaustive comparison actually has teeth ---- + // + // A deliberately-wrong premultiply (drops the rounding bias) and a deliberately-wrong + // unpremultiply (swaps two channels) must BOTH be detected by the same oracle comparison, + // otherwise a green run above would be meaningless. + + [Fact] + public void ExhaustiveComparisonCatchesAWrongPreMultiply () + { + var caught = false; + for (uint a = 0; a <= 255 && !caught; a++) { + for (uint v = 0; v <= 255 && !caught; v++) { + var color = (a << 24) | (v << 16) | (G0 << 8) | B0; + if (SkiaApi.sk_color_premultiply (color) != WrongPreMultiply (color)) + caught = true; + } + } + Assert.True (caught, "A knowingly-wrong premultiply was NOT detected — the oracle comparison lacks teeth."); + } + + [Fact] + public void ExhaustiveComparisonCatchesAWrongUnPreMultiply () + { + var aShift = SKImageInfo.PlatformColorAlphaShift; + var rShift = SKImageInfo.PlatformColorRedShift; + var gShift = SKImageInfo.PlatformColorGreenShift; + var bShift = SKImageInfo.PlatformColorBlueShift; + + var caught = false; + for (uint a = 1; a <= 255 && !caught; a++) { + for (uint v = 0; v <= 255 && !caught; v++) { + var pm = (a << aShift) | (v << rShift) | (Math.Min (G0, a) << gShift) | (Math.Min (B0, a) << bShift); + if (SkiaApi.sk_color_unpremultiply (pm) != WrongUnPreMultiply (pm)) + caught = true; + } + } + Assert.True (caught, "A knowingly-wrong unpremultiply was NOT detected — the oracle comparison lacks teeth."); + } + + private static uint WrongPreMultiply (uint color) + { + // The correct premultiply but with the +128 rounding bias dropped -> off-by-one on many inputs. + uint a = (color >> 24) & 0xff; + uint r = (color >> 16) & 0xff; + uint g = (color >> 8) & 0xff; + uint b = color & 0xff; + if (a != 255) { + r = (r * a) / 255; + g = (g * a) / 255; + b = (b * a) / 255; + } + return (a << SKImageInfo.PlatformColorAlphaShift) | + (r << SKImageInfo.PlatformColorRedShift) | + (g << SKImageInfo.PlatformColorGreenShift) | + (b << SKImageInfo.PlatformColorBlueShift); + } + + private static uint WrongUnPreMultiply (uint pmcolor) + { + // The correct unpremultiply but with red and blue swapped on output. + uint a = (pmcolor >> SKImageInfo.PlatformColorAlphaShift) & 0xff; + uint r = (pmcolor >> SKImageInfo.PlatformColorRedShift) & 0xff; + uint g = (pmcolor >> SKImageInfo.PlatformColorGreenShift) & 0xff; + uint b = (pmcolor >> SKImageInfo.PlatformColorBlueShift) & 0xff; + uint scale = UnPreMultiplyScale (a); + r = ApplyUnPreMultiplyScale (scale, r); + g = ApplyUnPreMultiplyScale (scale, g); + b = ApplyUnPreMultiplyScale (scale, b); + return (a << 24) | (b << 16) | (g << 8) | r; + } + } +}