LiquidFun Tutorial with Metal and Swift – Part 2

In this LiquidFun tutorial, you’ll learn how to simulate water on iOS using LiquidFun, and render it on screen with Metal and Swift. By Allen Tan.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

The NDC Projection Matrix

How you perceive and see objects on the screen depends on the type of projection transformation used to convert points to the normalized device coordinate system that Metal expects.

There are two general types of projection: perspective and orthographic.

Perspective projection gives you a realistic view of your objects because it scales them relative to their distance from a point of view. With this type of projection, the farther an object is from the viewer, the smaller it will appear.

As an example, take this perspective projection of four cubes and a square:

perspective

In the above graphic, you can tell that the two cubes at the top are farther away than the two cubes at the bottom, because they appear smaller. If these objects were rendered with an orthographic projection, you would get the following result:

orthographic

The four cubes look identical to the flat square. This is because orthographic projection discards depth of view, defined by the z-component of the coordinate system, and gives you a flat view of the world.

An object in orthographic projection only moves along the x- and y-axes, and you only see it according to its true size. This is perfect for 2D, so the next step is to create an orthographic projection matrix for your device.

Open ViewController.swift and add this method:

func makeOrthographicMatrix(#left: Float, right: Float, bottom: Float, top: Float, near: Float, far: Float) -> [Float] {
  let ral = right + left
  let rsl = right - left
  let tab = top + bottom
  let tsb = top - bottom
  let fan = far + near
  let fsn = far - near
  
  return [2.0 / rsl, 0.0, 0.0, 0.0,
    0.0, 2.0 / tsb, 0.0, 0.0,
    0.0, 0.0, -2.0 / fsn, 0.0,
    -ral / rsl, -tab / tsb, -fan / fsn, 1.0]
}

This function is OpenGL’s way of creating an orthographic projection matrix. I simply copied this function out of the OpenGL library and converted it to Swift.

You don’t need to understand the math for this tutorial, just how to use it. This function takes as parameters the screen’s bounds in points and returns the corresponding orthographic projection matrix.

You plug in the following parameters:

  • left and right: The left- and right-most x-coordinates of the screen. As you saw in the earlier diagram, these values will be 0 for left and the screen’s width in points for right.
  • bottom and top: The bottom-most and top-most y-coordinates of the screen. Here bottom will be 0 and top will be the screen height in points.
  • near and far: The nearest and farthest z-coordinates. These values affect which z-coordinates are visible on the screen. In OpenGL, this means any z-coordinate between near and far is visible, but it’s a slightly different case with Metal. OpenGL’s normalized z-coordinate system ranges from -1 to 1, while Metal’s is only 0 to 1. Therefore, for Metal, you can compute the range of visible z-coordinates using this formula: -(far+near)/2 to -far. You’ll pass in near and far values of -1 to 1 in order to create the 0-to-1 range of visible z-coordinates that Metal expects.

Using these parameters, you generate a one-dimensional array that Metal will later use as a 4×4 matrix. That is, Metal will treat the array as values arranged in four rows of four columns each.

It’s important to note that when creating matrices, Metal populates each row of a column before moving on to the next column. The array you create in makeOrthographicMatrix is arranged in the following way in matrix form:

ortho_matrix_mapping

Starting from the top-most row of the left-most column, Metal populates each row of each column first before moving on to the next column.

Note: The math behind the deriving this matrix is beyond the scope of this tutorial. If you wish to learn more, this site on OpenGL Projection Matrices explains it thoroughly.

Create a Uniform Buffer

You’re all set to create the uniform buffer that the vertex shader needs. It’s the third and last component that controls the fate of your particles onscreen.

Still in ViewController.swift, add the following property and new method:

var uniformBuffer: MTLBuffer! = nil

func refreshUniformBuffer () {
  // 1
  let screenSize: CGSize = UIScreen.mainScreen().bounds.size
  let screenWidth = Float(screenSize.width)
  let screenHeight = Float(screenSize.height)
  let ndcMatrix = makeOrthographicMatrix(left: 0, right: screenWidth,
    bottom: 0, top: screenHeight,
    near: -1, far: 1)
  var radius = particleRadius
  var ratio = ptmRatio
  
  // 2
  let floatSize = sizeof(Float)
  let float4x4ByteAlignment = floatSize * 4
  let float4x4Size = floatSize * 16
  let paddingBytesSize = float4x4ByteAlignment - floatSize * 2
  let uniformsStructSize = float4x4Size + floatSize * 2 + paddingBytesSize
  
  // 3
  uniformBuffer = device.newBufferWithLength(uniformsStructSize, options: nil)
  let bufferPointer = uniformBuffer.contents()
  memcpy(bufferPointer, ndcMatrix, float4x4Size)
  memcpy(bufferPointer + float4x4Size, &ratio, floatSize)
  memcpy(bufferPointer + float4x4Size + floatSize, &radius, floatSize)
}

Creating the uniform buffer is tricky, because your Swift code isn’t aware of the Uniforms structure that you created in your Metal shader code. What’s more, Swift doesn’t have a native equivalent for the float4x4 type that Metal uses. So the approach you take is to populate this structure, without knowing what the structure is, by copying the values into memory yourself.

Since float4x4 consists of 16 floats, you may think that the size of Uniforms is equal to the total size of 18 floats, but that isn’t the case. Because of structure alignment, the compiler inserts extra bytes as padding to optimally align data members in memory. The amount of padding inserted depends on the byte alignment of its data members. With Uniforms as an example, you get the following byte alignments and sizes:

uniforms_alignment

With structure alignment, the total size of the structure must be a multiple of the largest byte alignment, which in this case is 16 bytes. An easy way to get the amount of padding needed is to subtract the smaller byte alignments from the largest byte alignment. By doing so, you get:

uniforms_total

With a padding of 8 bytes, you get a total of 80 bytes, and that’s what’s happening in this section of the code:

You get the sizes of each data member and compute for the padding size to get the total size of the struct.

  1. First, you create the orthographic projection matrix, ndcMatrix, by supplying the screen’s dimensions to makeOrthographicMatrix. You also copy the constants particleRadius and ptmRatio as local variables for use later.
  2. Next, you compute for the size of the Uniforms struct in memory. As a refresher, take a second look at the structure’s definition:
    struct Uniforms {
      float4x4 ndcMatrix;
      float ptmRatio;
      float pointSize;
    };
    
    let floatSize = sizeof(Float)
    let float4x4ByteAlignment = floatSize * 4
    let float4x4Size = floatSize * 16
    let paddingBytesSize = float4x4byteAlignment - floatSize * 2
    let uniformsStructSize = float4x4Size + floatSize * 2 + paddingBytesSize
    
  3. Finally, you create an appropriately sized empty buffer named uniformBuffer, and copy the contents of each data member one by one using memcpy.
struct Uniforms {
  float4x4 ndcMatrix;
  float ptmRatio;
  float pointSize;
};
let floatSize = sizeof(Float)
let float4x4ByteAlignment = floatSize * 4
let float4x4Size = floatSize * 16
let paddingBytesSize = float4x4byteAlignment - floatSize * 2
let uniformsStructSize = float4x4Size + floatSize * 2 + paddingBytesSize

motherofmath

Note: Have a look at the official Metal Shading Language documentation to learn more about the alignment and sizes of each data type.

Before moving on, add a call to refreshVertexBuffer at the end of viewDidLoad:

refreshUniformBuffer()

With the vertex shader in place, Metal will know where your particles are, but you still need a fragment shader to draw them.

Allen Tan

Contributors

Allen Tan

Author

Over 300 content creators. Join our team.