C++
Precomputing a Game Physics Engine's Constants with C++ constexpr
Introduction
Every game physics engine has constants it needs on every frame: gravitational acceleration, pi, radian conversion factors, collision radii for each object type, pre-computed trigonometric tables for rotation math. If those values are computed at runtime — recalculated each time the program starts, stored in variables initialized by function calls — the program pays a small but real cost, and the compiler has no opportunity to use them in further compile-time optimizations.
constexpr, introduced in C++11 and significantly expanded in C++14 and C++17, allows the compiler to evaluate expressions at compile time. A constexpr variable is evaluated when the program is compiled, not when it runs. A constexpr function can be called at compile time if its arguments are compile-time constants, and the result is baked directly into the generated code. The runtime sees only the final numbers — no initialization, no function call overhead, no missed optimization opportunities from values the compiler could not analyze because they appeared to be dynamic.
This tutorial builds the physics constant system for a 2D game engine from a set of runtime-initialized global variables to a fully compile-time-evaluated system. Each section introduces one constexpr capability and shows exactly what the compiler is able to do — and verify — as a result.
Background
constexpr tells the compiler that an expression can and should be evaluated at compile time. The key rules:
- A
constexprvariable must be initialized by a constant expression. Its value is available at compile time and can be used as a template argument, array size, or any context that requires a constant. - A
constexprfunction can be called at compile time when all arguments are constant expressions. It can also be called at runtime with non-constant arguments, in which case it behaves like a regular function. - A
constexprconstructor enables objects to be constructed at compile time. The resulting object is a literal type and can be used as aconstexprvariable. if constexpr(C++17) is a compile-time branch. Only the taken branch is compiled; the discarded branch does not need to be well-formed for the active template instantiation. This enables zero-cost platform or type dispatch.
const and constexpr are different. const means a variable cannot be modified after initialization; it does not require the initializer to be a compile-time constant. constexpr implies const and also requires a compile-time constant initializer.
Practical Scenario
A 2D game engine needs a physics constant module used by three subsystems: the collision detector, the particle system, and the rigid body solver. The module defines gravitational acceleration, conversion factors between degrees and radians, collision radii for each object class, and a 360-entry lookup table of precomputed sine values used by the particle system's rotation math.
Currently the module is a header with global variables initialized by runtime function calls. Every time the program starts, it computes pi, then derives the radian factor from pi, then computes all 360 sine values, then initializes the collision radius table. The initialization runs in under a millisecond — not a performance problem. But the values are not available at compile time, so the compiler cannot use them as template arguments for SIMD array sizes, cannot fold them into constant expressions in callers, and cannot generate the lookup table as a read-only data segment that the OS can memory-map and share across instances of the game.
The team also has a debug build that validates every constant at startup and a release build that skips validation entirely. Currently the debug check is guarded by a #ifdef preprocessor block — which the type system cannot verify and which compiles the validation code into the debug build with a separate code path. if constexpr on a template parameter replaces the preprocessor block with a type-safe, zero-overhead alternative.
The Problem
Create the initial constants file:
touch physics.cpp
Compile and run with:
g++ -std=c++17 -o physics physics.cpp && ./physics
#include <iostream>
#include <cmath>
#include <array>
#include <chrono>
// Runtime-initialized globals — computed at program startup, not at compile time
const double GRAVITY = 9.80665;
const double PI = std::acos(-1.0);
const double DEG_TO_RAD = PI / 180.0;
const double RAD_TO_DEG = 180.0 / PI;
// Collision radii initialized from a "load config" function
double PLAYER_RADIUS = 0.0;
double ENEMY_RADIUS = 0.0;
double PROJECTILE_RADIUS = 0.0;
void load_physics_config() {
PLAYER_RADIUS = 0.45;
ENEMY_RADIUS = 0.60;
PROJECTILE_RADIUS = 0.15;
}
// Lookup table computed at runtime
double SINE_TABLE[360];
void init_sine_table() {
for (int i = 0; i < 360; ++i) {
SINE_TABLE[i] = std::sin(i * DEG_TO_RAD);
}
}
int main() {
load_physics_config();
init_sine_table();
std::cout << "GRAVITY = " << GRAVITY << "\n";
std::cout << "PI = " << PI << "\n";
std::cout << "DEG_TO_RAD = " << DEG_TO_RAD << "\n";
std::cout << "Player radius = " << PLAYER_RADIUS << "\n";
std::cout << "sin(90 deg) from table = " << SINE_TABLE[90] << "\n";
}
GRAVITY = 9.80665
PI = 3.14159
DEG_TO_RAD = 0.0174533
Player radius = 0.45
sin(90 deg) from table = 1
None of these values are available at compile time. PLAYER_RADIUS and ENEMY_RADIUS cannot be used as template arguments or in static_assert. SINE_TABLE is a mutable global — it can be accidentally modified. The initialization functions must be called in the correct order, which is an implicit requirement that is not enforced anywhere. If main forgets to call load_physics_config, PLAYER_RADIUS is zero for the entire run.
constexpr Variables
A constexpr variable is initialized at compile time and can be used anywhere a constant expression is required: array sizes, template arguments, static_assert conditions, and case labels.
Replace the entire content of physics.cpp with the following:
#include <iostream>
#include <cmath>
constexpr double GRAVITY = 9.80665;
constexpr double PI = 3.14159265358979323846;
constexpr double DEG_TO_RAD = PI / 180.0;
constexpr double RAD_TO_DEG = 180.0 / PI;
constexpr double PLAYER_RADIUS = 0.45;
constexpr double ENEMY_RADIUS = 0.60;
constexpr double PROJECTILE_RADIUS = 0.15;
// Can be used as array size because it's a compile-time constant
constexpr int SINE_TABLE_SIZE = 360;
double RUNTIME_SINE[SINE_TABLE_SIZE]; // array size from constexpr
// constexpr enables static_assert
static_assert(GRAVITY > 0.0, "Gravity must be positive");
static_assert(PLAYER_RADIUS < ENEMY_RADIUS, "Enemy should be larger than player");
static_assert(DEG_TO_RAD > 0.0 && DEG_TO_RAD < 1.0, "DEG_TO_RAD sanity check");
int main() {
std::cout << "GRAVITY = " << GRAVITY << "\n";
std::cout << "PI = " << PI << "\n";
std::cout << "DEG_TO_RAD = " << DEG_TO_RAD << "\n";
std::cout << "Player radius = " << PLAYER_RADIUS << "\n";
std::cout << "Table size = " << SINE_TABLE_SIZE << "\n";
}
GRAVITY = 9.80665
PI = 3.14159
DEG_TO_RAD = 0.0174533
Player radius = 0.45
Table size = 360
static_assert runs at compile time — a violation is a compile error, not a runtime panic. SINE_TABLE_SIZE as a constexpr int can be used as an array size directly. Any future change that makes ENEMY_RADIUS smaller than PLAYER_RADIUS fails the build immediately.
The initialization order problem is gone — there are no initialization functions to call. static_assert turns invariant violations into build failures rather than runtime assertions. The values are genuinely available at compile time: they can be passed as template arguments or used in case labels without any change to the calling code.
constexpr Functions
A constexpr function can be evaluated at compile time when called with constexpr arguments. The same function also works at runtime with non-constant arguments.
Replace the entire content of physics.cpp with the following:
#include <iostream>
#include <cmath>
constexpr double PI = 3.14159265358979323846;
constexpr double DEG_TO_RAD = PI / 180.0;
constexpr double GRAVITY = 9.80665;
constexpr double to_radians(double degrees) {
return degrees * DEG_TO_RAD;
}
constexpr double to_degrees(double radians) {
return radians * (180.0 / PI);
}
// Compute the velocity needed to reach a given height under gravity
constexpr double launch_velocity(double height_m) {
return std::sqrt(2.0 * GRAVITY * height_m);
}
// Compile-time constants derived from constexpr functions
constexpr double ANGLE_45_RAD = to_radians(45.0);
constexpr double ANGLE_90_RAD = to_radians(90.0);
constexpr double JUMP_HEIGHT_M = 2.5;
constexpr double JUMP_VELOCITY = launch_velocity(JUMP_HEIGHT_M);
static_assert(ANGLE_90_RAD > ANGLE_45_RAD, "90 degrees must be larger than 45");
static_assert(JUMP_VELOCITY > 0.0, "Launch velocity must be positive");
int main() {
std::cout << "45 deg in radians: " << ANGLE_45_RAD << "\n";
std::cout << "90 deg in radians: " << ANGLE_90_RAD << "\n";
std::cout << "Jump velocity for " << JUMP_HEIGHT_M << "m: " << JUMP_VELOCITY << " m/s\n";
// Runtime use with a non-constant argument
double user_angle = 30.0;
std::cout << "Runtime: " << user_angle << " deg = " << to_radians(user_angle) << " rad\n";
}
45 deg in radians: 0.785398
90 deg in radians: 1.5708
Jump velocity for 2.5m: 7.00357 m/s
Runtime: 30 deg = 0.523599 rad
JUMP_VELOCITY is a compile-time constant derived by calling launch_velocity(JUMP_HEIGHT_M) — both arguments are constexpr, so the square root is computed by the compiler. The same launch_velocity function works at runtime when called with a variable argument.
Physics formulas expressed as constexpr functions are evaluated once at compile time and their results are available everywhere a constant is needed, including template arguments. The formula is written once in a readable form rather than pre-computed by hand into a magic number. Changing JUMP_HEIGHT_M automatically recomputes JUMP_VELOCITY at the next build.
Note: In C++11 and C++14, constexpr functions had strict restrictions — no local variables, no loops, no conditionals. C++14 relaxed these rules significantly. C++17 allows almost anything that is valid in a constexpr context, including most loops and conditionals.
constexpr Constructors and Literal Types
A struct with a constexpr constructor is a literal type — its instances can be constexpr variables, computed entirely at compile time.
Replace the entire content of physics.cpp with the following:
#include <iostream>
#include <cmath>
constexpr double PI = 3.14159265358979323846;
constexpr double DEG_TO_RAD = PI / 180.0;
struct Vec2 {
double x, y;
constexpr Vec2(double x, double y) : x(x), y(y) {}
constexpr Vec2 operator+(const Vec2& o) const { return {x + o.x, y + o.y}; }
constexpr Vec2 operator*(double s) const { return {x * s, y * s}; }
constexpr double length_sq() const { return x * x + y * y; }
};
struct CollisionShape {
Vec2 center;
double radius;
constexpr CollisionShape(Vec2 c, double r) : center(c), radius(r) {}
constexpr double area() const { return PI * radius * radius; }
};
// These objects are fully constructed at compile time
constexpr Vec2 SPAWN_POINT = {0.0, 0.0};
constexpr Vec2 EXIT_POINT = {100.0, 50.0};
constexpr Vec2 SPAWN_TO_EXIT = EXIT_POINT + SPAWN_POINT * (-1.0);
constexpr CollisionShape PLAYER_SHAPE = {SPAWN_POINT, 0.45};
constexpr CollisionShape ENEMY_SHAPE = {{20.0, 10.0}, 0.60};
static_assert(PLAYER_SHAPE.radius < ENEMY_SHAPE.radius, "enemy must be larger");
static_assert(PLAYER_SHAPE.area() > 0.0, "player area must be positive");
int main() {
std::cout << "Spawn point: (" << SPAWN_POINT.x << ", " << SPAWN_POINT.y << ")\n";
std::cout << "Exit point: (" << EXIT_POINT.x << ", " << EXIT_POINT.y << ")\n";
std::cout << "Player area: " << PLAYER_SHAPE.area() << "\n";
std::cout << "Enemy area: " << ENEMY_SHAPE.area() << "\n";
std::cout << "Vector to exit: (" << SPAWN_TO_EXIT.x << ", " << SPAWN_TO_EXIT.y << ")\n";
}
Spawn point: (0, 0)
Exit point: (100, 50)
Player area: 0.636173
Enemy area: 1.13097
Vector to exit: (100, 50)
Vec2 and CollisionShape are literal types because their constructors are constexpr. The compiler constructs all instances — including the vector arithmetic — at compile time. static_assert on PLAYER_SHAPE.area() is a compile-time assertion on a computed value of a constructed object.
Game objects' initial positions, shapes, and configuration are level data — they do not change between frames and are determined at design time. Expressing them as constexpr objects makes the level's geometry available to the compiler, enabling constant-folding in callers and eliminating runtime construction cost for data that is truly static.
if constexpr for Zero-Cost Debug Validation
if constexpr(condition) evaluates the condition at compile time and discards the untaken branch entirely. Unlike a regular if, the discarded branch does not need to compile for the active template instantiation. This replaces #ifdef guards for compile-time dispatch.
Replace the entire content of physics.cpp with the following:
#include <iostream>
#include <cmath>
#include <stdexcept>
constexpr double PI = 3.14159265358979323846;
constexpr double GRAVITY = 9.80665;
constexpr double DEG_TO_RAD = PI / 180.0;
constexpr double to_radians(double degrees) {
return degrees * DEG_TO_RAD;
}
constexpr double launch_velocity(double height_m) {
return std::sqrt(2.0 * GRAVITY * height_m);
}
template <bool Debug>
struct PhysicsConfig {
constexpr void validate_angle(double degrees) const {
if constexpr (Debug) {
if (degrees < -360.0 || degrees > 360.0) {
throw std::out_of_range("angle out of range: " + std::to_string(degrees));
}
std::cout << "[DEBUG] angle validated: " << degrees << " deg\n";
}
// In release builds this entire function compiles to nothing
}
constexpr void validate_velocity(double v) const {
if constexpr (Debug) {
if (v < 0.0) {
throw std::domain_error("negative velocity: " + std::to_string(v));
}
}
}
};
using DebugPhysics = PhysicsConfig<true>;
using ReleasePhysics = PhysicsConfig<false>;
int main() {
DebugPhysics debug_cfg;
ReleasePhysics release_cfg;
std::cout << "=== Debug build ===\n";
try {
debug_cfg.validate_angle(45.0);
debug_cfg.validate_angle(999.0); // will throw
} catch (const std::out_of_range& e) {
std::cout << "[ERROR] " << e.what() << "\n";
}
std::cout << "\n=== Release build ===\n";
release_cfg.validate_angle(999.0); // compiles to nothing — no check, no output
std::cout << "Release: validate_angle is a no-op\n";
constexpr double JUMP_VELOCITY = launch_velocity(2.5);
std::cout << "\nJump velocity (2.5m): " << JUMP_VELOCITY << " m/s\n";
}
=== Debug build ===
[DEBUG] angle validated: 45 deg
[ERROR] angle out of range: 999.000000
=== Release build ===
Release: validate_angle is a no-op
Jump velocity (2.5m): 7.00357 m/s
In ReleasePhysics, the entire if constexpr (Debug) block is discarded by the compiler — the function compiles to a single ret instruction with no branch, no string construction, and no bounds check. In DebugPhysics, the full validation runs. The same source code serves both configurations without preprocessor macros.
if constexpr is type-safe, scope-aware, and refactorable. Unlike #ifdef, the discarded branch is still parsed and must be syntactically valid. Renaming validate_angle renames it in both the debug and release paths simultaneously. The zero-cost guarantee is stronger than a #ifdef guard because the compiler actively removes the dead code rather than the preprocessor blindly deleting text.
Summary
The physics constant system built in this tutorial demonstrates constexpr at every level, from individual scalar values to computed objects and zero-cost runtime branching:
constexprvariables are evaluated at compile time and can be used as template arguments, array sizes,caselabels, andstatic_assertconditions — capabilities thatconstdoes not providestatic_assert(expr, "message")is a compile-time assertion; a failing condition is a build error, which turns invariant violations into instant feedback rather than runtime panicsconstexprfunctions can be called at compile time when all arguments are constant expressions, baking results into the generated code; the same function also works at runtime with non-constant arguments without any changes- A struct with a
constexprconstructor is a literal type whose instances can be fully constructed at compile time, including member function calls; changing the constructor or struct layout propagates automatically to all compile-time uses if constexpr(condition)discards the untaken branch entirely at compile time, producing zero-cost dispatch for debug-only validation, platform-specific code, and template specializations without preprocessor macros