Throughout the project’s R&D stage, I experimented with various ways to represent drone controls in a game. What became apparent as a result of that, is that controlling a drone is incredibly difficult in real life, which is not exactly what we’d want in a racing game that’s supposed to be accessible to the general public. I concluded the R&D stage with a controller that’s able to pitch, yaw, roll and accelerate up (as a real drone would). Furthermore, I attempted to implement an “auto-stabilising” feature, trying to simplify controlling a drone in that sort of fashion. The final efforts for that can be seen in the video below:
Moving on to the pre-production stage, I figured I’d take what I’ve learnt from those previous attempts, and “do it right” this time. The evidence pointed that anything resembling actual drone controls would be too difficult, and the closest abstraction of that would be helicopter controls from the Grand Theft Auto series, which, apparently, are disliked by everyone I asked, even prompting Sion Lenton to exclaim [loosely in these words] “Oh god, you’re trying to make helicopters fun!”.
All of this research effort prompted me to think about vehicles that people do seem to enjoy controlling: cars and planes (although some seem to be on the fence with that last one). Therefore, in pre-production I started from scratch with controls, taking everything I’ve learned so far, with the goal to implement car controls that can go up and down as the first goal, then adding support for plane controls and finally making it all customisable so that the design team could explore different options and see what fits best.
This entry I’m writing already contains the goals achieved and more, so I’m going to go through the elements that comprise the final solution and explain how it all comes together.
The main vehicle simulation is defined in the CGCObjFlyingPlayer component. The screen grab below displays all of the tweakable parameters in the level editor, as well as the general structure of the functionality:
The vehicle has the capacity to move in all 3 axes (Forward/Back, Up/Down and Left/Right), and rotate in 2 axes (Yaw and Pitch, no Rolling due to the way we want our collision responses to behave). Therefore, this component is able to simulate the movement of a car that goes up and down – forward and back movement, up and down movement and yaw rotations, where left/right movement and pitch is disabled. It can also simulate a plane with forward movement, yaw and pitch (everything else is not used). Therefore, it seems to perfectly accomplish the goals set out initially.
CGCObjMovement and CGCObjRotation
To explain the way it all comes together, I’ll first have to cover the “+” expandable parts that define the movements and rotations.These are just multiple instances of the CGCObjMovement and CGCObjRotation classes that the vehicle component instances, and they are defined as POD classes purely for storing the data required. Here’s what CGCObjMovement looks like:
The variables are presented nicely annotated and designer friendly in the level editor. Most of these are self-explanatory, so I’ll cover the ones that aren’t necessarily that intuitive:
- m_fDamping – this is the rate at which movement is damped in the movement direction. So if the value is 0.9f, that means the vehicle will lose 90% of its velocity in the specified direction every frame. The use of this variable in the vehicle component is outline below:
This roughly translates to: If there is no input, figure out the speed at which the body is currently moving in the direction defined by this movement (for moving forward, it would be the bodies forward vector) and apply an impulse that’s a percentage of the current speed towards the opposite direction.
- m_fDeadZoneOffset – The concept of controller dead zones is explained nicely here: https://answers.yahoo.com/question/index?qid=20100702104148AAF149P . Since controller inputs come in the range of -1.0f to 1.0f, the dead zone serves to disregard small, accidental inputs. This is useful, for example, when steering (yaw) is assigned to an analog stick’s X axis, and going up/down is assigned to that same stick’s Y axis. Without dead zones, attempting to steer will always cause some degree of going up/down, which, in a tough situation, can ruin the game experience completely. Therefore, by setting a dead zone between 0.0f (none) and 1.0f (ignore all but the maximum input), certain input values can be ignored in the vehicle component: (don’t worry too much about the left-hand-side of the condition for now, it will be covered later on)
- The two Phyre::PEnums define the key bindings for positive and negative input. I’ll go into a lot more detail on how all of that is done later on. The only thing worth noting is that if the designer does not wish to use the particular movement, for example, strafing left and right, they can set the key binding to “None”. Furthermore, if they wish acceleration forwards to be automatic, they can set the positive input to be “Always on”. Furthermore, the designer can set the same input for two separate objects, and both movements will be executed on press (such as Strafe and Yaw at the same time, when turning the analog stick on the X axis, for example).
- M_fProxyInput is a variable not exposed to the designers, as it’s to do with AI. I will cover the use of this variable in later sections.
All of these variables have Accessor and Mutator functions so that they can be manipulated through code as well as the level editor, but the class really does nothing more than data storage.
The similar class that covers rotations, CGCObjRotation, is demonstrated below:
Some of the fields defined here are similar, and it would probably be a good idea to create a “Base” movement class to contain these commonalities. The perhaps non-intuitive variables here are m_v3RotationDirection and m_fRotationSlidyness.
Both of these are used for implementing vehicle understeer and oversteer. When driving a car on concrete roads, the tyres will hold a good grip and the car will not slide when turning. This is implemented by figuring out the lateral velocity in a given direction and negating a portion of the lateral velocity in that direction. The RotationDirection vector defines this direction, and the m_fRotationSlidyness defines how much lateral velocity to negate:
The whole concept works very similarly to the movement damping function described earlier. To better put this into perspective, the direction is set to the Right vector of the vehicle body for the Yaw rotation, for example. So when the vehicle turns left, a low slidyness value would make it slide quite a lot as it turns, whereas a high value would make it make a “tight” turn.
Putting the pieces together
Now that we understand what these classes do, here’s the complete picture of how they’re used in the FlyingPlayer (main vehicle) component. First of all, they are stored as separate member variables:
Unfortunately, I couldn’t put them into an array of movements, as that would prevent me from displaying them so nicely in the level editor. Next, lets see how actual movement happens. This is the CGCObjFlyingPlayer’s Update function:
I’ll explain the Trick, Boost and VisualRotation functionality later on, for not lets focus on the actual movements that the vehicle can perform.
The first important function is UpdateVectorInformation. This is where the movement and rotation objects get their direction vectors updated:
Note that for Yaw, the rotation axis is the World-Y axis, so that when the vehicle is set up to fly like a plane would (by pitching up and down), no roll is introduced by movement. This is similar to how first-person cameras work in games: ftp://ftp.ecs.csus.edu/clevengr/165/lecturenotes/Spring13/06CameraControlLectureNotes.pdf
And it’s also how controls work in simple flight games such as Sonic & All Stars Racing Transformed.
Once everything is up-to-date with the most recent information about the vehicle, gravity is negated if the appropriate flag is set, and lateral velocities are updated based on the “slidyness” variables discussed earlier.
Next, the turning (rotating) behaviour is applied in the PerformTurns() function:
Initially, the turning was done by applying Angular Impulses, but this would invariably cause Roll rotation, since applying a Yaw impulse and a Pitch impulse would lead to some Roll when velocities are interpolated. Therefore, to allow for pitch-based controls, the turning was changed to be done via quaternions. Again, there’s no interpolation done, since that led to Roll as well.
This change meant that some extra information had to be introduced into the CGCObjRotation class, which actually tracks state information. This was unfortunate, as it no longer became a configuration-storage class, but also had to incorporate the state variables, namely m_fCurrentSpeed. This had to be done because applying rotations results in a very stiff movement – all rotation is done at constant speed. Therefore, I had to emulate acceleration and deceleration by keeping track of speeds. All of this is done in UpdateTurnSpeed():
Here, the speed is updated based on input. If the input is in a different direction than the current rotation, I apply a deceleration as well to make changing directions feel less “floaty”. This is what the first conditional statement does. The rest of the function simply adds input to the current speed (speeds up) if not at top speed, or decelerates if no input is supplied.
Based on the speed of the Yaw and Pitch, the appropriate rotation amounts are added on to the current vehicle’s transform. Once again, pitch is pre-multiplied since it’s on the vehicle’s local coordinate space, and yaw is post-multiplied since it’s on the world coordinate space.
After rotations are performed, we update the movements. This time, it’s done by applying forces to the rigid body, so that the vehicle has appropriate inertia information for resolving collisions:
I’ve omitted the part of the Move function that determines the desired speed, as it was already shown before when discussing dead zones. The no input case was already discussed as well when talking about damping.
The remaining part is the actual acceleration, which is in itself trivial. If moving at less than top speed, accelerate. If moving too fast (or switching direction), decelerate.
With the behaviour explained above, the CGCObjFlyingPlayer component becomes a flexible enough vehicle model that can be configured to simulate a helicopter, a plane or a car. This meant that the design team could explore different configurations to see what could be used best for our game.
I wanted to make a helicopter controls video as well, but I was too bad at controlling it to do the option any justice, and the camera system was fighting it as well, so I decided to omit it.
However, while controlling a plane, car or helicopter might seem familiar enough to players for them to be able to manipulate such as vehicle potentially with ease, it does not seem like they’re controlling a drone at all. That is very bad, because we’re trying to “sell” our game based on the really cool drone racing videos, such as this one:
If we can’t reproduce that feel in our game, then our vehicles might as well be flying cars, or planes or helicopters, but we want them to feel like drones.
This brings me to the second part of the plan: “selling the lie” of controlling a drone. To elaborate, the goal is to have a vehicle model that the player would find easy to control and relate to – car or plane or whatever, and then to make the player think they’re controlling a drone. This is where the CGCObjVisualRotation component comes in.
The component was built with the “car that can also go up and down” control model in mind, but could be readily adapted to the plane or helicopter model as well.
The core functionality lies in the component’s update function, where all it does is a series of smooth rotations to the mesh (not the rigid body) of the vehicle:
Here, fDeltaForward is the Forward/Back Movement object’s input amount from the controller (between -1.0f and 1.0f), and fDeltaRight is the Left/Right Rotation (Yaw) object’s input amount from the controller (between -1.0f and 1.0f again), scaled by designer defined amounts.
The component defines a target rotation (starting with the identity), and appends some pitch and some roll based on input, and then smoothly interpolates the mesh towards the desired “look”. This means that when the vehicle accelerates, the mesh would tilt forwards. This is exactly how a real drone looks when you want to go forwards – since its propellers always push it up on its local coordinate space, tilting the drone forwards means that some of that push transfers to actually moving forwards.
Furthermore, if a real drone performs a turn, it rolls to the side to stop moving in its current direction, and pushes itself away and to the side. This is exactly what the component mimics when the vehicle yaws.
With this basic set of mesh rotations, it becomes possible to tweak all of the parameters in the vehicle model (such as slippery turning) and the visual rotations to achieve a feel that is similar to a real drone, but involves none of the complexity of controlling one. The link below demonstrates a first-person camera attached to the vehicle model:
While there’s still some discrepancies from real drone behaviour, feedback has generally been very positive, indicating that the drone in our game is definitely starting to feel like the one from all of the drone racing videos.
What next and what I would do differently
With this vehicle model in place, the design team are given a large amount of options to explore in terms of how we are going to simulate our drones. However, it has some less than ideal consequences due to the degree of freedom it provides.
The main shortfall of this approach is that the vehicle mesh is detached from the collision box. This means that while the mesh might be tilting forwards to visually represent acceleration, the collision box remains aligned to the XZ plane:
This means that our collision boxes would need to be big enough to encase the drone model in all mesh rotations if we want to avoid clipping. A collision sphere would be ideal for that, because if the drone is rotated around the middle of the sphere, it does not matter which way it is rotated, since its extents are equal to the radius at all times.
However, bigger collision boxes also mean that there’s more space for “invisible” collisions to happen, and it might lead to players thinking that the collisions that occur are completely unfair, ruining the game experience. This can become incredibly important if we want our players to fly through tight gaps and tiny corridors (which, at this point, we aren’t certain we will have).
Another minor problem that arises from this configuration is that the vehicle needs to be defined in more than one node on the scene graph – the first node is the collision box, and the second is its child node which is the mesh that rotates in its local coordinate space. This comes up as an extra layer of complexity for the design/art team to understand, and it would be easier to tweak all of the vehicle parts if the vehicle was contained in a single node.
All of these can be achieved via refactoring the VisualRotation component to act on the same component as the FlyingPlayer (physical vehicle model) component. However, making the visual representation of the vehicle act on the same object as the physical one is in itself a huge constraint. In the same way that I had to remove rolling from the vehicle model for the local pitch-global yaw set up to work, I would have to remove pitch controls entirely from the vehicle model for this unified visual and physical model to work.
In the current state, the physics model can pitch to simulate plane or helicopter controls. The visual model also pitches to represent acceleration. Therefore, these models would conflict with each other if they were to act on the same object. This is fine when we have decided on the vehicle simulation model we want to use, as such refactoring would remove flexibility and provide a better, more specialised model. The main advantage of unifying visual and physical rotations is improved precision of the collision box, which could be a big gain depending on our level design.
Currently, the issue is written as a user story and I will need to discuss it with the design team in the production stage and assign a priority to performing this vehicle model optimisation. However, I do personally feel that this optimisation will have to be done, since we can’t even start the race from the ground at this point without the model either floating in air (collision box is big enough to prevent mesh clipping) or the model clipping through the ground once the player starts accelerating due to visual pitch.
Another aspect of the vehicle controller that I would do differently is standardising the CGCObjMovement and CGCObjRotation classes, as mentioned earlier. They have common parts between them, which could be factored out for a more standardised movement model between rotations and displacements. I think there are pros and cons with unifying these classes. The benefit is that they would be easier to understand, as there wouldn’t be different deceleration representations which achieve the exact same goal conceptually, but are represented differently (as damping in movement, and deceleration in rotation). However, creating a base class is also a form of dependency, so during exploratory stages of development it might constrain the model more than needed, as it might be unclear how these classes are supposed to evolve (and they might well evolve towards having vastly different representations intentionally). That being said, I would definitely like to standardise these classes more in the future, once our vehicle model is finalised.
Finally, I don’t like the dependency between the FlyingPlayer controller and the VisualRotation. The latter currently is being updated with inputs by the former. What makes matters worse, is that the camera controller wants to retrieve the visual rotation from the FlyingPlayer as well. So the FlyingPlayer component becomes a central hub to manage all of the components. However, when components start to rely on other components, that introduces a very tight coupling between them. The FlyingPlayer component really doesn’t have to have a VisualRotation, as it can perfectly function without it. However, since it’s the central controller, it also has to have the functionality to update the VisualRotation component. To achieve both of these goals, an increasing number of checks need to be used to verify if the VisualRotation exists, and with a growing number of components it gradually starts becoming messy and difficult to manage these dependencies. In this case, I think a message passing system would be very helpful to reduce these checks. After all, the FlyingPlayer to VisualRotation dependency is one-way only, so it would be much tidier to simply send a message from the FlyingPlayer with the information that the VisualRotation needs, and thereon the FlyingPlayer doesn’t care if that message reaches anyone. That way, no checks are needed for if the VisualRotation exists or not, the FlyingPlayer has done its job without needing to do any explicit dependency checks.
The messaging system has been written up as a user story, and we do hope to get it into our framework early on in the production phase. This should also reduce the number of crashes for the design team when they forget to add a certain component and the dependency checks weren’t set up.
None due to NDA.