Modern C++ For Game Dev

6 June, 2015
c++ game-dev

Introduction

C++ now feels like a new language. With the introduction of C++ 11 and some fixes in C++ 14, the language has been overhauled into something which allows for cleaner, faster and more robust code while adding some significant changes to syntax and semantics. However, from all the changes that have been made, not all are directly applicable to game development. This is just another way of saying, you will be using certain features more frequently than others. For example, declaring a function noexcept now not only means that the function will not churn out exceptions, it also allows the compiler to optimize it further. Due to some constraints of game development, you will most probably not be using exceptions and hence it would possibly be redundant to use noexcept.

This is just an introduction to the new features accompanied with simple examples of how to use them. The features have not been explained in-depth nor does this article state the best practise to use them, it is merely a quick overview with possible applications. For detailed explanations I have included sources and references at the end. (Note: You will definitely need Visual Studio 2015 on Windows)

Features to Use

These are the new features which you should start using immediately. They will lead to more robust, uniform and maintainable code. Although most of the features are C++ 11 I’ll be using the newer C++ 14 syntax wherever possible.

nullptr

When initialising a pointer, instead of saying

Character *p1 = NULL;   //Nope
Character *p1 = nullptr; //Yep!

Use nullptr instead of NULL. This is because NULL is essentially 0, which is a literal integer and NOT a pointer. While nullptr is of type std::nullptr_t, this type implicitly converts to any pointer type ensuring type safe code.

One thing to note here is that if you alias NULL to map to nullptr then there is a possibility that your code might not work.

#define NULL nullptr //please don’t do this
int n = NULL;       //won’t work now

Initializer list

C++ now has a ‘uniform’ way for initializing objects and types, termed braced initialization, that can be used almost anywhere. Object, containers and even built-in types can take advantage of this syntax. An initialization looks like:

{ value1, value2, value3, value4. }

There can be multiple values within the braces but the values should be of a single type, even if the types can be implicitly converted. Now, using this syntax with a vector:

//the vector is initialized with the values
std::vector<int> limits { 4, 6, 2, 1 0, 3};
int max_players { 5 };      //default value of 5
int array_limits[] { 2, 8, 16};

In fact certain STL objects (such as atomic) require using this braced initialization syntax. Classes and functions can be overloaded to use the initializer list object, but first, some background info. Let’s say we have a class Player:

class Player
{
    Point Position;
    unsigned Num;

public:
       Player();                      //C1
       Player(Point p, unsigned n);   //C2
};

To initialize an object of class Player one can simply say:

Player p1;                  //calls C1
Player p2 { {10, 10}, 2};   //calls C2 and Ctor for Point

If the player class did not have C2 declared then p2 would not compile. Alternatively, if there is another constructor C3 as such, within the player class:

Player(std::initializer_list<typename>); //C3

The initialization of p2 will call this constructor (C3) rather than C2. In fact, you can even use std::initializer_list as a function argument for member functions. However if p2 is initialized using regular brackets, it will call C2.

Player p2 ( {10, 10}, 2 );  //calls C2 instead of C3

Another use for initializer lists is to be used in class declarations for default values. The Player class would then look like this:

class Player
{
    Point Position {10, 10};
    unsigned Num { 2 };
};

One thing to keep in mind when using initializer lists with auto is to make sure the deduced type is not an initializer list but an actual constructed object.

auto

This is one of the major changes, because this one keyword has made life so much easier. There are several ways auto can be leveraged. Auto allows the type of the variable / object to be deduced when initializing it. The first and foremost is for local and global initializations. So let’s take it step by step.

int tiles = 10;
auto tiles = 10; //tiles is of type int

const unsigned NUM_TILES = 10;
const auto NUM_TILES = 10; //type is int

When assigning local variables with values, even if they are integers, use auto. Yes, even for something as simple as this. Why? This will get you into the habit of writing the initializer idiom, which means that it’s not only easier to change from int to float by changing the value of 10 to 10.0f, but it also means that you’re avoiding implicit copies. I’ll explain this in the next paragraphs.

Point player_position(22, 16);

//initializer idiom
auto player_position = Point { 22, 16};

This might seem odd at first especially since you might think you’re creating a copy, but actually according to the standard both of the above lines have pretty much the same effect. This is the syntax for the initialization idiom

auto var_name = type {initi values}

Now what was that thing about implicit copies? Here’s the example:

vector<Bullet> &bullet = p1_bullets;       //Line 1
auto &bullet = p1_bullets;                 //Line 2

Now isn’t this a lot easier than typing the full vector initialization syntax again? And not just that, but assuming p1_bullets was of type vector instead, line 1 will not compile, but in certain situation when it does, C++ will create an implicit copy without your knowledge and potentially slow down your program while line 2 will automatically deduce the type changed to a const. The situation is pretty similar when for example changing from a vector to a list or vice versa.

Auto can also deduce the return types of functions. This has been refined in C++ 14. So you can now write something like this:

auto CreateTexture()
{
//explained in coming sections
return std::unique_ptr<Texture>{std::make_unique<DXTexture>() };
}

In this example, the return type is automatically deduced as unique pointer of type texture. If you may want to change it so that the function only returns type of DXTexture auto will deduce it as well. In certain cases you may want to use decltype(auto) but let’s just leave that for now.

And lastly, auto can also be used to alias functions. Suppose you have different maths libraries for different platforms but you want the user to use a common interface:

#ifdef _WIN32
    const auto &MatrixIdentity = XMMatrixIdentity;
#else //another platform
    const auto &MatrixIdentity = glMatrixIdentity; //just an example
#endif

When the user calls MatrixIdentity, regardless of the platform it should call the right function, however it is up to you to ensure the argument types are the same.

Read up on the reference collapsing rules which is unfortunately not covered here but super important.

constexpr & using

Macro’s aren’t preferred because they have no type-safety and can cause duplication and redundancy possibly increasing the size of the binary. Instead, inline functions are preferred for calculations and typedefs for aliasing types. However, the problem with inline functions is that they are used at runtime, unlike macros which are statically compiled. Enter constexpr which is kind of a mixture of inline functions and macros. It essentially allows macros like behaviour for functions, i.e. the functions are calculated on compilation with typesafety. Taking an example of a macros which finds the square of a number.

inline float square(float num){}
constexpr auto square(float num)           //better
{
return (num * num); //return type deduced automatically

}

Another really cool thing you can do with constexpr is define your own literals. For example if you wanted to define a float you add ‘f’ after the number and decimal point, you can do something very similar to that using constexpr. If you want to define a colour quickly, say something between range of 0 to 255 and assign that value to a RGB and return a colour object:

class Colour
{
    float R;
    float G;
    float B;

public:
    Colour() = default;
    constexpr Colour(float r, float g, float b):
      R{ r },
      G{ g },
      B{ b }
    {}

    ~Colour() = default;

    Colour(const Colour&) = default;
    Colour(Colour&&) = default;
};

constexpr Colour operator"" _col(long double num)
{
    return Colour{(float)num / 255.0f, (float)num / 255.0f, (float)num / 255.0f};
}

auto grayish = 128.0_col; //grayish is now of type Colour with RGB values as ~0.5f

In this case, you also need to declare the constructor for colour as constexpr and make sure to have the default / defined constructors for copy and rvalue references (explained in coming sections). The type of constexpr argument num has be of the largest size so if you want to use your type with float, use long double, if you want to use it with integers use long long int. One thing to note is that constexpr only works for values that are known at compilation and has several other constraints (for an in depth explanation see the references below). The STL also defines some literal operators which you can use.

auto name = foos; //name is now of type std::string
auto length = 64ul; //length is now of type unsigned long

One limitation of typedef was that you could not use it with templates. The keyword using lifts this limitation. using is very similar to typedef with a slightly different syntax.

typedef unsigned int uint;   //uint is now unsigned int
using uint = unsigned int;   //same

template<typename T>
using Bullets = vector<T>;

auto PlayerAmmo = Bullets<Bullet>{};
//PlayerAmmo is of type vector<Bullet>

Another useful feature is static_assert. It is just like assert, except it has no runtime costs. It is checked during compile time. Ideally this is meant to be used with templates along with type traits to limit the template arguments to the desired ones.

static_assert(sizeof(int) >= 4, Integer not 32 bits);
//compilation will fail if int is less than 4 bytes

Strongly Typed enums

Enums are global, and although you can scope an enum, it is was not required. This lead to clashes and overriding which caused ambiguous situations. Declaring a strongly typed enum means that it has to be scoped, similar to namespaces and avoids such problems.

enum PlayerNum
{ P1, P2, P3, P4 };

int p = P1; //allowed

enum class ScopedPlayerNum  //new syntax
{ P1, P2, P3, P4 };

int q = P2; //error
int r = ScopedPlayerNum::P3; //'new' syntax

rand() Replacement

This item is just a very short version of Stephan Lavavej’s talk. I’m going to summarise what he said in a few paragraphs. First of all, rand() is bad. It is awful, limited and inaccurate and should not be used. What to use instead? The header defines a lot of useful stuff that we can use. Now, to get random variables you need an engine, a random device, and a distribution. A random device produces random numbers, which can be used to seed an engine. An engine is a Pseudo-Random Number Generator (PRNG). The most common one being the Mersenne-Twister. And lastly a distribution is what defines the range and type of the numbers you want to generate.

std::random_device rd;

//Mersenne-Twister engine, rd() is the seed
std::mt19937 mt( rd() );

//closed range of [0, 99] NOT [0, 99)
std::uniform_int_distribution<int> dist(0, 99);

//now calling dist(mt) will generate a random number
int random_number = dist(mt);

A few things to note here. First of all, MT is pretty fast, seedable and reproducible. So if given the same seed it produces the same numbers. Which is very useful if you want to generate random numbers for a game, save it to a file and load it with the exact same seed-result. Random device on the other hand is slow, strongly platform dependant and non-reproducible. And one last thing before moving on to the next section, constructing and copying engines is pretty slow so try and avoid doing it multiple times.

Range Based for Loops

Often in your programs you want to traverse the whole container, be it a vector or a map or a list. Now instead of typing the long and intensive iterator syntax it is made easier by range-based for loops which have the syntax as follows:

auto ammo = std::vector<Bullet>{};

for(auto it : ammo) {};
//it cannot modify any elements, as it creates a copy

for(auto &it : ammo) {};
//now elements can be modified and no copies are created

And if ammo changes type from say vector to list, the loop still remains the same, no changes! So how does C++ interpret this loop? Here is the complete syntax:

for (range-declaration : expression) statement is translated into:

auto&& __range = expression;

for(auto _begin = begin-expr, _end = end-expr; _begin != _end; ++_begin)
{
    range-declaration = *_begin;
    statement;
}

The begin/end-expr are calculated by:

  • If it is an array, it is iterated from __range to __range + N
  • For classes which support it (containers), it calls __range.begin() and __range.end()
  • For everything else there’s mastercard begin(__range) and end(__range) use argument dependent lookup

Classes

There are several new features added to classes which make it much more secure and provide a flexible method to manage resources. Override and final are two such keywords. If you are overriding a virtual function of a base class declare that function override and if you don’t want any classes derived from the current class to be able to override that function, declare it final.

//abstract class
class Texture
{
public:
    virtual bool LoadTexture(const std::string &) = 0;
};

//derived 1
class GLTexture : public Texture
{
public:
    bool LoadTexture(const std::string &) override final;
};

class CustomGLTexture : public GLTexture
{
public:
     //ERROR!!
    bool LoadTexture(const std::string &) override;
};

Instead of declaring empty constructors, you can now use the keyword default and the compiler will generate them for you. And if you want to disable copying of your any object of your class, or perhaps even disable class instantiation / destruction, mark the function / constructor delete and the compiler will remove it. This is a lot better than moving the constructors into private.

class Texture
{
public:
//compile generated
    Texture() = default;
    ~Texture() = default;

//compiler deleted
    Texture(const Texture&) = delete;
    operator=(const Texture&) = delete;
//also delete or default rvalue refs
    Texture(const Texture &&) = delete;
};

Another really cool feature (imho) is delegating constructors, or constructors that can call other constructors. Example:

class Foo
{
    int x;
    int y;
    char c;
public:
    Foo() :
    x { 0 }, y { 0 }, c { A}
    {}

    Foo(char _c) :
    Foo() //delegating constructor
    {c = _c;}
};

STL

There are some new and exciting features in STL which are very useful for resource management along with some new developments for containers and algorithms. Do note, that all these features may not be available on every console platform.

Smart pointers

Smart pointers are objects which automatically delete pointer data at the end of its lifetime. Depending on what pointer and syntax you are using you can vary the lifetime of the pointer object. There are essentially two types of smart pointers included in the header . The type of pointer you use depends on the ownership of the resource.

std::unique_ptr<Player> P1;
std::shared_ptr<Box> B1;

If a particular resource is owned by just one object use unique_ptr, if several objects are to share a single resource use shared_ptr. In the case of unique_ptr, the pointer data is deleted when the object P1 is deleted. Shared_ptr is slightly different. It keeps track of the number of shared_ptrs which hold the same resource. Every time a shared_ptr pointing to data being held in B1 is deleted, the reference count is decremented by one (or incremented if another object is being tracked). When the reference count reaches 0, the pointer data and object are deleted securely. The increment / decrement of the reference count performed by the shared_ptr is an atomic operation, meaning it does not cause a race condition, the other data stored by the shared_ptr is however not atomic.

Accessing data from the pointers is done exactly as any other pointer, since the operators are overloaded by both smart pointer types. They can also be dereferenced similar to any other pointer.

P1->MoveForward();
B1->CheckCollision();

auto player_box = (*B1); //player_box is of type Box

Before I explain how to construct the pointers a few things to note.

std::unique_ptr<Player> P1, P2;
std::shared_ptr<Box> B1, B2;

P2 = P1; //will NOT work
B2 = B1; // works!

Unique_ptr does not include reference counting and hence has deleted the copy assignment operator while shared_ptr doesn’t! Lastly, be careful when using shared_ptr since it’s reference counting is atomic

To setup the pointers use

make_unique/shared<typename>(arguments).

This constructs data on the heap (freestore) or if you use arguments, it copies the data and sets it up for you.

std::unique_ptr<Player> P1 {std::make_unique<Player>() };

//even better
auto P1 = std::make_unique<Player>(); //calls new

auto B1 = std::make_shared<Box>();

What if you want an array of objects? In that case you want to call new[] on construction and delete[] on destruction. Both smart pointers are overloaded for this.

auto Players = std::make_unique<Player[]>(NUM_PLAYERS);

//Similarly
std::unique_ptr<Player[]> Player; //uninitialized

Smart pointers can be very easily used with abstract and concrete types. See the example above, in the item on auto where I explain return type deduction.

Now that you know how to use smart pointers, a few notes on when to use them and when to avoid using them. Use smart pointers for local variables that allocate memory on the heap.

auto vertices = std::make_unique<Vertex[]>(NUM_VERTICES);

vertices[0].position ..
vertices[10].normal = ..

Also, don’t have function arguments accept smart pointer. Unless you are actually modifying the smart pointer object, or unless you want to view its contents do NOT pass it into a function.

bool CreateVertexBuffer(std::unique_ptr<Vertex[]> _verts); //Why?? No!?

//oh HELL NO!
bool CreateVertexBuffer(const std::unique_ptr<Vertex[]>&);

//Yep!
bool CreateVertexBuffer(Vertex *_verts);

//call it using
CreateVertexBuffer(vertices.get());

.get() for any type of smart pointer returns the pointer to the data, in this case it returns Vertex *. Another type of pointer which I haven’t mentioned yet is std::weak_ptr. It is used to track and access an object only if it exists and can be deleted anytime from somewhere else. However to use a weak pointer you need to assign it a shared pointer resource. Assuming you have a weak pointer that points to a boss which needs to be defeated.

std::weak_ptr<Enemy> final_boss;

However, you don’t know when the boss is going to be killed / deleted. The actual boss object is managed by a shared pointer, elsewhere in the code:

auto BOSS = std::make_shared<Enemy>();

First, let’s setup the weak pointer,

//final_boss now points to the resource
//held within the object BOSS
final_boss = BOSS;

Now if you want to check, in another function, if final_boss is still valid, you can call this code:

if( final_boss.expired() == true)
    //if boss has been deleted
else
{
   //boss is still alive
auto BossRef = final_boss.lock();
//BossRef is of type shared_ptr
 BossRef->CheckHealth();
}

To access the pointer being held by final_boss use .lock() which returns a reference counted shared_ptr.

Additionally, I would like to mention CComPtr, it is the smart pointer provided by Microsoft for COM objects and is very useful especially when dealing with Windows/DirectX resources.

Function Pointers

So you want to create something like an event system? And you want to use function pointers? STL is to the rescue!! In the header , is a treasure trove of………ok I’ll stop. There are mainly two types of function pointer objects, std::function and std::mem_fn (member function). Also, don’t use std::bind() now, since auto and function pointers do the trick quite well.

//can store void foo();
auto F1 = std::function<void()>{};

//can store void foo(int, Player&)
auto F2 = std::function<void(int, Player&){};

These can be called using:

F1();
F2(0, P1);

Assuming class Player has a public function void MoveForward() and another one bool isJumping(), you can store a pointer using:

auto P1 = Player{};
auto move = std::mem_fn ( &P1::MoveForward);

auto jump = std::mem_fn<bool()> ( &P2::isJumping);

//called using:
move();    jump();

Algorithms & Containers

There have been a few changes and additions to STL containers and the header . One major addition to the containers is emplace() / emplace_back(). This is implemented in list, vector, dequeue etc. Instead of copying the element into the container (like push_back), emplacement uses rvalue references and the temporary object and add it to the container without creating a copy! The containers also implement cbegin() and cend() which return the const­_iterator for that container.

There are also some new containers which you can use. If you have a fixed sized array, you might declare it as

Player current_players[MAX_PLAYERS];

This C-style array is okay but it doesn’t give you access to iterators or anything of that sort, which is why you can now use the STL implementation std::array in the header which allows it to be used in range based for loops:

std::array<Player, MAX_PLAYERS> current_players;

Another useful container is unordered_map, which is like map but, as you guessed its unordered i.e. since there is no order / sorting it is faster to access but slower than map for iterating through it.

std::tuple now allows you to combine elements of different types together into a single object.

//initializes tuple of default player and number 2
//the template may have any number of arguments
auto player = std::make_tuple<Player, int>{Player{}, 2};

//alternatively
std::tuple<Player, int> player;

//to access the elements
std::get<0>(player); //returns Player&
std::get<1>(player); //returns int&

There are several additions to the library, which also now accepts function lambdas (next section). Just like find, find_if, sort etc, the following are a few additions to the algorithms:

all_of  tests condition on all elements in range
any_of  if any element fulfils the condition
shuffle  prefer this to random_shuffle
is_sorted  checks to see if container is sorted
and more

Features to Use If…

Ideally, these features should be used if you’re a library developer. For example, if you’re creating a vector/matrix library you can make good use of rvalue references and move semantics etc. It is important that you are aware of how these features work, even if you don’t have uses for them since these account for some major changes to C++.

Rvalue References & Move Semantics

If we have an assignment operation where all the variables are of type vector3:

pos = current_position + forward_vector;

Here, “pos” is an lvalue and “current_position + forward_vector” is an rvalue (left/right value). So when program execution reaches this statement, a new vector3 value is constructed with the contents of current_position + forward_vector, i.e. essentially the rvalue. The rvalue would be copied into the lvalue (pos). Before C++11, there was no way to access the rvalue, which meant that copies were being made causing undesirable overhead. With the introduction of rvalue references the vector3 class can access these temporary rvalue objects and use that memory directly without creating copies. So the vector3 class may have something like this:

//syntax for rvalue references (&&)
vector3& operator=(vector3 &&rhs)
{
     //assume float *data
     //'move' the data
     data = rhs.data;

    //null the unneeded data
    rhs.data = nullptr;
}

You are basically ‘moving’ the data from one object to another. Which is where the move semantics comes into play along with std::move. (Side Note: Rvalue references should be handled (defaulted / deleted) wherever applicable in constructor and non-constructor functions). Another example:

Matrix AddTranspose(const Matrix &rhs)
{
      Matrix temp;

      ...//add and put into temp
      ...//transpose temp

      return std::move(temp);
}

A few things to note here. First, the return type is not Matrix& and it is not const either. Using either of those inhibits the move semantics from working and doesn’t call the rvalue reference constructors. Secondly, using std::move doesn’t guarantee a swift move operation. All std::move does is, cast the object into an rvalue reference so that the appropriate constructor will be called, however if such a constructor is not implemented, it won’t work! Lastly, don’t don’t don’t DON’T return by rvalue reference. This is something which should only be used by advanced library devs:

Matrix&& AddTranspose(const Matrix &rhs); //Please NO

This returns a dangling reference which is deleted. Do keep RVO and NRVO in mind since they can be more efficient than rvalue references. Also, due to these type of changes, expression categories have expanded, please read up the standard on the new value categories.

Perfect Forwarding

If you want to use a function’s arguments as parameters to another function, you sometimes need to ensure that the values being passed are of the correct type. This is what perfect forwarding is. std::forward retains the correct type of the function argument and transfer it to another function. This is pretty much used for rvalue references. For example:

template<typename T, typename Arg>
shared_ptr<T> AmmoFactory(Arg &&arg)
{
return shared_ptr<T>(new T(std::forward<Arg>(arg)));
}

Two important things to note in the above code. Firstly, Arg &&arg, is something called a forwarding reference. Which means that it retains its lvalue OR rvalue property. IF it was Arg &arg, then any value passed into it, be it either l or r value would only be accessible as an lvalue. Secondly, std::forward. This allows rvalue references to retain that property and be passed into the shared pointers rvalue constructor. std::forward casts an argument to an rvalue if it is NOT an rvalue, otherwise it leaves it unchanged. Also, forwarding references cannot be virtual and have to be in the header.

Lambdas

Lambdas or a lambda expression is an unnamed function object which is used to capture a few lines of code and use the object for algorithms or assign it to callable objects. Lambdas are too big a topic to cover so I’m just going to introduce the very basics.

//very simple lambda, unassigned
[](int num) { return num * num;}

//assign it to a variable
auto square = [](int num){.}

//calling convention
square(11); //returns the square of 11

Lambda’s can also capture local variables and use them. Taking the random distribution example from the previous section:

std::mt19937 mt(1234);

std::uniform_int_distribution<int> dist(0, 999);

//access local variables mt and dist
auto get_random = [&_dis = dist, &_mt = mt] ()
{ return _dist(_mt); }

//use get random
std::cout << get_random();

You can even extend get_random to accept generic types using auto:

auto get_random = [&_dis = dist, &_mt = mt] (auto num)
{ return (num * _dist(_mt) ); }

//returns an integer
get_random(2);

//returns a float
get_random(1.5f);

Additionally

There are a few features which I have not covered:

  • Concurrency – C++ now has standard support for multi-threading and asynchronous operations.
  • Concepts – They allow you to specify the ‘conceptual’ constraints to template arguments.
  • Variadic templates – Now templates can take in an undefined number of arguments
  • Type Traits – Ideally used with template arguments to constraint the type of argument being used in the template
  • STL – I’ve only covered a fraction of the changes in the STL

Conclusion

The first section goes over the features which you absolutely should start using, like right now! While the second section introduces some ‘tricky’ features that you should be aware of and only use if you know exactly what you are doing.

This should, hopefully, be enough information to get you started with modern C++. It would definitely be more helpful to understand these features in depth along with appropriate usage and proper semantics. Do go through and read the references and sources below as they do a better job of explaining the topics. Now all there is left to say is: Welcome to Modern C++ and Happy Coding!

References & Sources

↑ Ctrl + Home ↑