Ascent
—Before this I had built a deferred renderer in Go: OpenGL 4.5, a single shadow map, physically based bloom, material batching, and PBR shading. It covered the fundamentals but was not a game and was not written in C++. This project was a test of how much of that knowledge I could carry over, and a first real attempt at C++ at the same time. The course required raw OpenGL or Vulkan, no frameworks, which suited what I wanted to do anyway. I chose OpenGL over Vulkan since I had only started learning Vulkan one semester earlier in the Fundamentals of Computer Graphics course, and being new to C++ at the same time was already challenge enough. I also wanted to build something genuinely good, and I knew that I could only do that in OpenGL.
For the Programmiertechniken für Visual Computing bachelor's course at TU Vienna (2024), the task was to build a real-time 3D game from scratch with a set of required rendering effects. I ended up going well past the minimum. We received a Sehr Gut and the project is listed on the course Hall of Fame.
The Game
The course is set on a mountain island. You fly through it wingsuit-style, threading checkpoint rings as fast as possible and racing against your own best time. Controls are minimal: mouse to look, Shift to boost. At full boost foreground objects like the goal rings streak past. Checkpoint propellers spin, some obstacles move, and there is a stack of physics boxes on the hillside that reacts to the player flying through it. The race timer and boost meter live in a HUD built with Nuklear. Finishing the course shows a performance graph alongside your previous best time.
Rendering
Scene Rendering and Material Batching
The scene is rendered with a very small number of draw calls by batching geometry by material. When the glTF file is loaded, meshes are split into sections
(primitives that share a material) and sorted so that all sections using the same material are contiguous in the vertex and element buffers. A draw command is
recorded for each section, and all commands sharing a material are grouped into a batch. The entire scene is then rendered with a single glMultiDrawElementsIndirect call per material, pulling draw parameters directly from a GPU-side buffer. Instancing is handled in the same pipeline:
per-object data such as the transformation matrix lives in a separate vertex buffer with an attribute divisor, so instanced objects cost nothing extra.
Modern OpenGL
The renderer uses a set of OpenGL 4.5 features that most tutorials never reach. Direct State Access lets you operate on objects directly by name rather than having to bind them first, which both eliminates redundant state changes and gives the API a cleaner, more object-oriented shape. Shaders are compiled as separate programs and linked into pipeline objects. Storage buffers use immutable storage, and vertex attributes use the separate attribute format API, which decouples the attribute layout description from the buffer binding and gives more flexibility in how data is arranged.
Physically Based Shading and Image-Based Lighting
Materials follow the PBR metallic-roughness model, with albedo, normal, roughness, metallic, and occlusion maps loaded from the glTF files. The shading
implementation draws on the Filament rendering engine's documentation, which covers the full
derivation. Indirect lighting comes from a precomputed environment map stored in a custom .iblenv format bundling diffuse irradiance and specular radiance,
compressed with LZ4. The split-sum approximation handles the specular integration. Having IBL from the start meant the scene looked physically grounded even before
any direct lights were added.

Depth Setup
The depth buffer uses a reversed-Z infinite projection: the far plane maps to 0 and the near plane to 1, with no explicit far clip. Reversing the mapping redistributes floating-point precision toward the distance rather than concentrating it near the camera, which made a noticeable difference to z-fighting on the large terrain.
Cascaded Shadow Maps
The sun is the only light source in the scene and uses cascaded shadow maps fitted to the view frustum. Filtering uses the 4-sample PCF technique from GPU Gems, combined with normal-offset biasing to avoid the disconnected-shadow artifact that constant depth bias introduces on steep surfaces. I later revisited this implementation for City Lights, where the cascade fitting and culling logic was substantially improved.
Ground Truth Ambient Occlusion
Ambient occlusion uses the GTAO algorithm, which takes a horizon-based approach and produces a closer approximation to ground-truth AO than SSAO at comparable sample counts. It was the most difficult effect to implement, largely because the reference paper assumes significant background knowledge and public implementations were scarce at the time. I later revisited and improved this for City Lights.

GPU Particle System
Particles are simulated entirely on the GPU using a compute shader, based on this reference. Emission rate, initial velocity, lifetime, size over life, and colour over life are all configurable and can be tweaked at runtime through the debug menu. The system is used for the checkpoint ring effects and the victory celebration when you finish the course.
Terrain and Water
The mountain uses hardware tessellation driven by a height map, with tessellation level computed per patch based on screen-space size. The terrain has a matching physics collider generated from the same height data. The water surface also uses tessellation combined with animated height maps to produce geometric waves. The wave normals are not interpolated, which gives the water a more abstract, stylised quality rather than a photorealistic one.
Bloom, Lens Flares, and Tonemapping
Physically based bloom uses the dual-filter pyramid approach from the CoD: Advanced Warfare presentation. I had implemented this in my earlier deferred renderer and reused the approach here and later in City Lights. Lens flares and light streaks are layered on top using John Chapman's pseudo lens flare method with Kawase-style streaks. Tonemapping uses AgX, which handles saturated highlights more gracefully than ACES or Reinhard. All post-process passes run as compute shaders rather than via a full-screen triangle.
Motion Blur
Framerate-independent motion blur is applied as a post-process pass, based on John Chapman's approach. It is disabled by default and can be turned on in the settings.
Volumetric Altitude Fog
Rather than simple distance-based fog, the implementation integrates density along the view ray while accounting for camera and fragment altitude. The scene is a mountain island surrounded by ocean, and the fog sits low over the water, thinning as the terrain rises. It gives the scene much more perceived depth than flat exponential fog would.
Physics and Audio
Collision detection and rigid body dynamics run through the Jolt Physics engine. Physics meshes are defined directly in Blender alongside the visual geometry, which kept the workflow unified but made the integration somewhat tricky to get right. The stack of boxes on the hillside is a fully simulated rigid body pile that reacts to the player. Audio is handled by SoLoud, which provides full 3D positional sound, used for in-world effects throughout the course.
Level Design
The level was built entirely in Blender, including all the modelling. Entities such as obstacles and checkpoints are placed and configured directly in the scene using Blender's custom properties feature, which exports cleanly into the glTF file and is read at load time. To lay out the track I wrote a set of Python scripts inside Blender for placing and adjusting the checkpoint sequence. Having Blender serve as the full editor for geometry, entities, and tooling made iteration fast.
Looking Back
I am happy with what was implemented. The rendering features held up and several of them, including the bloom, GTAO, and shadow maps, fed directly into City Lights in improved form. The main thing I would change is the C++ code itself: it was my first time writing C++ seriously and parts of it are quite odd in hindsight. The rendering and gameplay decisions I stand by.
Stack
- Language
- C++ (first project in the language)
- Graphics API
- OpenGL 4.5 (core profile)
- Shaders
- GLSL
- Scene loading
- glTF 2.0 via TinyGLTF, images via STB
- Physics
- Jolt Physics
- Math
- GLM
- UI
- Nuklear (HUD and menus), Dear ImGui (debug)
- Audio
- SoLoud (3D positional audio)
- Other
- Tweeny (UI animation), Tortellini (INI config), LZ4 (IBL decompression)