Exploring Metal: Creating Parameterised Noise Effect

I like Metal. Metal music, I mean. But was always intimidated by the Metal framework.

SwiftUI has made working with Metal and different effects easier than ever. We have different modifiers and directly call the method on the view itself. I am updating the noise effect on my app Meshing and decided to play around with a cool parameterised noise effect. It gives the user control over intensity, frequency, and opacity.

Shaders

Let's start with the shader code. Shaders are special programs that run on your device's graphics processor (GPU) to create visual effects. Here is the code for it:

#include <SwiftUI/SwiftUI_Metal.h>
#include <metal_stdlib>
using namespace metal;

[[ stitchable ]]
half4 parameterizedNoise(float2 position, half4 color, float intensity, float frequency, float opacity) {
    float value = fract(cos(dot(position * frequency, float2(12.9898, 78.233))) * 43758.5453);

    float r = color.r * mix(1.0, value, intensity);
    float g = color.g * mix(1.0, value, intensity);
    float b = color.b * mix(1.0, value, intensity);

    return half4(r, g, b, opacity);
}

Breaking this down line by line:

  • #include <SwiftUI/SwiftUI_Metal.h> and #include <metal_stdlib>: These lines import necessary Metal libraries to give us access to Metal's functions and types.
  • using namespace metal;: This line lets us use Metal's types and functions without typing "metal::" before each one.
  • [[ stitchable ]]: This attribute tells SwiftUI that this function can be a shader in SwiftUI views. Think of it as a bridge between SwiftUI and Metal.
  • half4 parameterizedNoise(...): This is the main function. It takes several inputs and returns a half4, a colour with red, green, blue, and alpha components. Each component is a "half" precision floating-point number, which is faster than full precision but still accurate enough for colours.
  • float2 position: This is the current pixel position we are colouring. It is a 2D vector with x and y coordinates.
  • half4 color: This is the base colour we are applying the noise to.
  • float intensityfloat frequencyfloat opacity: These parameters control how our noise looks.
  • float value = fract(cos(dot(position * frequency, float2(12.9898, 78.233))) * 43758.5453);: This line generates the noise:
    • dot(position * frequency, float2(12.9898, 78.233)): This calculates the dot product of the scaled position with a constant vector. It helps create a random-looking pattern.
    • cos(...): We take the cosine of this dot product to get a value between -1 and 1.
    • * 43758.5453: This multiplication spreads out our values.
    • fract(...): This function takes just the fractional part of a number, giving us a value between 0 and 1.
  • float r = color.r * mix(1.0, value, intensity);: This line calculates the red component of our final colour. Based on the intensity, the mix function blends between 1.0 and our noise value. We do the same for green and blue components.
  • return half4(r, g, b, opacity);: Finally, we return the new colour using the calculated r, g, b values and the given opacity.

We then create a file, Shader.metal, and put it in our project. Now that we have got our shader, let's create a SwiftUI view to use it. I will use an Aurora Borealis template from the Meshing app to apply the noise effect.

Creating a Container View

I usually prefer a container view so that I can use it anywhere and everywhere with different views:

struct ParameterizedNoiseView<Content: View>: View {
    let content: Content
    @Binding var intensity: Float
    @Binding var frequency: Float
    @Binding var opacity: Float

    init(intensity: Binding<Float>, frequency: Binding<Float>, opacity: Binding<Float>, @ViewBuilder content: () -> Content) {
        self._intensity = intensity
        self._frequency = frequency
        self._opacity = opacity
        self.content = content()
    }

    var body: some View {
        content
            .colorEffect(
                ShaderLibrary.parameterizedNoise(
                    .float(intensity),
                    .float(frequency),
                    .float(opacity)
                )
            )
    }
}

This ParameterizedNoiseView is a container view that applies the noise shader to any content we put inside it. We use @Binding for intensityfrequency, and opacity. This allows the parent view to control these values while the container view can read and use them.

In the body, we apply the colorEffect modifier to the content. It lets you apply custom shaders to your views, like adding a special filter to your photos, but for any part of the app's interface. It helps to get the underlying pixel's position and colour automatically without you writing any code.

Finally, we call ShaderLibrary.parameterizedNoise with the parameters to create the shader effect.

The shader function must have a specific signature to work as a color filter. It does not run on compile time so if you mess up the parameters, it will not work. And then you play the detective game to understand what went wrong.

Beautiful Mesh Gradient with Noises

Finally, we will create a beautiful Aurora Borealis mesh gradient for the Northern Lights' shimmering, colourful look. And then use the parameterised noise shader. I will break it down for you in simple terms.

First, we have a GradientTemplate struct:

struct GradientTemplate {
    let name: String
    let gridSize: Int
    let background: Color
    let colors: [Color]
    let positions: [SIMD2<Float>]
}

This structure is what I use for all the templates. Next, we have the main view, AuroraBorealisGradientView:

struct AuroraBorealisGradientView: View {
  @State private var intensity: Float = 0.5
  @State private var frequency: Float = 1.0
  @State private var opacity: Float = 0.5

  private let template: GradientTemplate = {
    let colorStrings = [
      "#00264d", "#004080", "#0059b3", "#0073e6",
      "#1a8cff", "#4da6ff", "#80bfff", "#b3d9ff",
      "#00ff80", "#33ff99", "#66ffb3", "#99ffcc",
      "#004d40", "#00665c", "#008577", "#00a693"
    ]
    let colors = colorStrings.map { Color(hex: $0) }

    let positions: [SIMD2<Float>] = [
      .init(x: 0.000, y: 0.000), .init(x: 0.263, y: 0.000),
      .init(x: 0.680, y: 0.000), .init(x: 1.000, y: 0.000),
      .init(x: 0.000, y: 0.244), .init(x: 0.565, y: 0.340),
      .init(x: 0.815, y: 0.689), .init(x: 1.000, y: 0.147),
      .init(x: 0.000, y: 0.715), .init(x: 0.289, y: 0.418),
      .init(x: 0.594, y: 0.766), .init(x: 1.000, y: 0.650),
      .init(x: 0.000, y: 1.000), .init(x: 0.244, y: 1.000),
      .init(x: 0.672, y: 1.000), .init(x: 1.000, y: 1.000)
    ]

    return GradientTemplate(
      name: "Aurora Borealis",
      gridSize: 4,
      background: Color(hex: "#001a33"),
      colors: colors,
      positions: positions
    )
  }()

  var body: some View {
    VStack {
      ParameterizedNoisesView(intensity: $intensity, frequency: $frequency, opacity: $opacity) {
        MeshGradient(width: template.gridSize,
                     height: template.gridSize,
                     points: template.positions,
                     colors: template.colors)
        .background(template.background)
      }
      .cornerRadius(16)
      .padding()

      VStack {
        Text("Intensity: \(intensity, specifier: "%.2f")")
          .bold()
        Slider(value: $intensity, in: 0...1)

        Text("Frequency: \(frequency, specifier: "%.2f")")
          .bold()
        Slider(value: $frequency, in: 0.1...10)

        Text("Opacity: \(opacity, specifier: "%.2f")")
          .bold()
        Slider(value: $opacity, in: 0.1...1)
      }
      .padding()
    }
  }
}

It sets up state variables for intensity, frequency, and opacity and adds sliders to control the noise parameters. Then, it uses the ParameterizedNoisesView to apply the noise effect to a MeshGradient.

Lastly, we have an extension on Color:

extension Color {
  init(hex: String) {
    let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted)
    var int: UInt64 = 0
    Scanner(string: hex).scanHexInt64(&int)
    let a, r, g, b: UInt64
    switch hex.count {
      case 3: // RGB (12-bit)
        (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17)
      case 6: // RGB (24-bit)
        (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF)
      case 8: // ARGB (32-bit)
        (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF)
      default:
        (a, r, g, b) = (1, 1, 1, 0)
    }

    self.init(
      .sRGB,
      red: Double(r) / 255,
      green: Double(g) / 255,
      blue:  Double(b) / 255,
      opacity: Double(a) / 255
    )
  }
}

This extension lets us create colours from hex strings. Super handy for defining colors in the gradient template.

Users can adjust the noise's intensity, frequency, and opacity, changing how the "lights" shimmer and move. And how much they want.

This is how it looks:

0:00
/0:17

Moving Forward

My long-term goal for this project is to implement a BYOS (Bring Your Own Shaders) system. This ambitious feature would allow users to compile and use their own custom shaders right in the app.

I have done my research on this, and I know it is possible. The idea is to use a backend service to compile user-submitted shader code. However, this is not a quick or easy feature to implement. It is quite time-consuming and involves some backend work.

I am excited about BYOS. It could turn the app into a playground for shader enthusiasts and push the boundaries of what's possible with mobile graphics! Keep an eye out for BYOS!

Happy shading!