From 3fb45923b000353f6391a6f7ff64c68d2ded3e91 Mon Sep 17 00:00:00 2001 From: Dale Price Date: Tue, 28 Nov 2023 16:06:20 -0600 Subject: [PATCH] in depth documentation of the shader; use UV coordinates to sample from the mask (no longer requires mask size to match the view size); add a version of the modifier that takes an `Image` directly --- Sources/Variablur/Blurs.metal | 114 +++++++++++------- .../Documentation.docc/Documentation.md | 5 +- Sources/Variablur/View+variableBlur.swift | 37 ++++-- .../Variablur/VisualEffect+variableBlur.swift | 20 ++- 4 files changed, 111 insertions(+), 65 deletions(-) diff --git a/Sources/Variablur/Blurs.metal b/Sources/Variablur/Blurs.metal index 0e3c959..15352d8 100644 --- a/Sources/Variablur/Blurs.metal +++ b/Sources/Variablur/Blurs.metal @@ -3,68 +3,92 @@ using namespace metal; #include -// Formula of a gaussian function as described by https://en.wikipedia.org/wiki/Gaussian_blur -inline half gaussianKernel1D(half x, half sigma) { - const half gaussianExponent = -(x * x) / (2.0h * sigma * sigma); +/// Formula of a gaussian function for a single axis as described by https://en.wikipedia.org/wiki/Gaussian_blur. Creates a "bell curve" shape which we'll use for the weight of each sample when averaging. +/// +/// - Parameter distance: The distance from the origin along the current axis. +/// - Parameter sigma: The desired standard deviation of the bell curve. +inline half gaussian(half distance, half sigma) { + // Calculate the exponent of the Gaussian equation. + const half gaussianExponent = -(distance * distance) / (2.0h * sigma * sigma); + + // Calculate and return the entire Gaussian equation. return (1.0h / (2.0h * M_PI_H * sigma * sigma)) * exp(gaussianExponent); } -// Calculate blurred pixel value using the weighted average of multiple samples along the X axis -half4 gaussianBlurX(float2 pos, SwiftUI::Layer layer, half radius, half maxSamples) { +/// Calculate pixel color using the weighted average of multiple samples along the X axis. +/// +/// - Parameter position: The coordinates of the current pixel. +/// - Parameter layer: The SwiftUI layer we're reading from. +/// - Parameter radius: The desired blur radius. +/// - Parameter axisMultiplier: A vector defining which axis to sample along. Should be (1, 0) for X, or (0, 1) for Y. +/// - Parameter maxSamples: The maximum number of samples to read in each direction from the current pixel. Texture sampling is expensive, so instead of sampling every pixel, we use a lower count spread out across the radius. +half4 gaussianBlur1D(float2 position, SwiftUI::Layer layer, half radius, half2 axisMultiplier, half maxSamples) { + // Calculate how far apart the samples should be: either 1 pixel or the desired radius divided by the maximum number of samples, whichever is farther. const half interval = max(1.0h, radius / maxSamples); - - // take the first sample - const half weight = gaussianKernel1D(0.0h, radius / 2.0h); - half4 total = layer.sample(pos) * weight; - half count = weight; - // if the radius is high enough to take more samples, take them + // Take the first sample. + // Calculate the weight for this sample in the weighted average using the Gaussian equation. + const half weight = gaussian(0.0h, radius / 2.0h); + // Sample the pixel at the current position and multiply its color by the weight, to use in the weighted average. + // Each sample's color will be combined into the `weightedColorSum` variable (the numerator for the weighted average). + half4 weightedColorSum = layer.sample(position) * weight; + // The `totalWeight` variable will keep track of the sum of all weights (the denominator for the weighted average). Start with the weight of the current sample. + half totalWeight = weight; + + // If the radius is high enough to take more samples, take them. if(interval <= radius) { + + // Take a sample every `interval` up to and including the desired blur radius. for (half distance = interval; distance <= radius; distance += interval) { - const half weight = gaussianKernel1D(distance, radius / 2.0h); - count += weight * 2.0h; - total += layer.sample(float2(half(pos.x) - distance, pos.y)) * weight; - total += layer.sample(float2(half(pos.x) + distance, pos.y)) * weight; + // Calculate the sample offset as a 2D vector. + const half2 offsetDistance = axisMultiplier * distance; + + // Calculate the sample's weight using the Gaussian equation. For the sigma value, we use half the blur radius so that the resulting bell curve fits nicely within the radius. + const half weight = gaussian(distance, radius / 2.0h); + + // Add the weight to the total. Double the weight because we are taking two samples per iteration. + totalWeight += weight * 2.0h; + + // Take two samples along the axis, one in the positive direction and one negative, multiply by weight, and add to the sum. + weightedColorSum += layer.sample(float2(half2(position) + offsetDistance)) * weight; + weightedColorSum += layer.sample(float2(half2(position) - offsetDistance)) * weight; } } - // return the weighted average of all samples - return total / count; + // Return the weighted average color of the samples by dividing the weighted sum of the colors by the sum of the weights. + return weightedColorSum / totalWeight; } -// Calculate blurred pixel value using the weighted average of multiple samples along the Y axis -half4 gaussianBlurY(float2 pos, SwiftUI::Layer layer, half radius, half maxSamples) { - const half interval = max(1.0h, radius / maxSamples); +/// Variable blur effect along the specified axis that samples from a texture to determine the blur radius multiplier at each pixel. This shader requires two passes, one along the X axis and one along the Y. +/// +/// The two-pass approach is better for performance as it scales linearly rather than exponentially with pixel count * radius * sample count, but can result in "streak" artifacts where blurred areas meet unblurred areas. +/// +/// - Parameter position: The coordinates of the current pixel in user space. +/// - Parameter layer: The SwiftUI layer we're applying the blur to. +/// - Parameter boundingRect: The bounding rectangle of the SwiftUI view in user space. +/// - Parameter radius: The desired maximum blur radius for areas of the mask that are fully opaque. +/// - Parameter maxSamples: The maximum number of samples to read _in each direction_ from the current pixel. Reducing this value increases performance but results in banding in the resulting blur. +/// - Parameter mask: The texture to sample alpha values from to determine the blur radius at each pixel. +/// - Parameter vertical: Specifies to blur along the Y axis. Because SwiftUI can't pass booleans to a shader, `0.0` is treated as false (i.e. blur the X axis) and any other value is treated as true (i.e. blur the Y axis). +[[ stitchable ]] half4 variableBlur(float2 pos, SwiftUI::Layer layer, float4 boundingRect, float radius, float maxSamples, texture2d mask, float vertical) { + // Calculate the position in UV space within the bounding rect (0 to 1). + const float2 uv = float2(pos.x / boundingRect[2], pos.y / boundingRect[3]); - // take the first sample - const half weight = gaussianKernel1D(0.0h, radius / 2.0h); - half4 total = layer.sample(pos) * weight; - half count = weight; + // Sample the alpha value of the mask at the current UV position. + const half maskAlpha = mask.sample(metal::sampler(metal::filter::linear), uv).a; - // if the radius is high enough to take more samples, take them - if(interval <= radius) { - for (half distance = interval; distance <= radius; distance += interval) { - const half weight = gaussianKernel1D(distance, radius / 2.0h); - count += weight * 2.0h; - total += layer.sample(float2(pos.x, half(pos.y) - distance)) * weight; - total += layer.sample(float2(pos.x, half(pos.y) + distance)) * weight; - } - } + // Determine the blur radius at this pixel by multiplying the alpha value from the mask with the radius parameter. + const half pixelRadius = maskAlpha * half(radius); - // return the weighted average of all samples - return total / count; -} - -// Variable blur effect along the specified axis that samples from a texture to determine the blur radius multiplier -[[ stitchable ]] half4 variableBlur(float2 pos, SwiftUI::Layer layer, float radius, float maxSamples, texture2d mask, float2 size, float vertical) { - // sample the mask at the current position - const half4 maskSample = mask.sample(metal::sampler(metal::filter::linear), pos / size); - // determine the blur radius at this pixel based on the sample's alpha - const half pixelRadius = maskSample.a * half(radius); - // apply the blur if the effective radius is nonzero + // If the resulting radius is 1 pixel or greater… if(pixelRadius >= 1) { - return vertical == 0.0 ? gaussianBlurX(pos, layer, pixelRadius, maxSamples) : gaussianBlurY(pos, layer, pixelRadius, maxSamples); + // Set the "axis multiplier" value that tells the blur function whether to sample along the X or Y axis. + const half2 axisMultiplier = vertical == 0.0 ? half2(1, 0) : half2(0, 1); + + // Return the blurred color. + return gaussianBlur1D(pos, layer, pixelRadius, axisMultiplier, maxSamples); } else { + // If the blur radius is less than 1 pixel, return the current pixel's color as-is. return layer.sample(pos); } } diff --git a/Sources/Variablur/Documentation.docc/Documentation.md b/Sources/Variablur/Documentation.docc/Documentation.md index ce57af7..c5d306b 100644 --- a/Sources/Variablur/Documentation.docc/Documentation.md +++ b/Sources/Variablur/Documentation.docc/Documentation.md @@ -16,8 +16,9 @@ To see live examples of some of the effects you can make, look at the Xcode Prev ### Applying variable blur to a view -- ``SwiftUI/View/variableBlur(radius:maxSampleCount:prioritizeVerticalPass:maskRenderer:)`` +- ``SwiftUI/View/variableBlur(radius:maxSampleCount:verticalPassFirst:maskRenderer:)`` +- ``SwiftUI/View/variableBlur(radius:maxSampleCount:verticalPassFirst:mask:)`` ### Applying variable blur as a VisualEffect -- ``SwiftUI/VisualEffect/variableBlur(radius:maxSampleCount:prioritizeVerticalPass:mask:maskSize:isEnabled:)`` +- ``SwiftUI/VisualEffect/variableBlur(radius:maxSampleCount:verticalPassFirst:mask:isEnabled:)`` diff --git a/Sources/Variablur/View+variableBlur.swift b/Sources/Variablur/View+variableBlur.swift index 85de2a7..b33d298 100644 --- a/Sources/Variablur/View+variableBlur.swift +++ b/Sources/Variablur/View+variableBlur.swift @@ -10,12 +10,36 @@ import SwiftUI @available(iOS 17, macOS 14, macCatalyst 17, tvOS 17, visionOS 1, *) public extension View { + /// Applies a variable blur to the view, with the blur radius at each pixel determined by a mask image. + /// + /// - Parameters: + /// - radius: The radial size of the blur in areas where the mask is fully opaque. + /// - maxSampleCount: The maximum number of samples the shader may take from the view's layer in each direction. Higher numbers produce a smoother, higher quality blur but are more GPU intensive. Values larger than `radius` have no effect. The default of 15 provides balanced results but may cause banding on some images at larger blur radii. + /// - verticalPassFirst: Whether or not to perform the vertical blur pass before the horizontal one. Changing this parameter may reduce smearing artifacts. Defaults to `false`, i.e. perform the horizontal pass first. + /// - mask: An `Image` to use as the mask for the blur strength. + /// - Returns: The view with the variable blur effect applied. + func variableBlur( + radius: CGFloat, + maxSampleCount: Int = 15, + verticalPassFirst: Bool = false, + mask: Image + ) -> some View { + self.visualEffect { content, _ in + content.variableBlur( + radius: radius, + maxSampleCount: maxSampleCount, + verticalPassFirst: verticalPassFirst, + mask: mask + ) + } + } + /// Applies a variable blur to the view, with the blur radius at each pixel determined by a mask that you create. /// /// - Parameters: /// - radius: The radial size of the blur in areas where the mask is fully opaque. /// - maxSampleCount: The maximum number of samples the shader may take from the view's layer in each direction. Higher numbers produce a smoother, higher quality blur but are more GPU intensive. Values larger than `radius` have no effect. The default of 15 provides balanced results but may cause banding on some images at larger blur radii. - /// - prioritizeVerticalPass: Whether or not to perform the vertical blur pass before the horizontal one. Changing this parameter may reduce smearing artifacts. Defaults to `false`, i.e. perform the horizontal pass first. + /// - verticalPassFirst: Whether or not to perform the vertical blur pass before the horizontal one. Changing this parameter may reduce smearing artifacts. Defaults to `false`, i.e. perform the horizontal pass first. /// - maskRenderer: A rendering closure to draw the mask used to determine the intensity of the blur at each pixel. The closure receives a `GeometryProxy` with the view's layout information, and a `GraphicsContext` to draw into. /// - Returns: The view with the variable blur effect applied. /// @@ -23,24 +47,23 @@ public extension View { /// /// - Tip: To achieve a progressive blur or gradient blur, draw a gradient from transparent to opaque in your mask image where you want the transition from clear to blurred to take place. /// - /// - Note: Because the blur is split into horizontal and vertical passes for performance, certain mask images over certain patterns may cause "smearing" artifacts along one axis. Changing the `prioritizeVerticalPass` parameter may reduce this, but may cause smearing in the other direction.. To avoid smearing entirely, avoid drawing hard edges in your `maskRenderer`. + /// - Note: Because the blur is split into horizontal and vertical passes for performance, certain mask images over certain patterns may cause "smearing" artifacts along one axis. Changing the `verticalPassFirst` parameter may reduce this, but may cause smearing in the other direction.. To avoid smearing entirely, avoid drawing hard edges in your `maskRenderer`. /// /// - Important: Because this effect is implemented as a SwiftUI `layerEffect`, it is subject to the same limitations. Namely, views backed by AppKit or UIKit views may not render. Instead, they log a warning and display a placeholder image to highlight the error. func variableBlur( radius: CGFloat, maxSampleCount: Int = 15, - prioritizeVerticalPass: Bool = false, + verticalPassFirst: Bool = false, maskRenderer: @escaping (GeometryProxy, inout GraphicsContext) -> Void ) -> some View { self.visualEffect { content, geometryProxy in content.variableBlur( radius: radius, maxSampleCount: maxSampleCount, - prioritizeVerticalPass: prioritizeVerticalPass, + verticalPassFirst: verticalPassFirst, mask: Image(size: geometryProxy.size, renderer: { context in maskRenderer(geometryProxy, &context) - }), - maskSize: geometryProxy.size + }) ) } } @@ -89,7 +112,7 @@ public extension View { #Preview("Blur masked using a shape") { Image(systemName: "circle.hexagongrid") .font(.system(size: 300)) - .variableBlur(radius: 30, prioritizeVerticalPass: true) { geometryProxy, context in + .variableBlur(radius: 30, verticalPassFirst: true) { geometryProxy, context in // draw a shape in an opaque color to apply the variable blur within the shape context.fill( Path( diff --git a/Sources/Variablur/VisualEffect+variableBlur.swift b/Sources/Variablur/VisualEffect+variableBlur.swift index 11b4baf..d91e315 100644 --- a/Sources/Variablur/VisualEffect+variableBlur.swift +++ b/Sources/Variablur/VisualEffect+variableBlur.swift @@ -13,16 +13,15 @@ internal let library = ShaderLibrary.bundle(Bundle.module) @available(iOS 17, macOS 14, macCatalyst 17, tvOS 17, visionOS 1, *) public extension VisualEffect { - /// Applies a variable blur, with the blur radius at each pixel determined by a mask image. Requires a mask image that matches the size of the view's layer. + /// Applies a variable blur, with the blur radius at each pixel determined by a mask image. /// - /// - Tip: Rather than using this effect directly, try ``SwiftUI/View/variableBlur(radius:maxSampleCount:prioritizeVerticalPass:maskRenderer:)`` which automatically handles creating a mask image of the correct size for you to draw into. + /// - Tip: To automatically generate a mask image of the same size as the view, use ``SwiftUI/View/variableBlur(radius:maxSampleCount:verticalPassFirst:maskRenderer:)`` which creates the image from drawing instructions you provide to a `GraphicsContext`. /// /// - Parameters: /// - radius: The maximum radial size of the blur in areas where the mask is fully opaque. /// - maxSampleCount: The maximum number of samples to take from the view's layer in each direction. Higher numbers produce a smoother, higher quality blur but are more GPU intensive. Values larger than `radius` have no effect. - /// - prioritizeVerticalPass: Whether or not to perform the vertical blur pass before the horizontal one. Changing this parameter may reduce smearing artifacts. Defaults to `false`, i.e. perform the horizontal pass first. - /// - mask: An image with an alpha channel to use as mask to determine the strength of the blur effect at each pixel. Fully transparent areas are unblurred; fully opaque areas are blurred by the full radius; partially transparent areas are blurred by the radius multiplied by the alpha value. The mask image should match the size of the view's layer. - /// - maskSize: The size (resolution) of the mask image. Should match the size of the view the effect is applied to. + /// - verticalPassFirst: Whether or not to perform the vertical blur pass before the horizontal one. Changing this parameter may reduce smearing artifacts. Defaults to `false`, i.e. perform the horizontal pass first. + /// - mask: An image with an alpha channel to use as mask to determine the strength of the blur effect at each pixel. Fully transparent areas are unblurred; fully opaque areas are blurred by the full radius; partially transparent areas are blurred by the radius multiplied by the alpha value. The mask will be uv-mapped to cover the entire view. /// - isEnabled: Whether the effect is enabled or not. /// - Returns: A new view that renders `self` with the blur shader applied as a layer effect. /// @@ -30,29 +29,28 @@ public extension VisualEffect { func variableBlur( radius: CGFloat, maxSampleCount: Int = 15, - prioritizeVerticalPass: Bool = false, + verticalPassFirst: Bool = false, mask: Image, - maskSize: CGSize, isEnabled: Bool = true ) -> some VisualEffect { self.layerEffect( library.variableBlur( + .boundingRect, .float(radius), .float(CGFloat(maxSampleCount)), .image(mask), - .float2(maskSize), - .float(prioritizeVerticalPass ? 1 : 0) + .float(verticalPassFirst ? 1 : 0) ), maxSampleOffset: CGSize(width: radius , height: radius), isEnabled: isEnabled ) .layerEffect( library.variableBlur( + .boundingRect, .float(radius), .float(CGFloat(maxSampleCount)), .image(mask), - .float2(maskSize), - .float(prioritizeVerticalPass ? 0 : 1) + .float(verticalPassFirst ? 0 : 1) ), maxSampleOffset: CGSize(width: radius, height: radius), isEnabled: isEnabled