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:
#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:
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:
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.
[[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.
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.
[[stitchable]] half4 random(float2 position, half4 color) {
return half4(position.x/256, position.y/256, 0, 1);
}
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:
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);
}
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:
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:
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:
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.
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:
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.
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()
:
[[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.
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:
[[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:
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:
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!