Skip to content

Commit

Permalink
in depth documentation of the shader; use UV coordinates to sample fr…
Browse files Browse the repository at this point in the history
…om the mask (no longer requires mask size to match the view size); add a version of the modifier that takes an `Image` directly
  • Loading branch information
daprice committed Nov 28, 2023
1 parent 47c2097 commit 3fb4592
Show file tree
Hide file tree
Showing 4 changed files with 111 additions and 65 deletions.
114 changes: 69 additions & 45 deletions Sources/Variablur/Blurs.metal
Original file line number Diff line number Diff line change
Expand Up @@ -3,68 +3,92 @@ using namespace metal;

#include <SwiftUI/SwiftUI_Metal.h>

// 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<half> 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<half> 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);
}
}
5 changes: 3 additions & 2 deletions Sources/Variablur/Documentation.docc/Documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:)``
37 changes: 30 additions & 7 deletions Sources/Variablur/View+variableBlur.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,37 +10,60 @@ 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.
///
/// The strength of the blur effect at any point on the view is determined by the transparency of the mask at that point. Areas where the mask is fully opaque are blurred by the full radius; areas where the mask is partially transparent are blurred by a proportionally smaller radius. Areas where the mask is fully transparent are left unblurred.
///
/// - 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
})
)
}
}
Expand Down Expand Up @@ -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(
Expand Down
20 changes: 9 additions & 11 deletions Sources/Variablur/VisualEffect+variableBlur.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,46 +13,44 @@ 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.
///
/// - Important: Because this effect is based on SwiftUI's `layerEffect`, 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,
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
Expand Down

0 comments on commit 3fb4592

Please sign in to comment.