C/C++ Embedded Systems & Algorithms
This is a real-time renderer I built from scratch in raw Vulkan for my Graphics Programming 2 course at Howest DAE. No wrapper libraries and no OpenGL-style hand-holding. The goal was to get properly hands-on with modern, low-level graphics programming: managing the GPU pipeline, memory, and synchronization myself, validation layer errors and all.
At its core, the renderer uses a deferred pipeline: a depth prepass first, followed by a G-Buffer pass that writes out albedo, normals, and roughness/metalness data, then a full-screen lighting pass that reads that G-Buffer to resolve lighting independently of scene complexity. Materials are shaded using a Cook-Torrance BRDF driven by physically based light units — point lights in candela, directional light in lux — with diffuse image-based lighting adding ambient contribution from precomputed HDR environment maps. Both directional and point lights cast shadows, using a standard shadow map for the former and cube shadow maps for the latter, softened with PCF filtering.
The whole pipeline runs in HDR from the G-Buffer through lighting accumulation, only converting down to LDR at the very end — exposure is calculated from physical camera settings (aperture, shutter speed, ISO) rather than an arbitrary slider, and the final tone mapping uses a fitted ACES approximation.
One-Time Passes
RenderToCubeMap pass. It renders a cube six times, once per face, using precomputed capture view matrices (one looking down each axis) and a shared 90° FOV projection, sampling the HDRI texture in the fragment shader to project it onto each face. Because the destination has multiple mip levels, this render repeats per mip, halving the target resolution each pass.RenderToCubeMap function is reused, but this time the skybox cube map (not the original HDRI) is the source, and the output is a smaller 64×64, single-mip cube map. The fragment shader used here (LightProbeRenderer) performs the irradiance convolution, integrating incoming light across the hemisphere for each direction, producing a blurred cube map representing diffuse irradiance, sampled at runtime for diffuse IBL instead of being recalculated every frame.Repeating Passes
gl_VertexIndex, rather than traditional vertex attribute bindings.gl_VertexIndex, no real vertex input) where everything else comes together. World position is reconstructed per-pixel from the depth buffer and camera matrices, then the shader loops over every point and directional light, applying full Cook-Torrance PBR shading (GGX distribution, Smith geometry term, Fresnel-Schlick) with light color derived from a Kelvin color-temperature conversion rather than flat RGB. Shadows are sampled per-light: directional shadows use a 3×3 PCF kernel against a hardware shadow sampler, point light shadows use a distance comparison against the cube shadow map. Diffuse IBL is layered on top by sampling the light probe along the surface normal. Pixels with no geometry (depth at the far plane) skip lighting entirely and sample the skybox directly instead.








Measured with RenderDoc, per-pass GPU timings break down as follows:
One-Time Costs (paid once at scene load, not per frame)
| Pass | Time |
|---|---|
| Model Texture Loading | 110 ms |
| Rendering Light Probe | 68 ms |
| Directional Shadow Map | 1.5 ms |
| Rendering into Skybox | 1.4 ms |
| Point Light Shadow Map | 1 ms |
Per-Frame Costs (paid every frame)
| Pass | Time |
|---|---|
| Depth Prepass | 0.1 ms |
| Lighting Pass | 0.1 ms |
| Geometry Pass | 0.08 ms |
| Post Processing | 0.007 ms |
The heaviest costs by far are one-time. Texture loading and light probe convolution alone account for the vast majority of total time spent, while every pass that actually runs every frame is comfortably sub-millisecond. That’s expected: shadows, the light probe, and the skybox are all static and baked once, so none of that cost repeats, and the deferred pipeline’s per-frame work stays cheap since lighting complexity is decoupled from geometry complexity.
Frame rate reflects that: ~2800 FPS in Release, ~750 FPS in Debug, captured on a laptop RTX 4070 rendering the standard glTF Sponza scene. The gap between Release and Debug builds comes from validation layers and unoptimized code running in Debug.