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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
LiquidFun Tutorial with Metal and Swift – Part 2
35 mins
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:
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:
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
andright
: The left- and right-most x-coordinates of the screen. As you saw in the earlier diagram, these values will be 0 forleft
and the screen’s width in points forright
. -
bottom
andtop
: The bottom-most and top-most y-coordinates of the screen. Herebottom
will be 0 andtop
will be the screen height in points. -
near
andfar
: 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 innear
andfar
values of-1
to1
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:
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.
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 float
s, you may think that the size of Uniforms
is equal to the total size of 18 float
s, 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:
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:
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.
- First, you create the orthographic projection matrix,
ndcMatrix
, by supplying the screen’s dimensions tomakeOrthographicMatrix
. You also copy the constantsparticleRadius
andptmRatio
as local variables for use later. - 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; };
Since
float4x4
consists of 16float
s, you may think that the size ofUniforms
is equal to the total size of 18float
s, 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. WithUniforms
as an example, you get the following byte alignments and sizes: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:
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:
let floatSize = sizeof(Float) let float4x4ByteAlignment = floatSize * 4 let float4x4Size = floatSize * 16 let paddingBytesSize = float4x4byteAlignment - floatSize * 2 let uniformsStructSize = float4x4Size + floatSize * 2 + paddingBytesSize
You get the sizes of each data member and compute for the padding size to get the total size of the struct.
- Finally, you create an appropriately sized empty buffer named
uniformBuffer
, and copy the contents of each data member one by one usingmemcpy
.
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
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.