G-Engine #6: Math Library

After my post about rendering basics, I had a fairly underwhelming final result: a single triangle being rendered in 3D. But it’s progress! My next goals are to do two seemingly simple things: move the virtual “camera” around my 3D environment to view the triangle from different perspectives, and move the triangle itself to different locations in 3D space.

If we were just writing some graphics library sample code, achieving those two goals would not be too tricky. However, I want to avoid using too many graphics library convenience methods (to avoid “lock in” to a particular graphics library) and I want to be laying the groundwork for 3D world features I’ll want in the future like translation/rotation/scale of objects and parenting.

One of the first things I need to address is my lack of math library. This post will explore a basic useful math library for a 3D game engine. Future posts will build upon this math library core to add support for 3D vector math, matrices, quaternions, and more!

Why Do We Need a Math Library?

C++ has built-in support for many mathematical operators using both integer and floating point numbers. With int, float, and double, we can add, subtract, divide, and multiply with ease.

The C++ Standard Library also provides many useful math functions - things like min, max, sine, cosine, tangent, absolute value, round, ceiling, floor, etc.

However, we still need our own math library! Our math library will provide a number of features that C++ alone does not provide:

  • A reliable way to check equality of floating-point numbers.
  • Wrappers around C++ Standard Library functions and additional convenience functions.
  • Classes representing 3D math and geometrical objects, like Vectors, Matrices and others

This post will focus on the first two points - we’ll discuss Vectors, Matrices, and other math concepts in dedicated posts.

You may be able to find a free or open source C++ math library on Github, and using that could save you a lot of time at this point. But again, we want to learn, so we’ll write it ourselves.

Testing the Math Library

As you write a math library, it’ll be extremely helpful to have some way to verify whether your code is correct or not.

If you write a lot of code to render a 3D object, but it doesn’t show up on screen, how do you know where the problem is? At this early stage in development, with very little visual feedback and few debug tools, it can be difficult to troubleshoot such issues. Problems can arise due to bad data, incorrect usage of graphics APIs, or incorrect implementations of math functions.

One thing we can do to rule out math bugs is write some unit tests to verify that our math functions and classes are working correctly.

Finding and evaluating a unit testing framework could be a whole blog post itself! After a bit of research, I decided on a unit testing framework called Catch. Catch is very simple to integrate and run, and it’s cross-platform and cross-IDE. The entire library is contained in a single header!

For example, we could write a simple math unit test like this:

// File MathTests.cpp
#include "catch.hh"
#include "Math.h"

TEST_CASE("Absolute value works")
{
    REQUIRE(Math::Abs(-5.0f) == 5.0f);
}

Using this structure, we can write simple to extremely complex tests to exercise our implementations of various math functions. Math libraries are one instance where unit tests are extremely well suited: a math function usually has one or more inputs and an output. Unit tests are extremely good for verifying that all inputs produce the expected outputs.

I keep my tests isolated from other source code files by putting them in their own Tests directory. In Xcode, I also created a separate “Target” for running tests. This allows me to choose between the “run my game” target and the “run my tests” target. This also allows me to only include my “test” .cpp files in the “test” target.

Reliable Floating-Point Number Comparison

Comparing numbers is supposed to be easy, and computers are supposed to be good at it. But not so fast! When numbers have fractional parts, you encounter some unexpected challenges:

  • Floating-point numbers are stored in 32-bits, and those bits may not be able to accurately represent some fractional numbers. For example 0.2 can’t be represented exactly as a floating point number!
  • Performing many floating-point math operations may result in rounding error, since intermediate or final values may not be represented exactly.
  • Numbers that are equal may not be exactly equal due to differences in precision. For example, is 0.24583923 equal to 0.245839230000001? They are very close, but a computer comparing the two numbers would say that they are not equal.

https://0.30000000000000004.com highlights a fun example in a variety of programming languages where a simple floating-point calculation yields unanticipated results.

Here’s another example:

TEST_CASE("Floating-point comparison works")
{
	float f1 = 4.7f;
	float f2 = 3.14f;
	float result = f1 / f2;
	REQUIRE(result == 1.4968152866f);
}

If you perform 4.7 / 3.14 in a calculator, you’ll likely get a result at least close to the number we are checking against. But it might be slightly different! The above test will probably FAIL on your machine. In my compiler, the resulting digits are correct, but the calculated result is less precise (1.4968152f vs. 1.4968152866f).

Direct comparison of floating-point numbers in this way is dangerous, and it actually generates a warning or error in some languages. The warning is warranted - this is likely to produce frustrating buggy behavior.

Think of it as your computer being a bit too pedantic; you say “if this variable equals 0, run this code,” but your computer says “ahhhh, oooo, so close - but 0.0000000000001 does not equal 0; sorry!”

You're not wrong...

The common solution to this problem is to avoid performing exact-equality checks between floating-point numbers. Instead, we can check whether two values are “close enough” to be considered equal. In the above example, surely those two floating-point numbers are close enough to be considered equal? It depends on your problem domain, but for video games, it’s likely fine.

So, we can write a function that does this for us!

// Math.h
#pragma once
#include <cmath>

namespace Math
{
    // Dictates that floating-point numbers within 0.000001 units are equal to one another.
    static const float kEpsilon = 1.0e-6f;

    inline bool IsZero(float val)
    {
        return (std::fabsf(val) < kEpsilon);
    }

    inline bool AreEqual(float a, float b)
    {
        return IsZero(a - b);
    }
}

First, we define a value, kEpsilon, which specifies our choice about how close two numbers need to be for us to consider them equal. In this case, I’m saying that if two numbers are within 0.000001 of one another, they are considered to be the same number for equality check purposes. (Why kEpsilon? The k is an indicator for a “constant”, and Epsilon is the conventional name given to such a value.)

Next, we create a function called IsZero, which determines whether a floating-point value is “close enough” to be considered zero. Given our current kEpsilon value, 0.00001f is not zero, but 0.000000001f would be.

Finally, we can define a function, AreEqual that determines whether two float numbers are close enough to be considered equal. By subtracting the two numbers, we get the difference between the two. If that difference is close enough to zero (aka, less than kEpsilon), we say they are equal.

I’ve created a file called Math.h, which contains a namespace Math, to hold general-purpose math functions. Inside, I have a number of inline functions. Note that the inline keyword is required to have a function definition in the header file like this when the functions aren’t in a class.

I can update my floating-point comparison test, which now works as expected:

TEST_CASE("Floating-point comparison works")
{
	float f1 = 4.7f;
	float f2 = 3.14f;
	float result = f1 / f2;
	REQUIRE(Math::AreEqual(result, 1.4968152866f));
}

C++ Math Wrappers

In addition to these floating-point comparison functions, I also added a number of “wrapper” functions in the Math.h header. These wrapper functions simply call the corresponding function in the C++ Standard Library.

inline float Floor(float val)
{
    return std::floor(val);
}

inline float Ceil(float val)
{
    return std::ceil(val);
}

inline float Round(float val)
{
    return std::round(val);
}

// Etc.

Why do this?

For one, let’s suppose I have sprinkled my engine code with hundreds of calls to std::floor, but I then realize there’s some issue with that function, or I want to start using a more efficient implementation. Or maybe I just want to output a log each time that function is called while I’m debugging an issue. It would be quite tedious to augment or replace all my calls to that function! By creating simple wrappers around these functions, I can easily replace or modify the implementation without having to modify other spots in my code.

A classic example of this is “inverse square root.” Taking square roots is fairly expensive compared to other math functions, but there was a time in the 90s when it was TOO expensive. Quake 3 famously used an optimized version of inverse square root. Such wrapper functions make it easy for us to tweak, modify, or completely change our math code in one place. You could start by using a simple 1.0f / std::sqrt(x), but if you find you need faster code, you could switch out the implementation for a faster one.

Since these functions are so small (for now) and marked as inline, they will likely be inlined by the compiler. This means that wrapping these functions shouldn’t add any performance overhead.

My Math namespace currently has these C++ Standard Library wrappers:

  • Sqrt (std::sqrt)
  • Pow (std::pow)
  • Mod (std::fmod)
  • Sin (std::sinf)
  • Cos (std::cosf)
  • Tan (std::tanf)
  • Asin (std::asinf)
  • Acos (std::acosf)
  • Atan (std::atanf)
  • Atan2 (std::atan2)
  • Floor (std::floor)
  • Ceil (std::ceil)
  • Round (std::round)
  • Min (std::min)
  • Max (std::max)
  • Min (std::fmin)
  • Max (std::fmax)
  • Abs (std::abs)

C++ Custom Math Functions

In addition to the wrapper functions, you sometimes need to define your own custom functions. The C++ Standard Library has a lot of useful math utilities, but it doesn’t have them all.

Game engines often need to deal with rotations in both radians and degrees. We can define some useful constants that’ll make that a bit easier in the future:

  • kPi (~3.14f; equates to 180 degrees)
  • k2Pi (~6.28f; equates to 360 degrees)
  • kPiOver2 (~1.57f; equates to 90 degrees)
  • kPiOver4 (~0.79f; equates to 45 degrees)
  • ToDegrees (converts radians to degrees)
  • ToRadians (converts degrees to radians)

Additionally, there are variations on Standard Library functions that we may want to define for convenience or performance:

  • InvSqrt
  • PowBase2 (assumes base 2 for more efficient implementation)
  • FloorToInt
  • CeilToInt
  • RoundToInt
  • Clamp

Finally, you may find that you need completely new math functions, which you can also add here:

  • Lerp (linear interpolation function)

Over time, I’ll no doubt add more wrapper and convenience functions to Math. You can find the current iteration here.

Conclusion

The math library is an important, albeit not particularly glamorous addition to a game engine. Since C++ does not provide any out-of-the-box floating-point math comparison utilities, it’s easy to overlook the issue and get into trouble later. Additionally, planning ahead with wrapper functions keeps our math logic organized, centralized, and easy to modify.

Though we’ve only created a single Math namespace, our math library will grow over time to include many standalone classes as well. We have only scratched the surface!

Some of the math functions added here will be useful as we implement foundational 3D math classes in upcoming posts: Vectors and Matrices.

comments powered by Disqus