Skip to content

Yannick Ruijter

C/C++ Embedded Systems & Algorithms

Vulkan Deferred PBR Renderer

The Renderer

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.

normalss

The Rendering pass

One-Time Passes

  • Rendering HDRI into Skybox — An equirectangular HDR image is loaded as a source texture, then converted into a 1024×1024 cube map (5 mip levels) using a dedicated 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.
  • Creating Light Probe — The same 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.
  • Directional Light Shadow Mapping — The orthographic frustum is fit to the scene automatically when the light is added: the scene’s AABB corners are projected onto the light’s direction to find how far back the light needs to sit, then re-projected into the light’s view space to compute tight min/max bounds for the projection, keeping the shadow map resolution concentrated on the actual scene instead of wasted on empty space. Since lights and geometry are static, the shadow map only needs to be rendered once, at creation, rather than every frame.
  • Point Light Shadow Mapping — Each point light gets its own depth cube map (6 faces, one mip). For every face, a view matrix looks outward from the light’s position along one of the six axis directions, paired with a shared 90° perspective projection, and the shadow pass is recorded once per face. Six recordings total per light. As with the directional light, this only happens once at creation, since nothing that would invalidate it ever moves.

Repeating Passes

  • Depth Prepass — A minimal pass that writes only depth. The fragment shader samples the object’s albedo texture and alpha-tests it against a 0.95 threshold, discarding fragments that fail, handling cutout-style transparency, with no color output at all. Vertex data is pulled directly from a raw storage buffer indexed by gl_VertexIndex, rather than traditional vertex attribute bindings.
  • Geometry Pass — The vertex shader builds a full TBN (tangent-bitangent-normal) matrix per-vertex via a Gram-Schmidt process, enabling proper normal mapping in the fragment shader. That fragment shader writes to four G-Buffer targets at once: albedo, a normal-mapped shading normal, packed roughness/metalness, and a separate geometric (un-mapped) normal, the last of which exists specifically so the lighting pass can use the true surface normal for shadow bias, independent of the normal map used for shading. Like the depth prepass, it alpha-tests before writing.
  • Lighting Pass — A full-screen pass (the triangle generated procedurally from 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.
  • Post Processing — The HDR result is exposure-adjusted using the EV100 method, derived from physical camera settings (aperture, ISO, shutter speed). The color then passes through two chained tone mapping curves. Uncharted2 followed by ACES. A combination chosen deliberately after comparing results, since it produced a better-looking image than either curve applied alone.

Performance

Measured with RenderDoc, per-pass GPU timings break down as follows:

One-Time Costs (paid once at scene load, not per frame)

PassTime
Model Texture Loading110 ms
Rendering Light Probe68 ms
Directional Shadow Map1.5 ms
Rendering into Skybox1.4 ms
Point Light Shadow Map1 ms

Per-Frame Costs (paid every frame)

PassTime
Depth Prepass0.1 ms
Lighting Pass0.1 ms
Geometry Pass0.08 ms
Post Processing0.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.

screenshot 2026 06 13 190719