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
Create a Fragment Shader
While the vertex shader determines the position of each vertex, the fragment shader determines the color of each visible fragment on the screen. You don’t need any fancy colors yet for this tutorial, so you’ll use a very simple fragment shader.
Add the following code to the bottom of Shaders.metal:
fragment half4 basic_fragment() {
return half4(1.0);
}
You create a fragment shader that simply returns the color white using RGBA values of (1, 1, 1, 1). Expect to see white particles soon—but it will behave like water, not snow!
Build a Render Pipeline
You’re almost there! The rest of the steps should be familiar to you from the Metal Tutorial for Beginners.
Open ViewController.swift and add the following properties and new method:
var pipelineState: MTLRenderPipelineState! = nil
var commandQueue: MTLCommandQueue! = nil
func buildRenderPipeline() {
// 1
let defaultLibrary = device.newDefaultLibrary()
let fragmentProgram = defaultLibrary?.newFunctionWithName("basic_fragment")
let vertexProgram = defaultLibrary?.newFunctionWithName("particle_vertex")
// 2
let pipelineDescriptor = MTLRenderPipelineDescriptor()
pipelineDescriptor.vertexFunction = vertexProgram
pipelineDescriptor.fragmentFunction = fragmentProgram
pipelineDescriptor.colorAttachments[0].pixelFormat = .BGRA8Unorm
var pipelineError : NSError?
pipelineState = device.newRenderPipelineStateWithDescriptor(pipelineDescriptor, error: &pipelineError)
if (pipelineState == nil) {
println("Error occurred when creating render pipeline state: \(pipelineError)");
}
// 3
commandQueue = device.newCommandQueue()
}
And just like you did with the other setup methods, add a call to this new method at the end of viewDidLoad
:
buildRenderPipeline()
Inside buildRenderPipeline
, you do the following:
- You use the
MTLDevice
object you created earlier to access your shader programs. Notice you access them using their names as strings. - You initialize a
MTLRenderPipelineDescriptor
with your shaders and a pixel format. Then you use that descriptor to initializepipelineState
. - Finally, you create an
MTLCommandQueue
for use later. The command queue is the channel you’ll use to submit work to the GPU.
Render the Particles
The final step is to draw your particles onscreen.
Still in ViewController.swift, add the following method:
func render() {
var drawable = metalLayer.nextDrawable()
let renderPassDescriptor = MTLRenderPassDescriptor()
renderPassDescriptor.colorAttachments[0].texture = drawable.texture
renderPassDescriptor.colorAttachments[0].loadAction = .Clear
renderPassDescriptor.colorAttachments[0].storeAction = .Store
renderPassDescriptor.colorAttachments[0].clearColor =
MTLClearColor(red: 0.0, green: 104.0/255.0, blue: 5.0/255.0, alpha: 1.0)
let commandBuffer = commandQueue.commandBuffer()
if let renderEncoder = commandBuffer.renderCommandEncoderWithDescriptor(renderPassDescriptor) {
renderEncoder.setRenderPipelineState(pipelineState)
renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, atIndex: 0)
renderEncoder.setVertexBuffer(uniformBuffer, offset: 0, atIndex: 1)
renderEncoder.drawPrimitives(.Point, vertexStart: 0, vertexCount: particleCount, instanceCount: 1)
renderEncoder.endEncoding()
}
commandBuffer.presentDrawable(drawable)
commandBuffer.commit()
}
Here, you create a render pass descriptor to clear the screen and give it a fresh tint of green. Next, you create a render command encoder that tells the GPU to draw a set of points, set up using the pipeline state and vertex and uniform buffers you created previously. Finally, you use a command buffer to commit the transaction to send the task to the GPU.
Now call render
at the end of viewDidLoad
:
render()
Build and run the app on your device to see your particles onscreen for the first time:
Moving Water
Now is when most of your work from this tutorial and the last will pay off—getting to see the liquid simulation in action. Currently, you have nine water particles onscreen, but they’re not moving. To get them to move, you need to trigger the following events repeatedly:
- LiquidFun needs to update the physics simulation.
- Metal needs to update the screen.
Open LiquidFun.h and add this method declaration:
+ (void)worldStep:(CFTimeInterval)timeStep velocityIterations:(int)velocityIterations
positionIterations:(int)positionIterations;
Switch to LiquidFun.mm and add this method definition:
+ (void)worldStep:(CFTimeInterval)timeStep velocityIterations:(int)velocityIterations
positionIterations:(int)positionIterations {
world->Step(timeStep, velocityIterations, positionIterations);
}
You’re adding another Objective-C pass-through method for your wrapper class, this time for the world object’s Step
method. This method advances the physics simulation forward by a measure of time called the timeStep
.
velocityIterations
and positionIterations
affect the accuracy and performance of the simulation. Higher values mean greater accuracy, but at a greater performance cost.
Open ViewController.swift and add the following new method:
func update(displayLink:CADisplayLink) {
autoreleasepool {
LiquidFun.worldStep(displayLink.duration, velocityIterations: 8, positionIterations: 3)
self.refreshVertexBuffer()
self.render()
}
}
Next, add the following code at the end of viewDidLoad
:
let displayLink = CADisplayLink(target: self, selector: Selector("update:"))
displayLink.frameInterval = 1
displayLink.addToRunLoop(NSRunLoop.currentRunLoop(), forMode: NSDefaultRunLoopMode)
You’re creating a CADisplayLink
that calls your new update
method every time the screen refreshes. Then in update
, you do the following:
- You ask LiquidFun to step through the physics simulation using the time interval between the last execution of
update
and the current execution, as represented bydisplayLink.duration
. - You tell the physics simulation to do eight iterations of velocity and three iterations of position. You are free to change these values to how accurate you want the simulation of your particles to be at every time step.
- After LiquidFun steps through the physics simulation, you expect all your particles to have a different position than before. You call
refreshVertexBuffer()
to repopulate the vertex buffer with the new positions. - You send this updated buffer to the render command encoder to show the new positions onscreen.
Build and run, and watch your particles fall off the bottom of the screen:
That’s not quite the effect you’re looking for. You can prevent the particles from falling off by adding walls to your physics world, and to keep things interesting, you’ll also move the particles using the device accelerometer.
Open LiquidFun.h and add these method declarations:
+ (void *)createEdgeBoxWithOrigin:(Vector2D)origin size:(Size2D)size;
+ (void)setGravity:(Vector2D)gravity;
Switch to LiquidFun.mm and add these methods:
+ (void *)createEdgeBoxWithOrigin:(Vector2D)origin size:(Size2D)size {
// create the body
b2BodyDef bodyDef;
bodyDef.position.Set(origin.x, origin.y);
b2Body *body = world->CreateBody(&bodyDef);
// create the edges of the box
b2EdgeShape shape;
// bottom
shape.Set(b2Vec2(0, 0), b2Vec2(size.width, 0));
body->CreateFixture(&shape, 0);
// top
shape.Set(b2Vec2(0, size.height), b2Vec2(size.width, size.height));
body->CreateFixture(&shape, 0);
// left
shape.Set(b2Vec2(0, size.height), b2Vec2(0, 0));
body->CreateFixture(&shape, 0);
// right
shape.Set(b2Vec2(size.width, size.height), b2Vec2(size.width, 0));
body->CreateFixture(&shape, 0);
return body;
}
+ (void)setGravity:(Vector2D)gravity {
world->SetGravity(b2Vec2(gravity.x, gravity.y));
}
createEdgeBoxWithOrigin
creates a bounding box shape, given an origin (located at the lower-left corner) and size. It creates a b2EdgeShape
, defines the four corners of the shape’s rectangle and attaches it to a new b2Body
.
setGravity
is another pass-through method for the world object’s SetGravity
method. You use it to change the current world’s horizontal and vertical gravities.
Switch to ViewController.swift and add the following import:
import CoreMotion
You’re importing the CoreMotion framework because you need it to work with the accelerometer. Now add the following property:
let motionManager: CMMotionManager = CMMotionManager()
Here you create a CMMotionManager
to report on the accelerometer’s state.
Now, create the world boundary by adding the following line inside viewDidLoad
, before the call to createMetalLayer
:
LiquidFun.createEdgeBoxWithOrigin(Vector2D(x: 0, y: 0),
size: Size2D(width: screenWidth / ptmRatio, height: screenHeight / ptmRatio))
This should prevent particles from falling off the screen.
Finally, add the following code at the end of viewDidLoad
:
motionManager.startAccelerometerUpdatesToQueue(NSOperationQueue(),
withHandler: { (accelerometerData, error) -> Void in
let acceleration = accelerometerData.acceleration
let gravityX = self.gravity * Float(acceleration.x)
let gravityY = self.gravity * Float(acceleration.y)
LiquidFun.setGravity(Vector2D(x: gravityX, y: gravityY))
})
Here, you create a closure that receives updates from CMMotionManager
whenever there are changes to the accelerometer. The accelerometer contains 3D data on the current device’s orientation. Since you’re only concerned with 2D space, you set the world’s gravity to the x- and y-values of the accelerometer.
Build and run, and tilt your device to move the particles:
The particles will slide around, and with a bit of imagination you can see them as water droplets!