Motion and Animations in the SDK 5.0: Part 2
Learn how to bring your scenes to life by moving and animating your models with the new SDK
Welcome back! In part 1 of this two-part series, we created a scene where a character named Gnark patrols his ancient temple, walking in circles along a fixed path. Today, we’re going to make this scene more interesting in two ways. First, we’ll get him to turn the corners more smoothly by playing a turning animation. Second, we’ll make him react when a user stands just a little too close for comfort.
If you followed along in part 1, you can pick up right where you left off. You can also download the assets used in this sceene here. Otherwise, you can see the code for the final version of the scene in the repo and read along without having to run any of the code yourself.
Note: The code in this tutorial has been migrated to version 5.1 of the SDK.
Add extra animations
As we mentioned in part 1, our gnark.gltf
file comes embedded with a couple of animations. In part 1 we only used the walk
animation, but in this installment we’re also going to take advantage of the animations that are saved in the model as turnRight
and raiseDead
. Before we can do that, we need to create AnimationState objects to handle each.
const turnRClip = new AnimationState('turnRight')
turnRClip.looping = false
gnarkAnimator.addClip(turnRClip)
const raiseDeadClip = new AnimationState('raiseDead')
gnarkAnimator.addClip(raiseDeadClip)
When creating an animationState
object, we can configure the object’s properties, which include looping
, speed
, and weight
.
All clip objects loop their animations by default, but we’re configuring the clip object for the turnRight
animation so that it only plays the animation once when activated.
Create a Timer Component
We want Gnark to stop moving forward for as long as the turnRight
animation is playing. We’ll create a new component to keep track of the passage of time while this happens.
To make this component more generic and easily reusable on other projects, we won’t hardcode a fixed amount of time. Instead, each time that the component is created, we’ll be able to set the value of the timeLeft
field. To do this, we give the component a constructor()
function that accepts a numerical argument.
@Component("timeOut")
export class TimeOut {
timeLeft: number
constructor( time: number){
this.timeLeft = time
}
}
We’ll use this component a little differently from how we’ve used components before. Our gnark
entity won’t start the scene with an instance of this component that stays there all along. Instead, we’ll only create a new instance when it’s needed (each time that Gnark turns a corner) and we’ll remove the component when we’re done with it.
We’ll also create a component group that lists every entity with a TimeOut
component. The gnark
entity will be jumping in and out of this group as instances of the TimeOut
component are added and removed.
export const paused = engine.getComponentGroup(TimeOut)
Create a Timer System
We’ll now add a specialized system to our scene to handle the TimeOut
component.
export class WaitSystem {
update(dt: number) {
for (let ent of paused.entities){
let time = ent.getComponentOrNull(TimeOut)
if (time){
if (time.timeLeft > 0) {
time.timeLeft -= dt
} else {
ent.removeComponent(TimeOut)
}
}
}
}
}
engine.addSystem(new WaitSystem())
Our system is simple and well-contained; it just subtracts the time that has gone by since the last frame (dt) from the TimeOut
component. If the time runs out, it removes the component.
The system uses the component group we created to iterate over every entity that has a TimeOut
component. We don’t really need to iterate over a list like this, since we know that the gnark
entity is the only one that’s relevant to this system in our scene. Nevertheless, it’s nice to write code that can later be easily reused in other projects.
About handling multiple systems
We could simply add the code of our timer to the GnarkWalk
system that our scene already had, and that would be ok. However, as projects grow in size it’s often useful to keep independent behaviors in separate systems that are clearly delineated. If you design your scene well, you can make complex behaviors emerge from simple decoupled systems that act upon the same entities independently.
An example use case for this kind of separate behavior would be building an AI system that decides where an entity will walk to next, and another gravity
system that pulls all entities down. If the AI system makes an entity walk off a cliff, the gravity system will do its thing without the AI system even noticing. Likewise, the gravity system doesn’t need to know the direction in which the character was moving or where it was going before it fell, it just pulls things down.
Modify the walk system
We now need to modify the GnarkWalk
system so that it incorporates the rotation animation and the TimeOut
component we just created. We took the code from part 1 of this tutorial and made a few changes so that the system now looks like this:
export class GnarkWalk {
update(dt: number) {
if (!gnark.hasComponent(TimeOut)){
let transform = gnark.getComponent(Transform)
let path = gnark.getComponent(LerpData)
walkClip.playing = true
if (path.fraction < 1) {
path.fraction += dt/12
transform.position = Vector3.Lerp(
path.array[path.origin],
path.array[path.target],
path.fraction
)
} else {
path.origin = path.target
path.target += 1
if (path.target >= path.array.length) {
path.target = 0
}
path.fraction = 0
transform.lookAt(path.array[path.target])
walkClip.pause()
turnRClip.play()
gnark.addComponent(new TimeOut(TURN_TIME))
}
}
}
}
You’ll notice that most of this code is no different from what we wrote in part 1. Refer to part 1 if you need to refresh your memory, but here’s what we just changed:
- The entire
update()
function is enclosed in a condition that checks if thegnark
entity has aTimeOut
component added to it. If the component exists, the system does nothing. - We set
walk.playing
totrue
. This is used right after aTimeOut
component expires and Gnark needs to start walking again. It will have no effect on the frames where the animation was already playing. - When Gnark’s done lerping over a segment of the path, we pause the
walk
animation and play theturnRight
animation. Remember that theturnRight
animation is non-looping, so it’s only played once. We also add aTimeOut
component to thegnark
entity. When this component is added, Gnark stops moving until theWaitSystem
removes it.
Note: We’re keeping the line that turns Gnark to face his next target position using LookAt. This is executed in the same frame where we start to play the
turnRight
animation. This is because theturnRight
animation happens to start with the model looking away and it then turns to face the front. Other models are likely to have animations that start facing forward and then turn to look away, in those cases you would need to change the rotation of the model at the end of the animation instead.
Try out the scene preview now! You should see Gnark performing the turnRight
animation on every corner of his path!
Get user position
If we want to get the distance between Gnark and the user, we first need to know where the user is. For that, we’ll instance a camera object.
const camera = Camera.instance
Once this object is created, we can get the user’s current position simply by calling camera.position
.
Calculate distances
As we all learned in school (while wondering if this knowledge would ever be useful), you get the distance between two points via a simple trigonometric formula: √a² + b²
This trigonometric formula is meant for 2D spaces, but it works well for our scene because we can assume that both points are at ground level.
We can save some processing power by not calculating the square root. This optimization is a fairly standard practice in game development. Finding a square root is a demanding computation and we can easily spare it. For example, instead of checking that the result of the whole formula is less than 4, we can simply check that the result of a² + b²
is less than 16.
function distance(pos1: Vector3, pos2: Vector3): number {
const a = pos1.x - pos2.x
const b = pos1.z - pos2.z
return a * a + b * b
}
Reacting to proximity
Let’s add yet another specialized system to our scene so that Gnark is able to respond when the user gets too close to him.
export class BattleCry {
update() {
let transform = gnark.getComponent(Transform)
let path = gnark.getComponent(LerpData)
let dist = distance(transform.position, camera.position)
if ( dist < 16) {
raiseDeadClip.playing = true
walkClip.playing = false
turnRClip.playing = false
transform.lookAt(camera.position)
}
else if (raiseDeadClip.playing){
raiseDeadClip.pause()
transform.lookAt(path.array[path.target])
}
}
}
engine.addSystem(new BattleCry())
In this system, we’re using the camera
object to get the user’s position, then we’re using the distance()
function we created to find the distance between Gnark and the user.
If this distance is less than 4 meters (remember our distance formula doesn’t calculate the square root, so we use 16 that equals to 4 squared), Gnark plays the raiseDead
animation. As he does this, he also pauses all other animations, and turns to face the user.
If the distance becomes greater than 4 meters and he’s still playing the raiseDead
animation, he stops the animation and turns back to the next target in his path.
There’s one last thing we need to do. We need to go back to the GnarkWalk
system and add another condition at the start of the update()
function. We need to make sure that the raiseDead
animation isn’t currently playing before moving Gnark forward. By adding this, he’ll remain still while the animation is playing, and resume walking when it stops.
if (!gnark.hasComponent(TimeOut) && !raiseDeadClip.playing ){
(...)
Final thoughts
We’re done! Let’s run the preview one more time. This time, be brave and walk right up to Gnark to see how he reacts to your proximity!
If you were just reading along and still want to jump in to experience the final scene, you can find it here. To see other examples that make use of the new SDK, check out the Example Scenes page in the documentation!