Transforms in 3D Space
Prerequisites
- None
Article
In this article, we're going to explore 3D transformations and the capabilities we'll need to make game play programming easy. If you've used Unity or Unreal Engine before, you've likely come across the Transform object as a property of their game objects. We're going to build a similar class here.
This article will not cover a lot of in-depth math, but will focus instead on the applications and implementation of 3D transforms. If you want to read more about math, there are many excellent articles out there on vectors, matrices, and quaternions, which we'll be using throughout our engine. While most of the math is not difficult, it is very hard to get right, so we're going to outsource most of the implementation of these types to the GLM library.
That said, I do like to leave the option open to write my own versions of these classes, so the first thing I generally do is to typedef the GLM-namespaced types to my own type names. Later, if we wanted to write our own implementations, then we could simply change the typedefs to our own classes:
#define GLM_FORCE_RADIANS
#include <glm/glm.hpp>
#include <glm/gtx/quaternion.hpp>
#include <glm/gtc/matrix_transform.hpp>
typedef glm::vec2 vector2;
typedef glm::vec3 vector3;
typedef glm::vec4 vector4;
typedef glm::quat quaternion;
typedef glm::mat3 matrix3;
typedef glm::mat4 matrix4;
When including the GLM libraries, we add a define here of GLM_FORCE_RADIANS - this is the preferred way to implement GLM and just means we'll be passing everything into GLM functions as radians instead of degrees.
The type names are fairly self explanatory and will mirror what you see in other engines.
Handedness and the Up Axis
Since this engine is built on OpenGL, we will define our transformations to match. If you're implementing a similar system in DirectX (which is left-handed), or want to support a Z-up axis to match your artists' 3D modeling programs, you can make some adjustments to the code below.
Our transforms will be based on a right-handed, Y-up system. That is, by default, we'll be looking down the negative-Z axis (forward) and up will be +Y.
Properties and Methods of a Transform
To track objects in 3D space in our game, we'll need to know 3 things: translation or position (a 3D vector), scale (also a 3D vector), and rotation (a quaternion).
You may wonder why we don't store rotation as Euler angles (on X-Y-Z or as yaw-pitch-roll), since those are generally more intuitive - the answer is to avoid a phenomenon known as gimbal lock. Gimbal lock occurs when you lose an axis of rotation because you've rotated such that two axes are now the same. This image provides a great visual:
Source: Wikipedia
Quaternions avoid this problem by storing a combination of rotations and essentially redefining the rotated axes. Fortunately, you can still create quaternion rotations from standard axes by using a function such as glm::angleAxis, which takes an angle or rotation around a well defined axis. For example, glm::angleAxis(glm::radians(degrees), vector3(0, 1, 0));
will create a quaternion that rotates 90 degrees around the Y-axis. These can be combined by multiplying quaternions together. In fact, one of our functions in our transform class is going to do just that:
void UTransform::Rotate(vector3 axis, float degrees) {
quaternion newRotation = glm::angleAxis(glm::radians(degrees), axis);
m_rotation = m_rotation * newRotation;
}
A quaternion can be also be "decomposed" into Euler angles, and we will use those later on in our editor and translate back and forth behind the scenes, since they are easier to understand and visualize than quaternions.
We will also want to redefine our three axis vectors relative to our game object. Instead of X, Y, and Z, we'll call them Right, Up, and Look. That way, when you're moving your player forward, you'll know to increment the position or set the velocity along the Forward vector; likewise, if you are creating a game with flight controls, you can use the Right, Up, and Look vectors to Pitch, Yaw, or Roll around your objects' own axes.
Finally, in addition to our traditional get and set functions for position, scaling, and rotation, we'll implement some helper methods to help us Move, Scale, and Rotate given different types of user-friendly input (like in our Rotate function example above).
Let's take a look at the Transform class:
/**
* A transform (combination of position, rotation, and scale)
*/
class UTransform : public UObject {
public:
UTransform() {
m_position = vector3(0.0f);
m_scaling = vector3(1.0f);
m_rotation = quaternion(1.0f, 0.0f, 0.0f, 0.0f);
}
~UTransform() = default;
/**
* Get/set position
*/
void Position(vector3 position) { m_position = position; }
vector3 Position() { return m_position; }
/**
* Get/set scaling
*/
void Scaling(vector3 scaling) { m_scaling = scaling; }
vector3 Scaling() { return m_scaling; }
/**
* Get/set rotation
*/
void Rotation(quaternion rotation) { m_rotation = rotation; }
quaternion Rotation() { return m_rotation; }
/**
* Move from current position (add to position)
*/
void Move(vector3 amount) { m_position += amount; }
/**
* Rotate
*/
void Rotate(quaternion rotation) { m_rotation *= rotation; }
void Rotate(vector3 axis, float degrees);
/**
* Scale by a factor
*/
void Scale(float factor) { m_scaling *= factor; }
/**
* Axis vectors (oriented to rotation)
*/
vector3 Look();
vector3 Up();
vector3 Right();
/**
* Set the rotation by specifying a new look vector
*/
void Look(vector3 look);
/**
* Transformation matrix
*/
matrix4 Matrix();
protected:
// Position in 3D space
vector3 m_position;
// Scaling factor
vector3 m_scaling;
// Rotation
quaternion m_rotation;
};
At the top of the class, our constructor sets reasonable "defaults" for our properties: a position at (0,0,0), a scaling factor of (1, 1, 1), and no rotation (represented by a quaternion of (1, 0, 0, 0) for the w, x, y, and z components). You can also see our Position, Rotation, and Scaling get and set functions. These take absolute values and set the appropriate variable as-is.
Next are our Move, Rotate, and Scale functions, which each take a vector3 and a quaternion respectively and "add" the translation, scaling factor or rotation to our variables (for scaling and quaternions, that means a multiplication).
Now we have our redefined axis functions for Look, Up, and Right vectors:
vector3 UTransform::Look() {
return(m_rotation * vector3(0, 0, -1));
}
vector3 UTransform::Up() {
return(m_rotation * vector3(0, 1, 0));
}
vector3 UTransform::Right() {
return(m_rotation * vector3(1, 0, 0));
}
These take our default axes, where right is +X, up is +Y and look is -Z and multiply them by our rotation quaternion to get their modified versions.
Next we'll take a look at an interesting helper function called Look() that takes a new look or forward vector and sets the rotation. As an example, think about a directional or spot light, which the team will define with a vector. We'll need to orient a camera in our rendering code to match for shadow rendering. You may also want to turn your player or an NPC to face each other - you can spherically interpolate (slerp) between two 3D vectors and not have to worry about the quaternion math behind the scenes:
void UTransform::Look(vector3 look) {
// First, check for parallel vectors
float dot = glm::dot(vector3(0, 0, -1), look);
if (dot > 0.999999 || dot < -0.999999) {
m_rotation = quaternion(0.0f, 0.0f, 0.0f, 1.0f);
return;
}
// Otherwise, set rotation
vector3 a = glm::cross(vector3(0, 0, -1), look);
float v1len = glm::length(vector3(0, 0, -1));
float v2len = glm::length(look);
float w = glm::sqrt((v1len * v1len) * (v2len * v2len)) + dot;
m_rotation.x = a.x;
m_rotation.y = a.y;
m_rotation.z = a.z;
m_rotation.w = w;
}
First, this function does a sanity check on the dot product between the new look vector and our "default" look vector of (0, 0, -1). If they are equal, we can bypass the rest and just reset our rotation.
If they are different, we can calculate the new rotation quaternion by doing a cross product of our default look vector and our new look vector. That will make up the x, y, and z parameters of our quaternion. The w parameter is set to the product of the length squared of both vectors plus their dot product, or the angle between them. This math orients our object with a forward or look vector pointing in a new direction.
Finally, we will need to convert our quaternion to a rotation matrix to use in our rendering and other code:
matrix4 UTransform::Matrix() {
matrix4 t = glm::translate(glm::mat4(1.0), m_position);
matrix4 s = glm::scale(glm::mat4(1.0), m_scaling);
matrix4 r = glm::toMat4(m_rotation);
matrix4 modelMatrix = t * r * s;
return(modelMatrix);
}
Our Matrix function combines all of our properties to create a 4x4 matrix that contains the translation, scaling factor, and rotation that make up our model or local transformation.
Next Steps
We now have a fairly robust Transform class that we and our gameplay programmers can use to set the position, scaling, and rotation of objects in our scene.
An Entity or game object will have a root Transform and the components that we add to it may have a Transform of their own (eg. an offset from the root or parent). In our rendering engine, we'll use Transform objects to create a scene graph that (along with a bounding shape) will help us do culling and determine levels of detail. In our physics engine, we'll push updates out of the physics engine and into the root transform, so that any Mesh, Audio, or other attached components will move along with our objecct.
The Transform class is a key piece of any game engine and we will use it extensively in subsequent articles.