A journey of a thousand miles starts with a single step. Most of the time, it doesn’t matter which direction that step takes you — only that you take it. In this case, a reasonable first step might include articulating a goal, then getting something trivial working toward that end.

With that in mind, my first goal is to learn everything I can about graphics programming. Why start with graphics? It’s probably obvious, but before things can interact with their environment and other things, things need to exist in the first place. Not to mention, visual demos tend to be the most compelling, which I find to be motivating.

To lay out a rough framework for my plans, these are the features I think are essential to a graphics engine for games:

  • Models, meshes, and textures
  • Camera system
  • Lighting
  • Animation

Some nice-to-haves include:

  • Particle effects
  • Post processing

And, most importantly, it has to be fast.

I’ll be following this excellent book by Joey de Vries, at least until I know enough to branch out on my own. By default, I’ll open source all my code until I have a good reason not to. The book uses C++, so I’ll do the same.

I’m going to be careful not to duplicate content from the book. Instead, I’ll explore surrounding concepts more deeply, document personal thoughts, and demo my progress.


C++ project setup isn’t the most exciting topic, but it is the first challenge I ran into. I have very little experience with C++, but I quickly noticed that the “right” way to do something is often unclear. The community is strongly opinionated (this is an understatement), but sentiments change over time and there isn’t always consensus around modern best practices. Therefore, my guiding principle became “do whatever works, address problems as they arise.”

I poked through a few existing C++ projects for reference, and here’s what I ended up with initially:

├── src
│   ├── # source files
├── include
│   ├── # dependencies
├── build
│   ├── # ignored in source control, cmake output
├── CMakeLists.txt # build file

CMake

C++ projects generally utilize CMake, which generates a platform-specific Makefile based on configuration in CMakeLists.txt. CMake supports nested configurations, but I started with this minimal configuration, specifying a flat list of files:

cmake_minimum_required(VERSION 3.25.0)
set (CMAKE_CXX_STANDARD 17) # Use C++17

project(Grafix
    VERSION 1.0
    DESCRIPTION "Graphics rendering engine"
    LANGUAGES CXX C) # Support C and C++ source files

# Look for linked libs in system lib path
link_directories(/usr/local/lib)

# Look for files in system include path as well as local include path
include_directories(/usr/local/include ./include)

add_executable(grafix
  src/main.cpp
  # Enumerate source files (.c, .cpp) here
)

# MacOS build configuration
if (APPLE)
    target_link_libraries(grafix
        "-framework Cocoa"
        "-framework IOKit")
endif()

# Link glfw3 library
target_link_libraries(grafix glfw3)

Then, to build:

cd /path/to/repo
cmake -S . -B build # Only run this once to generate build files
cd build
make

Dependencies

Aside from the language itself, dependency management is the biggest barrier to building software in C++. Without package management tooling or official standards around library installation and versioning, it can feel overwhelming to a newcomer.

There are a few formulations of C++ libraries that I’ve come across: statically linked libraries and header-only libraries. After evaluating pros and cons, I developed some imperfect but workable guidelines.

Statically Linked Libraries

Build linked libraries from source and install them in /usr/local . This is supported through the link_directories and include_directories in CMakeLists.txt. Linked libraries are then included via the target_link_libraries directive.

For example, I installed GLFW following these steps:

git clone https://github.com/glfw/glfw.git
cd glfw
cmake -S . -B build
cd build

# Installs header files and static library into /usr/local/include/GLFW/
# and /usr/local/lib/
sudo make install

# Don't forget to add to CMakeLists.txt as well!

This method is inherently cross-platform since build artifacts are produced directly from source. And, there’s no additional bloat to the repository because we don’t need to check in third party code.

However, there are some major drawbacks that I might weight more heavily if this were more than a personal project. Building one library might only take a few seconds, but as the project expands, environment setup and upgrades could become an expensive operation. Not to mention that I am relying on the locally installed build toolchain to properly compile the library.

If I were to rework my approach, it would look something like this:

  • Maintain an install script to download and unzip the versioned archive from Github releases. If a library doesn’t host precompiled binaries, this script could build from source instead.
  • Copy the header files to some user-owned location, e.g. ~/include. This way, we don’t need elevated permissions just to install or upgrade a library, and project dependencies are isolated from previously installed software.
  • Find the library file specific to the local processor architecture, and copy it into an adjacent folder, e.g. ~/lib.
  • Include these directories in CMakeLists.txt in place of /usr/local.

Header-Only Libraries

Check header-only libraries into source control under ./include. For example, to include stb_image, a popular header-only image loading library, simply copy it to ./include/stb/stb_image.h and check it into source control. Any source files that may be required to include the header go into an adjacent path under ./src, e.g. ./src/stb/stb_image.cpp.

A nice property of this approach is that no installation step is necessary — these libraries can be linked immediately after the repository is cloned. This obviates the need for hosted artifacts, additional downloads, versioning, or installation scripts.

However, this comes at the cost of repository size and build time. These seem like reasonable tradeoffs because a) header-only libraries tend to be small, and b) precompiled headers are an option if necessary.