C++ PBR Renderer
A native OpenGL 3.3 renderer written in C++17 featuring a
physically-based shading model, live shader hot-reload, an ImGui
control panel, and a fly camera — all wired together with GLFW, GLAD, and
GLM via CMake's FetchContent. No engine, no framework; just the
GPU pipeline built from scratch.
Preview
The Idea
The goal was to understand what actually happens between a shader file on disk and a lit pixel on screen. Reaching for a ready-made engine would have hidden all the interesting parts — buffer uploads, vertex attribute layout, uniform binding, projection math — behind abstractions. Building it from scratch in C++ forced every layer into the open and gave me a solid mental model of the OpenGL pipeline to carry into future graphics work.
Physically-Based Shading
The renderer uses a Cook-Torrance BRDF driven by four material parameters: albedo, metallic, roughness, and ambient occlusion. Four point lights are placed in a 2×2 grid around the scene and uploaded as uniform arrays each frame. All parameters are editable live through the ImGui panel without restarting.
struct PBRParams {
glm::vec3 albedo = glm::vec3(0.5f, 0.0f, 0.0f);
float metallic = 0.5f;
float roughness = 0.5f;
float ao = 1.0f;
} pbrParams;
// Uploaded every frame
glUniform3fv(glGetUniformLocation(pbrShader, "albedo"), 1, glm::value_ptr(pbrParams.albedo));
glUniform1f (glGetUniformLocation(pbrShader, "metallic"), pbrParams.metallic);
glUniform1f (glGetUniformLocation(pbrShader, "roughness"), pbrParams.roughness);
glUniform1f (glGetUniformLocation(pbrShader, "ao"), pbrParams.ao);
Shader Hot-Reload
Shaders are loaded from disk at runtime and polled every 0.5 seconds using
std::filesystem::last_write_time. When a change is detected, the
new source is compiled and linked into a replacement program object. On
success the old program is deleted and the new one takes over immediately —
no restart required. On failure, the previous shader keeps running
and the error log is displayed in red inside the ImGui panel, so mistakes are
visible without breaking the viewport.
bool tryReloadShader(GLuint& currentShader,
const char* vertexPath,
const char* fragmentPath)
{
std::string errorLog;
GLuint newShader = createShaderProgram(vertexPath, fragmentPath, errorLog);
if (newShader != 0) {
glDeleteProgram(currentShader); // discard old program
currentShader = newShader;
shaderErrorLog = "";
return true;
} else {
shaderErrorLog = errorLog; // display error, keep old shader running
return false;
}
}
The check is rate-limited to once every 0.5 seconds so filesystem polling does not show up in the frame budget.
Shader Compilation Pipeline
Source is read from disk with a plain std::ifstream, compiled
via glCompileShader, and linked into a program object.
Compilation and linking errors are captured separately into a string and
surfaced to the UI rather than just printed to stdout, which made iteration
much faster during PBR shader development.
GLuint compileShader(const char* source, GLenum type, std::string& errorLog)
{
GLuint shader = glCreateShader(type);
glShaderSource(shader, 1, &source, NULL);
glCompileShader(shader);
int success;
char infoLog[1024];
glGetShaderiv(shader, GL_COMPILE_STATUS, &success);
if (!success) {
glGetShaderInfoLog(shader, 1024, NULL, infoLog);
errorLog = infoLog;
return 0; // caller keeps the old program running
}
return shader;
}
Procedural Geometry
All three primitives — sphere, cube, and plane — are generated in code and uploaded once into static VAOs on first use. The sphere uses a UV sphere construction: positions, normals, and texture coordinates are built from spherical coordinates over a 64×64 segment grid, then interleaved into a single VBO and indexed with a triangle-strip winding that alternates direction each row to avoid a stitch seam.
// UV sphere — positions double as normals on a unit sphere
for (unsigned int y = 0; y <= Y_SEGMENTS; ++y) {
for (unsigned int x = 0; x <= X_SEGMENTS; ++x) {
float xSeg = (float)x / X_SEGMENTS;
float ySeg = (float)y / Y_SEGMENTS;
float xPos = std::cos(xSeg * 2.0f * PI) * std::sin(ySeg * PI);
float yPos = std::cos(ySeg * PI);
float zPos = std::sin(xSeg * 2.0f * PI) * std::sin(ySeg * PI);
positions.push_back(glm::vec3(xPos, yPos, zPos));
normals.push_back (glm::vec3(xPos, yPos, zPos)); // unit sphere: pos == normal
uv.push_back (glm::vec2(xSeg, ySeg));
}
}
The vertex layout — position (3 floats), normal (3 floats), UV (2 floats) — is shared across all three primitives, so a single shader handles every object without rebinding attribute pointers.
Fly Camera & Projection Modes
The camera is a standard yaw-pitch fly camera. Right-click-drag captures
the cursor and routes mouse deltas into yaw and pitch, clamped to ±89°
to avoid gimbal lock at the poles. WASD moves along the view vector,
Q/E strafe vertically, and scroll zooms by translating along
cameraFront.
The renderer supports both perspective and orthographic projections, toggled from the panel. In orthographic mode, scroll adjusts the half-height of the ortho frustum rather than moving the camera forward, which keeps object sizes consistent while zooming.
glm::mat4 projection;
float aspect = (float)SCR_WIDTH / (float)SCR_HEIGHT;
if (useOrthographic) {
float orthoWidth = orthoSize * aspect;
projection = glm::ortho(-orthoWidth, orthoWidth,
-orthoSize, orthoSize, 0.1f, 100.0f);
} else {
projection = glm::perspective(glm::radians(45.0f), aspect, 0.1f, 100.0f);
}
glm::mat4 view = glm::lookAt(cameraPos, cameraPos + cameraFront, cameraUp);
Pressing 1, 2, or 3 snaps the camera to face the sphere, cube, or plane respectively at the configured focus distance. Input is debounced with a static bool per key so a held press only triggers once.
ImGui Control Panel
An ImGui window overlays the scene and exposes every tweakable in the
renderer: material sliders, object visibility, per-object position and
scale, camera mode, shader paths, and the error log. Mouse events are
gated through io.WantCaptureMouse so clicking ImGui widgets
does not accidentally rotate the camera.
void mouse_button_callback(GLFWwindow* window, int button, int action, int mods)
{
ImGuiIO& io = ImGui::GetIO();
if (io.WantCaptureMouse) return; // ImGui owns this click
if (button == GLFW_MOUSE_BUTTON_RIGHT) {
if (action == GLFW_PRESS) {
cameraEnabled = true;
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
firstMouse = true;
} else {
cameraEnabled = false;
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_NORMAL);
}
}
}
Build System
CMake's FetchContent pulls GLFW, GLM, and ImGui from GitHub
at configure time, so the only manual dependency is GLAD — generated once
from the web configurator for OpenGL 3.3 Core and committed to the repo.
ImGui's backend source files (imgui_impl_glfw.cpp,
imgui_impl_opengl3.cpp) are compiled directly into the
executable rather than a separate library, keeping the CMakeLists simple
and avoiding any linker surprises.
# Shaders copied to the build directory automatically —
# hot-reload works out of the box after cmake --build
file(COPY ${CMAKE_SOURCE_DIR}/shaders DESTINATION ${CMAKE_BINARY_DIR})