October 4, 2024

Nikita Patskov

Nikita Patskov

@NikitkaPa

Beautiful shader gradients in iOS

Being one step ahead of Apple.

Post coverPost cover
I've always been in love with awesome gradients. They everywhere. Gradients conquered design language a long time ago. It's time to make a step forward and bring the joy of movement.
There are many approaches on how to bring your gradient to life. The simplest one is to just change linear gradient properties over time. Both UIKit and SwiftUI frameworks give you possibility to easily implement that. But I always wanted to do more. Wanted to make something more magical and shiny.
Last year I started to learn how shaders work. At first, I've ported mesh gradient on metal, initially implemented by movingparts team. It looked okay in static, but it's animation felt unnatural. Then I recreated Apple's 'Hello' startup animation on metal too to build up more shaders knowledge.
You curious to see the final result? Just scroll down to take a look on final result on the bottom of this blogpost page!

Playground

I'm adding final result that you can play with to top of that article. Change colors, tap buttons, move sliders to explore possibilities.
Bottom border
X normal
Y normal
Z normal
Noise dencity
Noise strength
Num vertices

Starting simple

Let's create a simple plane in Metal geometry first and set up renderer. Initial setup pretty much replicates simple Apple tutorial about how to draw a triangle in metal. The only difference is that we have 20 triangles instead of just one. You can inspect the plane contents by toggling wireframe switch. Everything our vertex shader can do is to passthrough everything to fragment shader.
Shader.metal
struct VertexIn {
    simd_float4 position;
    simd_float4 color;
};

struct VertexOut {
    simd_float4 position [[position]];
    simd_float4 color;
};

vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
                             constant VertexIn *vertices [[buffer(0)]]) {
    VertexIn in = vertices[vertexID];
    VertexOut out;

    out.position = in.position;
    out.color = in.color;
    return out;
}

fragment float4 fragment_main(VertexOut in [[stage_in]]) {
    return in.color;
}
Mesh.swift
// Color is (r, g, b, a) components
static func complexPlane() -> [VertexIn] {
    var result: [VertexIn] = []
    let size = SIMD2<Float>(10, 2)
    for x in stride(from: 0, to: size.x, by: 1) {
        for y in stride(from: 0, to: size.y, by: 1) {
            let position = SIMD3(.init(x / size.x, y).metal, 0)
            result.append(
                .init(position: .init(position, 1),
                      color: .init(1, 1, 1, 1))
            )
        }
    }
    return result
}

private extension SIMD2<Float> {
    // Converts from origin at topLeft to origin at center of the screen
    var toMetalGeometry: SIMD2<Float> {
        .init(x * 2 - 1, (1 - y) * 2 - 1)
    }
}
Renderer.swift
func draw(in view: MTKView) {
    guard let drawable = view.currentDrawable else { return }

    let renderPassDescriptor = MTLRenderPassDescriptor()
    renderPassDescriptor.colorAttachments[0].texture = drawable.texture
    renderPassDescriptor.colorAttachments[0].loadAction = .clear
    renderPassDescriptor.colorAttachments[0].storeAction = .store
    renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(0.0, 0.0, 0.0, 1.0)

    let commandBuffer = commandQueue.makeCommandBuffer()!
    let commandEncoder = commandBuffer.
          makeRenderCommandEncoder(descriptor: renderPassDescriptor)!

    commandEncoder.setRenderPipelineState(pipelineState)

    commandEncoder.setVertexBuffer(node.buffer, offset: 0, index: 0)

    commandEncoder.drawPrimitives(type: .triangleStrip vertexStart: 0, vertexCount: node.vertexCount)

    commandEncoder.endEncoding()
    commandBuffer.present(drawable)
    commandBuffer.commit()
}
In renderer we simply pass our parameters to shader.
node.buffer is an array of VertexIn that we created in complexPlane method, wrapped to MTLBuffer. Also note that we use triangle strip primitive types here.

⌥⌘1

sickle@sickle-blog:~

~

cat

/tmp/greetings.txt
Hello, stranger!

There is a product that will deal with lots of your incoming emails, keeping your inbox clean. Wanna check? Just follow:

open

https://sickle.app

~

Coming to third dimension

The real awesomeness begins. 30 years ago computer games started to discover third dimension. Why we can't do the same? The idea is simple. Default gradients operate only in 2d planes. We can play with colours and stops, but there is just not enough properties change.
With 2d gradient we can choose the colour only based on x or y coordinates. The closer node (pixel) to the bottom, the closer to next color in stops it is. By adding third dimension we bringing new variables that we can play with. But let's postpone that and start with moving the plane as-is to 3-dimensional world. There are three main components that affect rendering of object in 3d.
Where the object in space and how it is oriented (rotated)? This is handled by object's translation and rotation matrices and combined to the model matrix
Shader.metal
struct ModelConstants {
    simd_float4x4 modelMatrix;
}

vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
     constant VertexIn *vertices [[buffer(0)]],
     constant ModelConstants &modelConstants [[buffer(1)]],
     constant SceneConstants &sceneConstants [[buffer(2)]]) {
    VertexIn in = vertices[vertexID];
    VertexOut out;

    out.position = modelConstants.modelMatrix * in.position;
    out.color = in.color;
    return out;
}
Model matrix
enum Axis {
    case x
    case y
    case z

    var simd: SIMD3<Float> {
        switch self {
        case .x: return .init(1, 0, 0)
        case .y: return .init(0, 1, 0)
        case .z: return .init(0, 0, 1)
        }
    }
}

constants.modelMatrix = matrix_identity_float4x4
    * Matrix.translation(direction: position)
    * Matrix.rotation(angle: .degrees(rotation.x), axis: Axis.x.simd)
    * Matrix.rotation(angle: .degrees(rotation.y), axis: Axis.y.simd)
    * Matrix.rotation(angle: .degrees(rotation.z), axis: Axis.z.simd)
    * Matrix.scale(axis: scale)
Matrix.swift
static func translation(direction d: SIMD3<Float>) -> matrix_float4x4 {
    return .init(columns: (
        .init(  1,   0,   0,  0),
        .init(  0,   1,   0,  0),
        .init(  0,   0,   1,  0),
        .init(d.x, d.y, d.z,  1)
    ))
}

static func scale(axis a: SIMD3<Float>) -> matrix_float4x4 {
    return .init(columns: (
        .init(a.x,   0,   0,  0),
        .init(  0, a.y,   0,  0),
        .init(  0,   0, a.z,  0),
        .init(  0,   0,   0,  1)
    ))
}

static func rotation(angle: Angle, axis: SIMD3<Float>) -> matrix_float4x4 {
    .init(simd_quatf(angle: Float(angle.radians), axis: axis))
}

Camera properties

Next questions in 3d is where is the "camera" in the world (position)? Where does camera view looks (rotation)? Camera also has it's own translation and rotation matrices that combined to the view matrix.
And we can use exactly same matrix transformations that we created for object. Also, let's increase amount of vertices from 10 to 50. Try out wireframe view here again! Can't see the triangles? Zoom in!
Last but not least is what is the field (angle) of view and for how long can we see stuff? This is handled by projection matrix. I got projection matrix implementation from here
Shader.metal
struct SceneConstants {
    simd_float4x4 viewMatrix;
    simd_float4x4 projection;
};

vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
     constant VertexIn *vertices [[buffer(0)]],
     constant ModelConstants &modelConstants [[buffer(1)]],
     constant SceneConstants &sceneConstants [[buffer(2)]]) {

    VertexIn in = vertices[vertexID];
    VertexOut out;

    out.position = sceneConstants.projection
     * sceneConstants.viewMatrix
     * modelConstants.modelMatrix
     * in.position;
    out.color = x.color;

    return out;
}
Camera.swift
class Camera {
    private var _zoom: Float = 45.0
    var position: SIMD3<Float> = .init(0, 0, 3)
    var rotation: SIMD3<Float> = .init(repeating: 0)
    var scale: SIMD3<Float> = .init(repeating: 1)

    var projectionMatrix: matrix_float4x4 {
        return Matrix.perspective(fov: Angle(degrees: Double(self._zoom)),
                                  aspectRatio: Renderer.aspectRatio,
                                  near: 0.1,
                                  far: 1000)
    }

    var viewMatrix: matrix_float4x4 {
        matrix_identity_float4x4
        * Matrix.rotation(angle: .degrees(Double(rotation.x)), axis: Axis.x.simd)
        * Matrix.rotation(angle: .degrees(Double(rotation.y)), axis: Axis.y.simd)
        * Matrix.rotation(angle: .degrees(Double(rotation.z)), axis: Axis.z.simd)
        * Matrix.translation(direction: -position)
    }
}
Matrix.swift
static func perspective(fov: Angle, aspectRatio: Float, near: Float, far: Float) -> matrix_float4x4 {
    let fov = Float(fov.radians)

    let t: Float = tan(fov / 2)

    let x: Float = 1 / (aspectRatio * t)
    let y: Float = 1 / t
    let z: Float = -((far + near) / (far - near))
    let w: Float = -((2 * far * near) / (far - near))

    var result = matrix_identity_float4x4
    result.columns = (
        SIMD4<Float>(x,  0,  0,   0),
        SIMD4<Float>(0,  y,  0,   0),
        SIMD4<Float>(0,  0,  z,  -1),
        SIMD4<Float>(0,  0,  w,   0)
    )
    return result
}

Let's shake it!

Once we brought our plane into 3d we can also start to play with Z coordinate. Now we came to the most interesting part. We can bring controlled randomness to our mesh by using noise functions.
Shader.metal
struct SceneConstants {
    simd_float4x4 viewMatrix;
    simd_float4x4 projection;
    float noiseDencity;
    float noiseStrength;
};

vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
     constant VertexIn *vertices [[buffer(0)]],
     constant ModelConstants &modelConstants [[buffer(1)]],
     constant SceneConstants &sceneConstants [[buffer(2)]]) {
    VertexIn in = vertices[vertexID];
    VertexOut out;
    float noiseDencity = sceneConstants.planeModifiers.noiseDencity;
    float noiseStrength = sceneConstants.planeModifiers.noiseStrength;

    float3 normal = float3(0.1, 0.5, 0.1);
    float4 position = in.position;

    float distortion = 0.75 * cnoise(0.43 * position * noiseDencity);

    float3 distorted_pos = position.xyz + normal * distortion * noiseStrength;

    out.position = sceneConstants.projection * sceneConstants.viewMatrix * modelConstants.modelMatrix * float4(distorted_pos, 1);
    out.color = in.color;

    return out;
}
What is cnoise here? There are many noise implementations. We will take classic "cnoise" variant. Originally implemented in glsl, we can easily port it to Metal.
Shader.metal
float3 mod289(float3 x) {
    return x - floor(x * (1.0 / 289.0)) * 289.0;
}

float4 mod289(float4 x) {
    return x - floor(x * (1.0 / 289.0)) * 289.0;
}

float4 permute(float4 x) {
    return mod289(((x*34.0)+1.0)*x);
}

float4 taylorInvSqrt(float4 r) {
    return 1.79284291400159 - 0.85373472095314 * r;
}

float3 fade(float3 t) {
    return t*t*t*(t*(t*6.0-15.0)+10.0);
}

float cnoise(float3 P) {
    float3 Pi0 = floor(P); // Integer part for indexing
    float3 Pi1 = Pi0 + float3(1.0); // Integer part + 1
    Pi0 = mod289(Pi0);
    Pi1 = mod289(Pi1);
    float3 Pf0 = fract(P); // Fractional part for interpolation
    float3 Pf1 = Pf0 - float3(1.0); // Fractional part - 1.0
    float4 ix = float4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
    float4 iy = float4(Pi0.yy, Pi1.yy);
    float4 iz0 = Pi0.zzzz;
    float4 iz1 = Pi1.zzzz;

    float4 ixy = permute(permute(ix) + iy);
    float4 ixy0 = permute(ixy + iz0);
    float4 ixy1 = permute(ixy + iz1);

    float4 gx0 = ixy0 * (1.0 / 7.0);
    float4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
    gx0 = fract(gx0);
    float4 gz0 = float4(0.5) - abs(gx0) - abs(gy0);
    float4 sz0 = step(gz0, float4(0.0));
    gx0 -= sz0 * (step(0.0, gx0) - 0.5);
    gy0 -= sz0 * (step(0.0, gy0) - 0.5);

    float4 gx1 = ixy1 * (1.0 / 7.0);
    float4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
    gx1 = fract(gx1);
    float4 gz1 = float4(0.5) - abs(gx1) - abs(gy1);
    float4 sz1 = step(gz1, float4(0.0));
    gx1 -= sz1 * (step(0.0, gx1) - 0.5);
    gy1 -= sz1 * (step(0.0, gy1) - 0.5);

    float3 g000 = float3(gx0.x,gy0.x,gz0.x);
    float3 g100 = float3(gx0.y,gy0.y,gz0.y);
    float3 g010 = float3(gx0.z,gy0.z,gz0.z);
    float3 g110 = float3(gx0.w,gy0.w,gz0.w);
    float3 g001 = float3(gx1.x,gy1.x,gz1.x);
    float3 g101 = float3(gx1.y,gy1.y,gz1.y);
    float3 g011 = float3(gx1.z,gy1.z,gz1.z);
    float3 g111 = float3(gx1.w,gy1.w,gz1.w);

    float4 norm0 = taylorInvSqrt(float4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
    g000 *= norm0.x;
    g010 *= norm0.y;
    g100 *= norm0.z;
    g110 *= norm0.w;
    float4 norm1 = taylorInvSqrt(float4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
    g001 *= norm1.x;
    g011 *= norm1.y;
    g101 *= norm1.z;
    g111 *= norm1.w;

    float n000 = dot(g000, Pf0);
    float n100 = dot(g100, float3(Pf1.x, Pf0.yz));
    float n010 = dot(g010, float3(Pf0.x, Pf1.y, Pf0.z));
    float n110 = dot(g110, float3(Pf1.xy, Pf0.z));
    float n001 = dot(g001, float3(Pf0.xy, Pf1.z));
    float n101 = dot(g101, float3(Pf1.x, Pf0.y, Pf1.z));
    float n011 = dot(g011, float3(Pf0.x, Pf1.yz));
    float n111 = dot(g111, Pf1);

    float3 fade_xyz = fade(Pf0);
    float4 n_z = mix(float4(n000, n100, n010, n110), float4(n001, n101, n011, n111), fade_xyz.z);
    float2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
    float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
    return 2.2 * n_xyz;
}
We have two main components introduced. First, distortion property that is calculated via "cnoise" method. We apply this distortion to each point in our vertex, moving all x, y and z coordinates by certain amount. Noise strength defines how far our nodes will be moved from theirs origins. Additionally, we added "normal" vector. The trick is that we don't want our nodes to move the same distance over all directions. They should change over y axe a lot and fluctuate just a bit over x and y.

Try it out!

X normal
Y normal
Z normal
Noise dencity
Noise strength
Num vertices

Here comes the animation

Until now our mesh were completely frozen. We want to make our gradient moooove beautifully. Our cnoise method produces distortion value based on single import number. We can add time value to this pure cnoise function input to change results it produces over time. Every draw call of MTKView we just incrementing delta time based on frames per second of view to have same animation across devices.
Shader.metal
struct SceneConstants {
    ...
    float t;
};

vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
     constant VertexIn *vertices [[buffer(0)]],
     constant ModelConstants &modelConstants [[buffer(1)]],
     constant SceneConstants &sceneConstants [[buffer(2)]]) {

    ...
    float distortion = 0.75 * cnoise(0.43 * position * noiseDencity + sceneConstants.t);
    ...

    return out;
}
Renderer.swift
var sceneConstants: SceneConstants = ...

public func draw(in view: MTKView) {
    guard let drawable = view.currentDrawable else { return }

    ...
    let deltaTime = 1 / Float(view.preferredFramesPerSecond)
    sceneConstants.t += deltaTime

    commandEncoder.setVertexBytes(&sceneConstants,
                                  length: MemoryLayout<SceneConstants>.stride,
                                  index: 2)

    ...

    commandEncoder.endEncoding()

    commandBuffer.present(drawable)
    commandBuffer.commit()
}

Something is missed

We have a plane now that looks good. But that is just the first step. There is one important thing absent. It is… gradient 🤨. It is time to add colours, to X, Y and Z coordinate!
Let's add simple 2D gradient first on out 3d object. Previously we had float4(1, 1, 1, 1) that represents white colour. Now let's change it a bit. During creation of vertices let's determine either it top node or bottom node. If y is zero, then we creating top node. Otherwise we adding node to the bottom.
Plane.swift
static func complexPlane(topColor: SIMD4<Float>, bottomColor: SIMD4<Float>) -> [VertexIn] {
    var result: [VertexIn] = []
    let size = SIMD2<Float>(10, 2)
    for x in stride(from: 0, to: size.x, by: 1) {
        for y in stride(from: 0, to: size.y, by: 1) {
            let position = SIMD3(.init(x / size.x, y).metal, 0)
            result.append(
                .init(position: .init(position, 1),
                      color: y == 0 ? topColor : bottomColor,
                      index: .init(x, y))
            )
        }
    }
    return result
}

private extension SIMD2<Float> {
    // Converts from origin at topLeft to origin at center of the screen
    var toMetalGeometry: SIMD2<Float> {
        .init(x * 2 - 1, (1 - y) * 2 - 1)
    }
}

Using the power of third dimension

Finally, 3D comes to play too. We can use z coordinate of node to mix third color into. Imagine wiggling plane. Let's define that "if the closer node.z to bottomBorder, the more 'bottomColor' we should mix into it".
The algorithm is the default linear blend of two colors. If node is at 0 or higher, we use default left or right color. If node is equal 'bottomBorder' or below, we use 'bottomColor' for node. If it is between 0 and 'bottomBorder', we blend colors.
Shader.metal
struct SceneConstants {
    ...
    float3 bottomColor;
    float bottomColorBorder;
  };

  vertex VertexOut vertex_main(uint vertexID [[vertex_id]],
                               constant VertexIn *vertices [[buffer(0)]],
                               constant ModelConstants &modelConstants [[buffer(1)]],
                               constant SceneConstants &sceneConstants [[buffer(2)]]) {

    ...
    out.color = in.color;

    if (distorted_pos.z < 0) {
      float4 diff = sceneConstants.bottomColor - in.color;
      float d = saturate(distorted_pos.z / sceneConstants.bottomColorBorder);
      float4 b = diff * d;
      out.color = in.color + b;
    }
    ...

    return out;
  }
Our mesh is done! You can go back to the beginning of the post to play with it 😉

What's next?

Our gradient looks fine now but far away from production usage. First, it still looks unnatural. And we know how to make things look more real, right? Of course it is noise! We will discover noise options in our next articles. We also need to pick good camera position to "simulate" 2d plane for user. The thing that we want to build is gradient, not plane hanging in the void, right? See you soon and thanks for reading!

Credits

  • Shader code
  • ShaderGradient that inspired me
  • 2etime youtube channel that explains 3d concepts very well
  • Article from movingparts that originally welcomed me to gradient meshes in general