A week ago, I started exploring 3D graphics.
Last summer, I made a 2D collision simulator with OpenGL, but this time, I wanted to use another library. I wanted my projects to utilize native graphics APIs, like Metal on MacOS, Direct3D on Windows, and Vulkan on Linux.
The bgfx library was perfect for this. It created abstractions that translated to each of those rendering backends, allowing me to leverage the Metal API with code that worked on other platforms.
The first thing I had to configure was a build system. A build system transforms the code you write into executable binaries that you can run.
Every time I got into graphics programming in the past, the guides always pushed some kind of IDE. Most commonly, with OpenGL, it was Microsoft Visual Studio. It involved downloading prebuilt binaries, setting up file paths in the linker, and .sln files. That way, building was as simple as pressing a button. This article outlines how you can build your projects without relying on those tools.
To truly understand how build systems work, it's necessary to go up through the layers of abstraction.
Compilation
At the bottom level, there are compilers. Compilers can take a source code file and generate machine code in the form of an executable. For example, read this C file.
#include <stdio.h>
int main() {
printf("Hello, world!\n");
return 0;
}To actually get the message to show up on the terminal, the C file has to be translated into machine code. With gcc, one of the most popular compilers, you'd run the following command:
gcc main.c -o mainThis command triggers the compilation of main.c, but more accurately it's described as a pipeline. The first thing in that pipeline is preprocessing. The lines with a # at the beginning are preprocessor statements and are read first. Things like #include <stdio.h> get expanded, macros get replaced in the code, and preprocessor conditionals are handled.
Once preprocessing finishes, the compiler does its work. In a nutshell, it parses code, checks types, optimizes, and converts everything to assembly language. After this step, you're left with architecture-specific assembly code.
Assembly then must be turned into raw binary instructions. This process gives us an object file like main.o. It's not executable yet, because it doesn't know where the code for printf is, just that it needs printf.
Finally, the last step is linking. During linking, we need the definitions of external symbols like printf or knowledge of where to find the necessary definitions. There are two types of linking: static and dynamic. In static linking, we copy machine code definitions of used symbols into the program's binary and patch the object file's instructions to reflect where we can find those definitions.
Dynamic linking is similar, but instead of inserting the location of the definitions directly inside the binary, the linker stores load commands, which are essentially strings containing the location of the definitions. Symbols like printf get a stub which tells where exactly in the program the symbol is used. When the OS runs a dynamically linked executable, the loader reads load commands, fetches the binary definitions, reads the stubs, and updates the program so that future calls go directly to the external library's code.
These pipelines have many ways to differentiate themselves from the default. For example, with gcc, you could use flags like -std=c17 to specify which C standard should be used in compilation, -l<lib> to link against an external library, or even -static to force static linking. If you frequently are editing your source code, it is error-prone and inconvenient to type out the gcc commands manually. Additionally, if your project has numerous files, you're wasting time compiling source code that hasn't changed.
Build Systems
This is where build systems come in. make is the most popular build system, and it was created to solve this pain point. It can define what flags to use when compiling and automatically tracks what source code files changed so that your computer only recompiles those. Configuration is done in a Makefile, which looks something like this:
default: main.c
gcc -std=c17 main.c -o main -static
clean:
rm -f mainWhen you run the make command by itself, it runs the first command defined in your Makefile. The file name on the same line as default: is the dependency, so if main.c hasn't changed since the last build, make doesn't compile that specific file again. Here, we've defined the default command to be:
gcc -std=c17 main.c -o main -staticThis runs every time I type make and the build system detects a change in the main.c.
The clean section simply removes the executable and saves me from having to write the remove command every time.
But this won't suffice when our projects need to be cross-platform, grow too large to manually enter every source dependency, or need to support optional features and different builds like debug and release.
Build System Generators
This is where the highest layer of abstraction, build system generators come in.
Build system generators generate Makefiles that are impractical to write by hand. The industry standard build system generator is named CMake, and you can configure it with CMakeLists.txt.
In this example, consider the following file tree:
└── project/
├── CMakeLists.txt
├── README.md
├── LICENSE
├── .gitignore
├── src/
│ ├── main.c
│ ├── utils.c
│ ├── io.c
│ └── linked_list.c
└── include/
├── utils.h
├── io.h
└── linked_list.hWe need to ensure that the project is built with:
- Compiled with C17 standard
- Set only C files as dependencies
- Cross-platform
Here's how it's done in CMake configuration:
cmake_minimum_required(VERSION 3.10)
project(MyProjectName)
set(CMAKE_C_STANDARD 17)
set(CMAKE_C_STANDARD_REQUIRED ON)
include_directories(${PROJECT_SOURCE_DIR}/include)
file(GLOB SOURCES
"${PROJECT_SOURCE_DIR}/src/*.c"
)
add_executable(${PROJECT_NAME} ${SOURCES})
if(NOT WIN32)
target_link_libraries(${PROJECT_NAME} PRIVATE m)
endif() The set() commands force our build system to use the C17 standard. In the build system configuration it generates, it might add a flag like -std=c17 to the gcc command.
include_directories() shows the build system where the headers are. That way, if I write
#include "utils.h"inside main.c, the compiler downstream will know exactly what file to find its definition.
file(GLOB SOURCES) sets the ${SOURCES} variable to any file in the src/ folder that ends with a .c file extension. This variable is targeted by the build system and all those sources will become dependencies.
Finally, CMake has the capability to easily detect what OS the program is being built on. In this example, I've made it so that on Windows, the math library named m isn't explicitly linked since Windows compilers do it automatically. However, Unix compilers need the explicit -lm flag to include the math library.
Once we run CMake, the Makefiles are automatically generated with the operating system in mind, saving us a lot of work.
Thanks for reading this article. This is my first technical one, and it was a great way to test out my codeblock styling. If you took an introductory C course but used a prebuilt development environment and had no idea what was underneath, I hope this was illuminating.
That's all for now. Until next time, I am out.