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})