From b81b9a2792b441788975eb4d2e54d470fdb168e2 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Thu, 30 Jul 2026 13:56:46 +1000 Subject: [PATCH] Use associated alpha blending with coverage Route BlendWithCoverage calls through AssociatedAlphaPorterDuffFunctions when TOperator.IsAssociatedAlpha is true, across all SIMD widths (scalar, Vector256, Vector512) and all BlendWithCoverageFunction overloads. --- .../PixelBlender{TPixel,TOperator}.cs | 52 +++++++--- .../PixelFormats/PixelBlenderTests.cs | 96 +++++++++++++++++++ 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/src/ImageSharp/PixelFormats/PixelBlenders/PixelBlender{TPixel,TOperator}.cs b/src/ImageSharp/PixelFormats/PixelBlenders/PixelBlender{TPixel,TOperator}.cs index ee8c6905f..d2b26db80 100644 --- a/src/ImageSharp/PixelFormats/PixelBlenders/PixelBlender{TPixel,TOperator}.cs +++ b/src/ImageSharp/PixelFormats/PixelBlenders/PixelBlender{TPixel,TOperator}.cs @@ -315,7 +315,9 @@ internal sealed class PixelBlender : PixelBlender Vector512 coverageVector = CreateClampedVector512(ref coverageBase); Vector512 blended = TOperator.Invoke(backgroundVector, Unsafe.Add(ref sourceBase, i), amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 4; @@ -335,7 +337,9 @@ internal sealed class PixelBlender : PixelBlender Vector256 coverageVector = CreateClampedVector256(ref coverageBase); Vector256 blended = TOperator.Invoke(backgroundVector, Unsafe.Add(ref sourceBase, i), amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 2; @@ -346,7 +350,10 @@ internal sealed class PixelBlender : PixelBlender Vector4 backgroundPixel = Unsafe.Add(ref backgroundRef, (uint)i); Vector4 blended = TOperator.Invoke(backgroundPixel, Unsafe.Add(ref sourceRef, (uint)i), amount); - Unsafe.Add(ref destinationRef, (uint)i) = PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F)); + float clampedCoverage = Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F); + Unsafe.Add(ref destinationRef, (uint)i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage) + : PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage); } } @@ -376,7 +383,9 @@ internal sealed class PixelBlender : PixelBlender Vector512 coverageVector = CreateClampedVector512(ref coverageBase); Vector512 blended = TOperator.Invoke(backgroundVector, sourceVector, amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 4; @@ -396,7 +405,9 @@ internal sealed class PixelBlender : PixelBlender Vector256 coverageVector = CreateClampedVector256(ref coverageBase); Vector256 blended = TOperator.Invoke(backgroundVector, sourceVector, amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 2; @@ -407,7 +418,10 @@ internal sealed class PixelBlender : PixelBlender Vector4 backgroundPixel = Unsafe.Add(ref backgroundRef, (uint)i); Vector4 blended = TOperator.Invoke(backgroundPixel, source, amount); - Unsafe.Add(ref destinationRef, (uint)i) = PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F)); + float clampedCoverage = Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F); + Unsafe.Add(ref destinationRef, (uint)i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage) + : PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage); } } @@ -439,7 +453,9 @@ internal sealed class PixelBlender : PixelBlender Vector512 coverageVector = CreateClampedVector512(ref coverageBase); Vector512 blended = TOperator.Invoke(backgroundVector, Unsafe.Add(ref sourceBase, i), amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 4; @@ -460,7 +476,9 @@ internal sealed class PixelBlender : PixelBlender Vector256 coverageVector = CreateClampedVector256(ref coverageBase); Vector256 blended = TOperator.Invoke(backgroundVector, Unsafe.Add(ref sourceBase, i), amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 2; @@ -471,7 +489,10 @@ internal sealed class PixelBlender : PixelBlender Vector4 backgroundPixel = Unsafe.Add(ref backgroundRef, (uint)i); Vector4 blended = TOperator.Invoke(backgroundPixel, Unsafe.Add(ref sourceRef, (uint)i), Numerics.Clamp(Unsafe.Add(ref amountRef, (uint)i), 0, 1F)); - Unsafe.Add(ref destinationRef, (uint)i) = PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F)); + float clampedCoverage = Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F); + Unsafe.Add(ref destinationRef, (uint)i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage) + : PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage); } } @@ -502,7 +523,9 @@ internal sealed class PixelBlender : PixelBlender Vector512 coverageVector = CreateClampedVector512(ref coverageBase); Vector512 blended = TOperator.Invoke(backgroundVector, sourceVector, amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 4; @@ -523,7 +546,9 @@ internal sealed class PixelBlender : PixelBlender Vector256 coverageVector = CreateClampedVector256(ref coverageBase); Vector256 blended = TOperator.Invoke(backgroundVector, sourceVector, amountVector); - Unsafe.Add(ref destinationBase, i) = PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); + Unsafe.Add(ref destinationBase, i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector) + : PorterDuffFunctions.BlendWithCoverage(backgroundVector, blended, coverageVector); } scalarStart = vectorCount * 2; @@ -534,7 +559,10 @@ internal sealed class PixelBlender : PixelBlender Vector4 backgroundPixel = Unsafe.Add(ref backgroundRef, (uint)i); Vector4 blended = TOperator.Invoke(backgroundPixel, source, Numerics.Clamp(Unsafe.Add(ref amountRef, (uint)i), 0, 1F)); - Unsafe.Add(ref destinationRef, (uint)i) = PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F)); + float clampedCoverage = Numerics.Clamp(Unsafe.Add(ref coverageRef, (uint)i), 0, 1F); + Unsafe.Add(ref destinationRef, (uint)i) = TOperator.IsAssociatedAlpha + ? AssociatedAlphaPorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage) + : PorterDuffFunctions.BlendWithCoverage(backgroundPixel, blended, clampedCoverage); } } diff --git a/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs b/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs index c3028b33b..c203bee16 100644 --- a/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs +++ b/tests/ImageSharp.Tests/PixelFormats/PixelBlenderTests.cs @@ -243,6 +243,102 @@ public class PixelBlenderTests Assert.Equal(new Rgba32P(30, 33, 16, 60), blender.Blend(background, source, .625F)); } + [Fact] + public void AssociatedBlendWithCoverageAppliesCoverageToColorAndAlpha() => + FeatureTestRunner.RunWithHwIntrinsicsFeature( + RunAssociatedBlendWithCoverageAppliesCoverageToColorAndAlpha, + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + + [Fact] + public void BlendWithCoverageMatchesAcrossAlphaRepresentations() => + FeatureTestRunner.RunWithHwIntrinsicsFeature( + RunBlendWithCoverageMatchesAcrossAlphaRepresentations, + HwIntrinsics.AllowAll | HwIntrinsics.DisableAVX512F | HwIntrinsics.DisableAVX | HwIntrinsics.DisableHWIntrinsic); + + private static void RunAssociatedBlendWithCoverageAppliesCoverageToColorAndAlpha() + { + PixelBlender blender = PixelOperations.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver); + Rgba32P source = Rgba32P.FromRgba32(new Rgba32(37, 211, 89, 173)); + Vector4 sourceScaled = source.ToScaledVector4(); + + // Seven pixels exercise one AVX-512 batch plus three tails, or three AVX2 batches plus one tail. + float[] coverage = [1F, .8F, .6F, .4F, .2F, 0F, .5F]; + float[] amounts = [1F, 1F, 1F, 1F, 1F, 1F, 1F]; + Rgba32P[] background = new Rgba32P[7]; + Rgba32P[] sourceSpan = [source, source, source, source, source, source, source]; + Rgba32P[] destination = new Rgba32P[7]; + Rgba32P[] expected = new Rgba32P[7]; + Vector4[] sourceSpanBuffer = new Vector4[destination.Length * 3]; + Vector4[] constantSourceBuffer = new Vector4[destination.Length * 2]; + + // Compositing over transparency reduces source-over to the source itself, so coverage must + // scale the associated color and alpha lanes together: the premultiplied source times coverage. + // An unassociated coverage lerp instead premultiplies and unassociates around the mix, which + // cancels coverage out of the color lanes entirely at zero backdrop alpha. + for (int i = 0; i < expected.Length; i++) + { + expected[i] = Rgba32P.FromScaledVector4(sourceScaled * coverage[i]); + } + + blender.BlendWithCoverage(Configuration.Default, destination, background, sourceSpan, 1F, coverage, sourceSpanBuffer); + Assert.Equal(expected, destination); + + blender.BlendWithCoverage(Configuration.Default, destination, background, source, 1F, coverage, constantSourceBuffer); + Assert.Equal(expected, destination); + + blender.BlendWithCoverage(Configuration.Default, destination, background, sourceSpan, amounts, coverage, sourceSpanBuffer); + Assert.Equal(expected, destination); + + blender.BlendWithCoverage(Configuration.Default, destination, background, source, amounts, coverage, constantSourceBuffer); + Assert.Equal(expected, destination); + } + + private static void RunBlendWithCoverageMatchesAcrossAlphaRepresentations() + { + PixelBlender straightBlender = PixelOperations.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver); + PixelBlender associatedBlender = PixelOperations.Instance.GetPixelBlender(PixelColorBlendingMode.Normal, PixelAlphaCompositionMode.SrcOver); + + Rgba32[] background = + [ + new(0, 0, 0, 0), + new(29, 83, 137, 107), + new(255, 255, 255, 255), + new(220, 80, 40, 160), + new(10, 20, 30, 40), + new(40, 200, 100, 192), + new(180, 160, 20, 128), + ]; + + Rgba32 source = new(37, 211, 89, 173); + float[] coverage = [1F, .8F, .6F, .4F, .2F, 0F, .5F]; + + Rgba32[] straightDestination = new Rgba32[background.Length]; + Rgba32P[] associatedDestination = new Rgba32P[background.Length]; + Rgba32P[] associatedBackground = new Rgba32P[background.Length]; + Vector4[] workingBuffer = new Vector4[background.Length * 2]; + + for (int i = 0; i < background.Length; i++) + { + associatedBackground[i] = Rgba32P.FromRgba32(background[i]); + } + + straightBlender.BlendWithCoverage(Configuration.Default, straightDestination, background, source, 1F, coverage, workingBuffer); + associatedBlender.BlendWithCoverage(Configuration.Default, associatedDestination, associatedBackground, Rgba32P.FromRgba32(source), 1F, coverage, workingBuffer); + + // Both destinations must describe the same composite: canonical associated bytes may differ + // by one quantization step per channel while alpha is representation-independent. + for (int i = 0; i < background.Length; i++) + { + Rgba32P expected = Rgba32P.FromRgba32(straightDestination[i]); + Rgba32P actual = associatedDestination[i]; + + Assert.True(Math.Abs(expected.R - actual.R) <= 1, $"[{i}] expected {expected}, actual {actual}"); + Assert.True(Math.Abs(expected.G - actual.G) <= 1, $"[{i}] expected {expected}, actual {actual}"); + Assert.True(Math.Abs(expected.B - actual.B) <= 1, $"[{i}] expected {expected}, actual {actual}"); + Assert.Equal(expected.A, actual.A); + } + } + [Fact] public void Blend_WithConstantSourceAndSingleAmount() {