A small real-time renderer written from scratch in modern C++ on top of OpenGL 3.3 Core Profile. It imports polygonal 3D models through Assimp, uploads them to the GPU as indexed vertex buffers, and shades them with a per-fragment Phong lighting model. Window creation, the OpenGL context, and input are handled by GLFW; OpenGL entry points are resolved at runtime by GLAD.
The project grew out of a graphics programming course and is deliberately kept readable: no engine framework, no abstraction layers you have to fight, every draw call traceable from main.cpp to the GLSL source.
- Backpack model by Gert Gedik on Sketchfab
Model import
- Loads meshes through Assimp, so every format Assimp supports is fair game. The bundled sample models (Backpack, Nanosuit, Planet, Rock) are OBJ.
- Import post-processing: triangulation, smooth normal generation, UV flipping, and tangent/bitangent calculation.
- Recursive scene graph traversal - a model with many nodes and sub-meshes is flattened into a list of drawable meshes.
- Diffuse, specular, and normal maps are read from the material, with a per-model texture cache so a map shared by several meshes is uploaded to the GPU only once.
- Meshes of the same model may use different materials, so each mesh tells the shader which maps it actually binds. Untextured meshes fall back to a flat material colour.
Rendering
- Per-fragment Phong shading: ambient, Lambertian diffuse, and specular highlights with a shininess exponent of 32.
- Normal mapping via the tangent basis imported from Assimp, with Gram-Schmidt re-orthogonalisation of the interpolated tangent.
- Specular maps modulate the highlight per texel, so leather and metal on the same mesh reflect differently.
- Normals are transformed by the inverse-transpose of the model matrix, so non-uniform scaling does not break lighting.
- The projection aspect ratio is read back from the framebuffer, so resizing the window does not distort the image.
- A single point light orbits the scene on a sine path, which is what gives the showcase its moving highlight.
- Depth testing, mipmapped textures with trilinear minification, and a wireframe mode toggled at runtime via
glPolygonMode.
Camera and interaction
- Free-flying Euler-angle camera (yaw/pitch) with pitch clamped to +/-89 degrees to avoid gimbal flip.
- Frame-rate independent movement through delta-time scaling.
- Scroll-wheel zoom implemented as a field-of-view change, clamped to 1-45 degrees.
- The model can be rotated around its own pivot independently of the camera.
Engineering
- Ownership is expressed through the type system rather than by convention.
std::unique_ptrwith a custom deleter for the GLFW window, move-only RAII wrappers (GlResource.h) for every OpenGL handle, andstd::shared_ptrwhere a texture genuinely is shared between meshes. - Teardown order is explicit in
Engine::Finalize, because GPU handles have to be deleted while the context that owns them is still alive. - Every class has one job and holds only the state that job needs.
Materialis the shader and the surface colour, the object transform lives inTransform, the light inLight, and everything about turning them into pixels inRenderer. - A shared
IObjectinterface gives the subsystems with a real lifecycle (Engine,Viewport) the sameInitialize/Draw/Finalizecontract. Plain value types such asTransformandLightstay out of it rather than implementing methods they have no use for. - Centralised
ErrorHandlerwith a strongly typedMessageTypeenum, an error-code-to-message lookup table, ANSI-coloured console output, and automatic__FILE__/__LINE__capture at the call site. DataHandlerresolves resource paths relative to the running executable, so the viewer behaves the same when launched from an IDE or from a shell.
main.cpp
└── Application owns the engine, runs Initialize -> Run -> Finalize
└── Engine owns all subsystems, drives the frame loop
├── Viewport GLFW window, OpenGL context, GLAD loading, input, buffer swap
├── Camera view and projection matrices, Euler angles, movement and zoom
├── Transform the model's pivot, yaw and pitch, and the model matrix
├── Light position and colour of the point light, and its animated path
├── Renderer depth test, frame and per-object uniforms, issues the draw
├── Material shader program and surface colour
│ └── Shader loading from file, compilation, linking, uniform setters
└── Model Assimp import, texture loading
└── Mesh VAO/VBO/EBO, vertex attribute layout, draw call
One frame, in order:
Viewport::Update() delta time, poll input state, apply camera and model transforms
Light::Animate() move the point light along its sine path
Viewport::Draw() clear colour and depth buffers
Renderer::BeginFrame() collect what holds for this frame: view, projection, light
Renderer::Draw() bind the material, upload its uniforms, then Model::Draw()
Model::Draw() bind textures and VAOs, one glDrawElements per mesh
Viewport::LateDraw() swap buffers, poll events
Uniforms are written by whoever knows the value.
Material sets what describes the surface, Renderer sets what describes the frame (view, projection, camera position, light) and what describes the object (the model matrix), and Mesh binds its own texture units.
glUniform* only ever reaches the currently bound shader program, which is why BeginFrame merely collects the frame values and Renderer::Draw uploads them after the material has bound its shader.
The vertex format (MeshData.h) carries position, texture coordinates, normal, tangent, and bitangent, plus four bone indices with weights.
All seven attribute slots are bound; the bone data is groundwork for skeletal animation and is not consumed by the current shader.
| Input | Action |
|---|---|
| Mouse movement | Look around |
W A S D |
Move the camera forward, left, backward, right |
| Mouse wheel | Zoom in and out |
| Arrow keys | Rotate the model around its own axis |
1 |
Toggle wireframe mode |
R |
Reset the camera position |
ESC |
Close the application |
- A C++20 compiler
- CMake 3.20 or newer
- GLFW 3 and Assimp development packages
- A GPU and driver exposing OpenGL 3.3 Core Profile
GLAD, GLM, and stb_image are vendored under Libs/ and need no installation.
sudo apt install build-essential cmake libglfw3-dev libassimp-dev
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug
cmake --build build -j
./x64/Debug/ModelViewerThe executable deliberately lands in x64/<Config>/ to match the Visual Studio output layout, because resource paths are resolved relative to it.
Open ModelViewer.sln in Visual Studio 2022 and build the x64 configuration.
The prebuilt GLFW and Assimp binaries in Libs/x64/ are already wired into the project.
assimp-vc143-mtd.dll has to sit next to the executable.
The CMake build works on Windows as well if you would rather not use MSBuild.
Rider and CLion on Linux cannot open the .sln for a C++ project - open CMakeLists.txt as the project instead.
The build exports compile_commands.json, so clangd-based editors work out of the box.
A few things are still compile-time constants rather than runtime settings:
| What | Where |
|---|---|
| Model to display | modelPathAndFileName in Engine.h |
| Shader pair | vertexShaderFileName / fragmentShaderFileName in Engine.h |
| Window size, title, clear colour | constants in Engine.h |
| Light colour, position, and animated path | Light.h |
| Surface colour | objectColor in Material.h |
| Model start rotation and pivot | Transform.h |
| Near and far plane | Camera.h |
Two shader pairs ship with the project: LightModel* implements the full Phong model and is the default, ModelVertexShader/ModelFragmentShader is a minimal unlit pass that just samples the diffuse map.
Worth knowing before you file a bug:
- A single hardcoded point light, no shadowing, no post-processing.
- Ambient occlusion and roughness maps shipped with some sample models are ignored, because the lighting model has no slot for them.
- Exactly one model is loaded per run, chosen at compile time.
- The inverse-transpose normal matrix is computed per vertex in the shader rather than uploaded as a uniform.
- Immediate-mode UI (ImGui) for wireframe, camera speed, light, and material settings
- Runtime model loading and multiple simultaneous models
- More advanced lighting: multiple light types, Blinn-Phong or a PBR pass that can use the roughness and AO maps
- Skeletal animation using the bone attributes the vertex format already reserves
- Longer term: build a small puzzle game or simulation on top of the viewer
ModelViewer/ engine sources and GLSL shaders
Resources/
Shader/ GLSL vertex and fragment shaders
Models/ sample models and their textures
Libs/ vendored headers (GLAD, GLM, stb) and Windows binaries
Media/ showcase recordings
CMakeLists.txt cross-platform build
ModelViewer.sln Visual Studio build
- CMake - build system
- GLFW - windowing, context creation, input
- GLAD - OpenGL function loader
- Assimp - model import
- GLM - vector and matrix maths
- stb_image - texture decoding
- Learn OpenGL and real-time 3D rendering from the ground up, without an engine in the way
- Grow the viewer into a simple (puzzle) game or simulation
- Marcus Schaal (SAE Lecture: Graphics and Shader Programming)
- Joey de Vries for his excellent OpenGL tutorials @ LearnOpenGL
MIT - see LICENSE.
