João Gabriel
João GabrielDecember 7, 2024

Shaders in SwiftUI: Randomness

Learn how you can use Metal shaders to render randomness.
Shaders in SwiftUI: Randomness

Hello! And welcome back to another one of our shader tutorials.


This time, we'll be discussing how and why to use randomness in shader creations. If you're not familiar with shaders in SwiftUI, make sure to check our Introduction tutorial on the subject. This time around, we'll be using the colorEffect modifier.

This tutorial will mostly adapt content from The Book of Shaders to the paradigm of Metal.

Simple randomness in Swift

At this point, you might be very familiar with the rand() functions in Swift. They are implemented on numeric types, and they work like so:

print(Double.rand(in: 0...10)) // 5.123456789

Internally, the code above will call a method on the SystemRandomNumberGenerator class of the Swift standard library, which in turn calls a function from within the Swift compiler — this function varies from platform to platform. You can find more details about the inner workings of rand() in the Swift source code.

On Apple platforms, the rand() function uses arc4random_buf(3), which generates random bytes without a specified position. Although they're technically pseudorandom, since they are generated by an algorithm, they're random enough to make them usable for the vast majority of Mac and iOS development.

Well, why are we discussing all this after all?

Metal does not have a native random function. In fact, most shader languages don't — resounding with their purpose of solely describing how to render pixels, those languages tend to value reproducibility and determinism.

If you want to generate random numbers in a shader, you'll have to implement a ranadom function yourself — and that's part of our task today. We'll build a random function and put it to work. Let's get started!

Color effect

To write a color effect shader, we'll use the colorEffect modifier. Differently from the distortionEffect modifier we used in our previous tutorials, this effect will, for each pixel on the screen, receive its position and current color, do some type of calculation (that's our job) and return a new color. The color value, in this case, is a half4 type, which is a vector with four components: red, green, blue and alpha.

According to the documentation, this is the required shader signature of the colorEffect modifier:

[[stitchable]] half4 name(float2 position, half4 color, args...)

Let's go ahead and create a random.metal file, and defining our initial shader code:

random.metal
#include <metal_stdlib>
using namespace metal;
 
[[stitchable]] half4 random(float2 position, half4 color) {
    return half4(1, 0, 0, 1); // red
}

Our half4 return value specifies a vector with four components: red, green, blue and alpha, in a range from 0 to 1. In this case, we're returning a red color.

Fantastic. Now, let's go ahead and apply this shader to a view:

ContentView.swift
import SwiftUI
 
struct ContentView: View {
    var body: some View {
        Color.black
            .aspectRatio(1.0, contentMode: .fit)
            .colorEffect(ShaderLibrary.random())
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .padding()
    }
}

Fantastic! This is the result we get:

Shaders in SwiftUI: Randomness

As expected, our SwiftUI Color view — which is black originally — becomes red when the shader is applied. We'll be playing with randomness in a bit, but first, let's discuss how we can use the position parameter of our shader function by creating a simple color gradient.

random.metal
[[stitchable]] half4 random(float2 position, half4 color) {
    return half4(position.x, position.y, 0, 1);
}

Okay, we're getting something different now. The rectangle has become yellow, since, except for the very first column and row of pixels, the x and y values of the position parameter are always greater than 1 — therefore the result will always be maxxed out in red and green, resulting in this bright yellow.

Shaders in SwiftUI: Randomness

We can overcome this by dividing the position components by the width and height of our view. We could pass that down from SwiftUI programatically, but for testing purposes let's estimate its size at about 256 points.

random.metal
[[stitchable]] half4 random(float2 position, half4 color) {
    return half4(position.x/256, position.y/256, 0, 1);
}
Shaders in SwiftUI: Randomness

Looks much better now!

This is how we can get position data into the shader. Let's talk about randomness now.

Randomness

The conventional method of writing a randomness generator function within a shader is to simply use the position of the current pixel as a base number, and then play with it mathematically to get something that's pretty close to actual randomness, while being 100% deterministic. That's called pseudo-randomness.

One of the simplest ways of achieving pseudo-randomness is through extraction of the fractional component of a number, which is done by the fract() function. For example, fract(1.5) returns 0.5. Pair that with a sine wave multiplied by a number of your choice, for example, and this is the result:

noise.metal
float rand(float t) {
    return fract(sin(t)*213412.7);
}
 
[[stitchable]] half4 random(float2 position, half4 color) {
    float white = rand(position.x);
    return half4(white, white, white, 1);
}
Shaders in SwiftUI: Randomness

To adapt it to a 2D environment, we can replace t with a dot product of the pixel position by a vector of your choice.


The dot product can be defined as a number indicating the alignment of two vectors (let's call them a and b), and, for two two-dimensional vectors, it can be defined by the following formula: x[0] * y[0] + x[1] * y[1]. The Metal Standard Library has an implementation of this with the dot() function. You can find more about it in the documentation.


This is how it can be done:

noise.metal
float rand(float2 st) {
    return fract(sin(dot(st.xy, float2(12.9898, 78.233)))*(43758.5453123));
}
 
[[stitchable]] half4 random(float2 position, half4 color) {
    float white = rand(position);
    return half4(white, white, white, 1);
}

Notice how we're also multiplying the result of the dot product by another number, which, by the way, is also completely up to you.

Fantastic. We've now gotten ourselves a random shader function. Let's see how it looks:

Shaders in SwiftUI: Randomness

In the screenshot above, we can see how the shader is rendering a single grayscale value for each pixel. To enlarge the size of each pixel, let's change the shader function like so:

noise.metal
float rand(float2 st) {
    return fract(sin(dot(st.xy, float2(12.9898, 78.233)))*(43758.5453123));
}
 
[[stitchable]] half4 random(float2 position, half4 color) {
    float2 pos = position/10; // First, let's divide the position by 10
 
    float2 floored = floor(pos); // Then, let's floor it
 
    float white = rand(floored);
    return half4(white, white, white, 1);
}
 

Dividing and flooring works the following way: let's say that, in the original xy plane, we had a range of pixels from 1 through 30. Dividing by 10 would give us a range from 0.1 through 3, including a few fractional parts, such as 0.8, 1.7, and 2.5.

Shaders in SwiftUI: Randomness

By flooring (rounding to the nearest lower integer), we make it so that all fractional pixels are treated within the same "blob", resulting in a single color for all of them.

This is the result:

Shaders in SwiftUI: Randomness

Great! We now can clearly see the random blobs of pixels being displayed. In the next tutorial, we'll see how we can take advantage of the fractional part of those blobs to reproduce patterns.

Before we leave though, let's do something fun: let's animate it! TV static, anyone?

Animating the shader

To animate the shader, let's add back a parameter t to our rand() function, and use it to animate the output.

noise.metal
float rand(float2 st, float t) {
    return fract(sin(dot(st.xy, float2(12.9898, 78.233)*t)+t)*(43758.5453123));
}

Now, let's add a time parameter to the random() function and pass it to rand():

noise.metal
[[stitchable]] half4 random(float2 position, half4 color, float time) {
    float2 pos = position/10;
 
    float2 floored = floor(pos);
    float white = rand(floored, time);
 
    return half4(white, white, white, 1);
}

Great! Finally, let's wrap our SwiftUI layout with a TimelineView and supply the shader with the proper elapsed time.

ContentView.swift
struct ContentView: View {
    let startDate = Date.now // First, let's store the start date
 
    var body: some View {
        TimelineView(.animation) { context in
            // Then, let's calculate the elapsed time and multiply it by 0.5 for half the speed
            let elapsed = context.date.timeIntervalSince(startDate)*0.5
 
            Color.black
                .colorEffect(ShaderLibrary.random(.float(elapsed)))
        }w
        .aspectRatio(1.0, contentMode: .fit)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .padding()
    }
}

While we're at it, it could be a good idea to also parameterize the scaling factor of the shader, so that we can change it on the fly. Let's do that:

noise.metal
[[stitchable]] half4 random(float2 position, half4 color, float time, float scale) {
    float2 pos = position/scale; // Add a scale parameter
 
    float2 floored = floor(pos);
    float white = rand(floored, time);
 
    return half4(white, white, white, 1);
}

And then, let's add a slider to our SwiftUI view:

ContentView.swift
struct ContentView: View {
    @State private var scale: Float = 10 // First, let's add a state variable for the scale
 
    let startDate = Date.now
 
    var body: some View {
        TimelineView(.animation) { context in
            let elapsed = context.date.timeIntervalSince(startDate)*0.5
 
            Color.black
                .aspectRatio(1.0, contentMode: .fit)
                .colorEffect(ShaderLibrary.random(.float(elapsed), .float(scale))) // Then, let's pass the scale to the shader
                .clipShape(RoundedRectangle(cornerRadius: 12))
                .padding()
                .overlay(
                    VStack {
                        Slider(value: $scale, in: 1...100) // Finally, let's add a slider to the view
                    }
                )
        }
    }
}

And this is our final result:

Shaders in SwiftUI: Randomness

Pretty cool, huh? You know now how to achieve pseudo-randomness within the context of a Metal shader, and how to animate it. For the next steps, think about how you could use this in a real-world application — maybe a fun and quirky loading screen or something else. The possibilities are endless!