Third-Person Camera System
In third-person games, the camera affects the player experience deeply at many levels. As example,
Did Lara Croft just got shot by a bullet, the camera provides the feedback to the player.
Is Lara running on low stamina, the camera lures the player to develop empathy.
Especially when a camera does its job well, players do not notice it. It's as if the camera isn't even there! That’s what motivates me to dive into the problem space of third-person cameras.
The goal of my thesis is to architect a camera system which enables smart and aesthetically pleasing camera movement. Here is a quick video highlighting all the features:
( All features showcase in 90 seconds )
In a moderate to fast paced third-person game, the player is usually engaged with the gameplay so much that controlling the camera on top of it can easily feel like a burden and break the immersion. Saying that, should player want to take control over the camera, they always can!
The intention behind auto-control feature was to enable the camera to position itself automatically such that it complements the player movement. I call my solution Modified Cone Raycast. Let's dive into how it works.
As you can see in the left debug image, a handful of raycasts in the shape of a three-dimensional cone are fired to sample the surrounding environment. The right debug image shows these raycasts' target points, as if they are projected on the camera's front/near plane. A red dot indicates an impact with a geometry and the green dots indicates no impact. Based on which part of the circle has more “complaints” about impacting with surrounding geometry, the camera is then smoothly rotated towards the opposite direction. This is indicated by white-magenta arrow.
( Debug View: Modified Cone Raycast )
( Debug circle showcasing raycast target points, projected on camera's front plane )
For simplicity, the camera uses polar coordinates (radius, rotation, altitude), and its anchor is the BB8. One thing to keep in mind is that the Auto-Control changes only the rotation of the camera's polar coordinates.
These videos are here to help you visualize how it works in real time:
( Debug Video: Camera Auto-Control )
( Without Debug: Camera Auto-Control feature showcase )
Camera Auto-Control does a fine job in avoiding collisions by changing just the rotation of camera’s polar coordinates. However, when the player is in control of the camera, a different take on the collision avoidance becomes essential.
To visualize the problem, take a look at the top-down sketch on the right. Let’s say the player wants to rotate the camera counterclockwise from a start position, but there is a building in the way. In this scenario, reducing the radius of the camera’s polar coordinates will be a good way to avoid the building.
I use the same Modified Cone Raycast to determine how much reduction in the radius (of camera's polar coordinate) should happen. Each ray has a weight assigned to it. Each ray votes for the amount of radius reduction that should happen to prevent the collision reported on their end. Later these votes are resolved in a form of a weighted average to calculate the final reduction in the radius.
( Top-down sketch showcasing the radius reduction )
( Weights assigned to each rays of the Modified Cone Raycast )
( Radius Reduction by the Modified Cone Raycast )
Because the total number of rays is very high compared to the rays that actually reports an impact, the weighted average ends up being too small; which is it not effective. To mitigate this problem, the assigned weights reacts based on the velocity of the camera, which you'll see in the following video. For reference while watching the video, keep an eye on the debug circle similar to the right image provided below. Each dot represents the target point of a raycast projected on the camera's front/near plane. The blue-green arrow represents camera's current velocity. White dot means that the weight assigned to the raycast is the low, and the red dot represents a higher weight.
( Debug View - Weighted Raycasts )
( Debug circle showcasing raycast weights )
Since the chance of camera-collision is much higher in the direction where the camera is going (i.e. direction of the camera velocity), the respective part of the Modified Cone Raycast ends up with much higher weights. Thus, they contribute more towards the final reduction in the radius. This approach produced much more effective results.
You might ask, why even bother with too many raycasts if getting the weighted average is tricky? I tried raycasts in a simpler 2D cone formation, which works almost flawlessly except in one case: where the potential collision might happen because of the geometry situated directly above/below the raycasts. As a result, the reduction in radius happens very abruptly with 2D Cone Raycast. You can see the comparison in the following video.
( Debug view of two-dimensional cone raycast )
( Comparison video - 2D vs. 3D cone raycast )
Consider a scenario where the BB8 is standing next to a building, and the player tries to position the camera towards the building. To avoid the collision in such a scenario, the camera will position itself very close to the BB8, and the body of the BB8 ends up covering the majority of the screen. If the camera can switch its behavior from follow camera to shoulder view, then the shoulder view will let the player see what’s ahead much more clearly. The following video shows the comparison.
( Comparison video of Behavior Handover - disabled vs. enabled )
Each Camera Behavior (like Follow, Shoulder View, or Freelook) has a different set of rules it needs to satisfy. These set of rules are called Camera Constraints - you’ll know more about it in the System Architecture section. Now, when the active behavior gets changed to a different one, the active set of rules also needs to be changed. As you can see in the left debug video below, when the Follow behavior is active the Modified Cone Raycast camera constraint is enabled. After the behavior handover to the shoulder view, the cone raycast gets disabled in favor of enabling constraints of the shoulder view. My system also makes sure that the behavior handover doesn’t happen in middle of a frame. Otherwise it can produce side effects.
( Debug video: Behavior Handover )
( Without debug, Behavior Handover feature showcase )
Reorient the Camera
This feature lets the player reorient the camera to the back side of BB8 by press of a button. Sounds easy, right? But there is some subtlety to it.
The problem arises because the movement input (left-stick) is relative to the camera. If the camera is facing north and you tilt the left-stick forward then the BB8 will move in the north direction, and when you tilt the stick towards right then the BB8 starts moving towards east (again, relative to the camera).
Keep that in mind while taking a look into the debug video below. You’ll realize that because of the camera relative controls, even though the left-stick is held at the same place, the BB8 changes its movement direction when the camera reorientation happens. This could be frustrating to the player because they never intended to change the movement direction of the BB8, they only intended to change the camera's orientation.
( Debug video showcasing the problem with Camera Reorientation )
So, our intention here is to retain the movement direction of the BB8 until the player wants to change it. Let’s break it down into two steps, using both we can to provide the desired player experience.
Retain the movement direction
Input Interpolation - To give back the movement-control to the player
Retain the movement direction: Check the following video to get a better understanding of this step.
( Debug video comparing the movement direction )
When the camera reorientation begins, the controller’s left-stick input and the camera’s forward direction are cached. These two directly affects the BB8's movement. Until the player decides to change the movement direction by moving the left-stick, these cached values are used to keep moving in the same direction.
But wait, what? You are saying that you take away the controls from the player?! No, not permanently. That’s where the second step comes in the picture.
Input Interpolation: This step is designed to gently give back the movement-control to the player.
( Debug Circle - Left Stick Input )
( On Camera Reorientation just started )
( When Input Interpolation takes place )
The red dot represented in these visuals is the left-stick's current position. When the camera reorientation begins, it looks like the middle image. At this point, the stick's position alongside the camera's forward are cached, and are being used to keep the BB8 moving in the same direction. The gray dot represents the cached left-stick position. The purple area around the gray dot represents a dead-zone, inside which any left-stick input is ignored.
As soon as the player moves the stick outside the dead-zone, the Input Interpolation takes place. From now on the camera's forward is getting updated according to its current orientation. So on this frame, the gray dot, cached left-stick position, is remapped to a new position such that relative to the current/updated camera forward the BB8 would keep moving in the same direction. You can see the remapped region in the right image. After that frame, the remapped (cached left-stick) position slowly interpolates towards the red dot, i.e. where the player is currently holding the left-stick.
In the provided videos the interpolation of the input happens over 2 seconds, so that you can observe the transition easily. But in my actual implementation, that time is reduced to 0.25 seconds. In other words, within this 0.25 seconds the player gets total control of the BB8, back in their hands.
As we discussed, the input interpolation gives the control back to the player in short time. But there is one scenario where the player gets the control immediately back. It happens when the player releases the left-stick. Player might do that if they get confused. In that case, the red dot would land into the green region, which represents center of the debug circle.
Check out following video to see both of these techniques work in realtime.
( Camera Reorientation - Realtime Debug View )
The purpose of the Look Ahead feature is to complement the player movement. As the name suggests, the camera tries to look ahead according to where the player is headed. You can see in the video below that if the BB8 is moving forward, the camera moves a little more towards the forward direction. If the BB8 is moving in the right direction, the camera tries to show what’s ahead in the right direction.
( Look Ahead feature showcase )
I implemented a Camera Motion Controller which is responsible for moving the camera and handling this characteristic. Look into the System Architecture section to know more about the Camera Motion Controller.
The motion controller knows the player’s current velocity, and it also knows the current position of the camera and its goal position. So, by adding some offset to the goal position according to the player velocity, the look ahead characteristics can be achieved. In following debug video, the white cross-point showcases the added offset relative to the player's position.
( Debug view showcasing Look Ahead offset )
In my initial attempts, I ended up directly adding the player velocity to the camera position, which works well only if the acceleration of BB8 is low. But as I increased the acceleration to higher values for better feeling player movement, the change in offset became too abrupt such that it can easily make the player feel motion sick. Thus, I decided to smoothly interpolate towards the new position as an alternative.
( Comparison video showcasing smoother Look Ahead )
Before I dive into the architecture, let me talk about two fundamental concepts that forms the basis of communication between camera system's other components: Camera Context and Camera State.
As the name suggests, the Camera Context has all the contextual information about the camera which can be essential to the camera system:
Anchor game object: a pointer to the character (here BB8) which the camera is following
Raycast callback: a pointer to the raycast method, implemented on the game side
Camera collision radius: the radius of the camera's collider
Sphere collision callback: pointer to the collision check method, implemented on the game side
Camera state last frame: camera's state on the previous frame
Camera State contains essentially every properties which can be tweaked by the camera system. The system can smoothly interpolate between two camera states if needed too. Take a look at the class diagram given below.
( Class diagram showcasing contextual classes of the camera system )
The purpose of the Camera State History is to keep a record of previous camera states, and the camera manager uses it. By getting an average of these camera states the Input Interpolation feature works.
Let's talk about the Camera Manager,
( Class diagram showcasing basic structure the camera system )
A Camera Behavior defines how the camera will behave in the most ideal conditions. These are some behaviors I scripted:
A Follow camera is good at following a third-person character.
A Shoulder View camera is good at showing what's ahead when there isn't enough room for the camera around the character. Another common use case of shoulder view camera is as an aim camera in third-person combat.
A Freelook camera lets an observer roam freely around the map. My debug camera is a Freelook.
Camera Constraints are a set of rules which can be applied on a camera state returned by the active camera behavior. If the current state does not satisfy a given constraint, the constraint will update the state such that it is acceptable. All constraints are applied according to the painter's algorithm: from lowest to highest priority.
Each camera behavior has tags indicating names of the constraints it has to satisfy. As an example, the Freelook behavior doesn't need to satisfy any constraints, but the Follow behavior has to satisfy all of the constraints given below:
After all the constraints are executed, we end up with a camera state which is considered as the goal state. Here the Camera Motion Controller comes in the picture, which is in charge of moving the camera to this goal state. A motion controller could be as simple as snapping the camera's position to the goal, which is the default motion controller provided by my system. But a designer can code any type of complex motion controllers that fits the game.
An example of such a custom motion controller is the Modified Proportional Controller (MPC) which works based on player and camera's velocities. The Look Ahead feature is implemented based on MPC.
Thus, the camera gets moved after going through all three components of the system: Camera Behavior, Camera Constraints, and Camera Motion Controller. Here is the code of Update( ) method of class Camera Manager: