Lesson 9: Movement
In the last few lessons, we've seen how we could use the view matrix to position a camera within our virtual world, and then implement some trivial movement and use the mouse to rotate the camera so we can look around.
If you tried our the code from the last lesson though, you may have noticed that if you try to move around, the direction of movement is fixed and still aligned to the world axes. Moving "forwards" will always move you towards the positive X axis, the direction you were looking when the program started, but not necessarily the direction you're looking now.
Moreover, our camera simply takes a single "step" each time a key is pressed, but it's much nicer if we can hold down a key and have our camera move continuously until we release the key. So we'll look at implementing some fluid and consistent movement too.
Camera Movement
Currently, pressing the "W" key or the up arrow will reposition our camera one unit further into the world's X axis. To make it so we always move towards the direction the camera is facing, we once again need to pull in our knowledge of trigonometry.
To derive the formulae, let's again take a series of inputs for our camera, imagine what the outputs should be, and then figure out what the underlying function is. To start off with, we'll focus on forwards movement.
First of all, we can conclude that this relationship completely ignores the camera's pitch angle. If we assume we are walking on flat ground, having the camera slightly pitched upwards or down doesn't affect what moving "forwards" does. Therefore, we know that the pitch angle won't have any effect on what we calculate here, only the camera's yaw.
Examining therefore what happens if we move forward for various yaw inputs, we can observe the following:
- At 0° yaw: looking into the X axis. A step forwards should give us X + 1, Y + 0.
- At 90° yaw: looking into the Y axis. A step forwards should give us X + 0, Y + 1.
- At 180° yaw: looking into the -X axis. A step forwards should give us X - 1, Y + 0.
- At 270° yaw: looking into the -Y axis. A step forwards should give us X + 0, Y - 1.
We know we're dealing with circles and angles, so we're likely dealing with trigonometric functions. Looking at how the X axis changes for a given input angle, we can observe it follows a cosine relationship. Likewise, the Y axis follows a sine relationship with the yaw.
So to move 1 unit forwards, we can add cosine(yaw) to the X position, and sine(yaw) to the Y position. Both of these results can of course be scaled to take bigger or smaller steps, but this is essentially how we can change our code to make our movement relative to the direction the camera is facing.
Now that we understand how to go forwards, moving backwards is really easy: we simply do the reverse of what we did to move forwards! So instead of adding the above we can just subtract it to take a step back.
Next, let's consider the left/right movement. Just like for forwards and backwards, they will again just be the inverse of each other, so we only really need to figure out one. I'm going to start by assuming the left key has been pressed:
- At 0° yaw: looking into the X axis. A step left should give us X + 0, Y + 1.
- At 90° yaw: looking into the Y axis. A step left should give us X - 1, Y + 0.
- At 180° yaw: looking into the -X axis. A step left should give us X + 0, Y - 1.
- At 270° yaw: looking into the -Y axis. A step left should give us X + 1, Y + 0.
We can see that the change in the X axis follows a negative sine wave of the yaw, and the Y axis a cosine wave.
Therefore a left movement can be thought of as subtracting a sine wave of the yaw in the X axis, and adding a cosine of the yaw in the Y axis. Again, to move to the right, we can simply do the opposite of this, adding a sine wave to the X, and subtracting a cosine wave in the Y axis.
For anyone reading this and struggling a bit, I want to just stress this - even at the highest levels, these formulae don't just appear in people's minds. It is really helpful to grab a sheet of paper and draw grids and diagrams and write out example scenarios. Everyone who programs graphics falls back to a pen and paper when they need to think things through!
Converting to code
Now that we have some idea of how a step in each direction needs to change our camera's X and Y position in order to make the movement relative to the direction the camera is facing, let's see what it looks like as code.
Previously, in our handleEvents
function we had something like this, simply moving us into the world's X axis when forwards is pressed:
239. |
|
240. |
|
241. |
|
242. |
|
So peeking back up at our formulae, we can see that instead we need to change our code so that we add the cosine of our camera's yaw angle to the X position, and the sine of the yaw to the Y position. This gives us the following:
239. |
|
240. |
|
241. |
|
242. |
|
243. |
|
Again, it's necessary to make sure our angles are converted to radians before using the built-in trigonometry functions, but otherwise this is all we need to do.
We can then do the same for the rest of the movement directions:
239. |
|
240. |
|
241. |
|
242. |
|
243. |
|
244. |
|
245. |
|
246. |
|
247. |
|
248. |
|
249. |
|
250. |
|
251. |
|
252. |
|
253. |
|
254. |
|
255. |
|
256. |
|
257. |
|
258. |
|
If you run this code, you should find that your arrow keys now move around relative to the direction the camera is facing, not the fixed axes of the world!
Time based Movement
While we now have movement in our world, one more thing we need to consider is how we move.
The way our code is currently implemented, if we press the up arrow key we will move one step forwards.
However, on some machines this will move forwards just once. On other machines though, this will - after a second - trigger many movements forwards. On most operating systems, holding down a key will trigger that key's keypress event many times (picture holding down an arrow key in a text editor. If the key is held we expect to keep scrolling through the text indefinitely).
What's worse, on different machines the rate at which these repeat events are fired varies as does the delay between holding the key down and the repeats starting.
So as we're on the topic of movement, let's take a quick look at fluid movement when a user holds down a key. Let's aim to continually move forward at a constant rate across all machines as soon as a key is pressed, and stop the moment it's released.
The most straight-forward way to achieve this would be just to set up some boolean variable along the lines of isUpArrowCurrentlyPressed
.
When the key is pressed, we set this to true, and when it is released we set it to false.
Then, every time our main function loops and we call the update
function, we can test the state of this variable, and if it is set to true, move forwards.
This is simple and means that we would instantly start moving forwards as soon as the key is pressed, and stay moving forwards for as long as it's held down, but it has a small problem.
The speed of the movement depends on how fast our code's main loop is running. If my machine is looping 100 times a second, I will move forward 100 units every second the key is pressed. But if your machine is looping 1000 a second, you will move forwards 1000 units in the same time period.
The result would be your machine flying through our world uncontrollably fast, while mine might be frustratingly slow. A ten times difference in performance between users with fast machines and slow machines is actually on the small side, we can easily expect to see much more on real user machines. So it's important we take a moment to look at how we can make this movement consistent regardless of the hardware it's being run on.
The simple way to fix this is to instead make our movement time-based. That is to say, we instead define our camera's movement speed in terms of units per second (frankly, like you would probably expect a speed to be defined).
Then, on each frame, we instead test how much time has passed since the previous frame, and adjust the amount we move by this. Let's think through some examples. Suppose we want to move at 1 unit per second.
- If 1 second has passed since the last frame, we should move 1 unit forward
- If 2 seconds have passed since the last frame, we should move 2 units forward
- If 0.5 seconds have passed since the last frame, we should move 0.5 units forward
So hopefully it's clear that we can just take the desired speed (in units per second), and multiply it by the number of seconds passed since the previous frame, to get a hardware-consistent way to adjust our camera's position.
Let's implement!
The first thing we therefore need to do is define our camera's speed in world units per second:
13. | int windowWidth = 1024; |
14. | int windowHeight = 600; |
15. | |
16. | float mouseSensitivity = 0.3; |
+ 17. |
|
18. | |
19. | SDL_Window* window = NULL; |
20. | SDL_GLContext context = NULL; |
So this will control how fast our camera can move around.
Then, we need to keep track of how much time has passed since our update
function was last called.
We can do this by storing the system timestamp each frame, and then at the next frame compare what we had to the current timestamp and see what the difference is.
We'll therefore create a variable to hold the timestamp of the previous
frame:
22. | bool programRunning = true; |
23. | bool isFullscreen = false; |
24. | bool useWireframe = false; |
+ 25. |
|
26. | |
27. | float x = 0; |
28. | float y = 0; |
29. | float z = 0; |
SDL will take care of most of the work here, including making this implementation cross-platform friendly. SDL stores these timestamps as unsigned 64-bit integers, hence the data-type.
We've initialised this variable to zero here, which isn't ideal.
If our program ends up taking a while to start, our first call to update
will have a huge time delta, causing it to make a big jump.
Therefore as soon as our program has finished initialising, we should update our previous timestamp, so the first update won't be so jarring:
158. | glClearColor(0.04f, 0.23f, 0.51f, 1.0f); |
159. | |
160. | glEnable(GL_DEPTH_TEST); |
161. | |
+ 162. |
|
163. | |
164. | return true; |
165. | } |
So at the end of the init
function, we set our timestamp value with a call to SDL_GetTicks()
.
This function returns the number of milliseconds since SDL was initialised.
Note therefore these timestamps are not absolute UNIX timestamps, but this isn't an issue.
We can now update our update
function!
Let's start by calculating how much time has passed since the function was last called:
247. | void update() |
248. | { |
+ 249. |
|
+ 250. |
|
+ 251. |
|
252. | |
253. | ... |
We grab the current timestamp as soon as the function is called.
Subtracting from this the previous timestamp will leave the difference (or delta), giving us the number of milliseconds that has passed since the function was last called.
With the time delta calculated, we update the previousTimestamp
variable so that next time update
is called, it can calculate the offset from this frame.
Now we have the amount of time that has passed, when a key is pressed we can scale the movement by it:
247. | void update() |
248. | { |
249. | Uint64 currentTimestamp = SDL_GetTicks(); |
250. | Uint64 timeDelta = currentTimestamp - previousTimestamp; |
251. | previousTimestamp = SDL_GetTicks(); |
252. | |
+ 253. |
|
254. | |
255. | ... |
We take our movementSpeed
and and multiply it by the time delta as we discussed.
As the timestamps given to us by SDL are in milliseconds though, rather than seconds, we need to do an additional division by 1000 in order to get the correct result.
We can then use this new stable movement speed to re-implement our previous movement code.
Fortunately, instead of having to create lots of variables along the lines of isUpArrowCurrentlyPressed
, SDL actually performs this for us:
253. | float movementDistance = movementSpeed * timeDelta / 1000; |
254. | |
+ 255. |
|
+ 256. |
|
+ 257. |
|
+ 258. |
|
+ 259. |
|
+ 260. |
|
+ 261. |
|
When we make calls to SDL_PollEvent
, SDL actually remembers the state of the keys passing through it.
We can read these states by making a call to SDL_GetKeyboardState
.
The function optionally accepts an integer if you want to get the number of keys currently pressed down, or pass NULL if you're not interested.
The function returns a pointer to an array of booleans, indicating whether a specific key is pressed or not. The array is ordered according to the keyboard scan codes, so we can test the boolean value of the array at a specific index in order to get the state of the key we're interested in.
For reference, a full list of keyboard scancodes is available from the SDL wiki here.
Note that internally, as SDL gets the keystates from data received from our call to SDL_PollEvent
in the update()
function, it's important that we still handle events normally if we want to use this approach.
If a key that we're interested in is pressed, we do as we did before but this time multiply the result by the movementDistance, in effect making the distance moved proportional to time.
We can then do the same for the rest of our movements:
257. | if(keyboardState[SDL_SCANCODE_W] || keyboardState[SDL_SCANCODE_UP]) |
258. | { |
259. | x += cos(yaw * 3.1415 / 180) * movementDistance; |
260. | y += sin(yaw * 3.1415 / 180) * movementDistance; |
261. | } |
+ 262. |
|
+ 263. |
|
+ 264. |
|
+ 265. |
|
+ 266. |
|
+ 267. |
|
+ 268. |
|
+ 269. |
|
+ 270. |
|
+ 271. |
|
+ 272. |
|
+ 273. |
|
+ 274. |
|
+ 275. |
|
+ 276. |
|
Of course for our vertical movements, we can simplify this a little and just move according to the moveDistance variable:
272. | else if(keyboardState[SDL_SCANCODE_D] || keyboardState[SDL_SCANCODE_RIGHT]) |
273. | { |
274. | x += sin(yaw * 3.1415 / 180) * movementDistance; |
275. | y -= cos(yaw * 3.1415 / 180) * movementDistance; |
276. | } |
+ 277. |
|
+ 278. |
|
+ 279. |
|
+ 280. |
|
+ 281. |
|
+ 282. |
|
+ 283. |
|
+ 284. |
|
285. | } |
We now how time-based, fluid, responsive movement across our 3D world!
Don't forget that we need to remove the corresponding old movement code from our handleEvents
function as the work is now handled here in the update
function instead.
The end of our handleEvents
function therefore looks like this:
228. |
|
229. |
|
230. |
|
231. |
|
232. |
|
233. |
|
234. |
|
235. |
|
236. |
|
237. |
|
238. |
|
239. |
|
240. |
|
241. |
|
242. |
|
243. |
|
244. |
|
245. |
|
No more arrow key handling in there anymore!
Great, that's everything!
Conclusion
Now everything should be in place for us to move around our virtual world with a first-person style camera. Compile and run, and you should now be able to look and move around freely, with the movement now being consistent across machines and taking into account your camera's heading!
Next up, back to graphics! In the next lesson we'll look at texture mapping our triangles. See you there!