Victor Roeck
Back to projects
ProjectRendering · Vulkan

Vulkan rasterizer

A small but complete rendering engine built from scratch in Vulkan, exposing the modern, explicit graphics API that an older driver keeps hidden.
TypePersonal project
APIVulkan
LanguageC++ / GLSL
FieldReal-time rendering

Overview

Coming mostly from high-level engines and OpenGL, this project trades their convenience for Vulkan's total, explicit control over the GPU. It is a from-scratch renderer that grew into a small engine, one that manages every instance, device, queue, command buffer, synchronisation primitive and memory allocation by hand rather than leaving them to a driver.

Why build a renderer in Vulkan?

Modern explicit APIs (Vulkan, Direct3D 12, Metal) hand responsibilities that an OpenGL driver used to absorb back to the developer. After years on higher-level engines and OpenGL, this machinery stays invisible until you implement it yourself. I started from vulkan-tutorial.com[1] and Brendan Galea's video series[2], then kept pushing the project well past where they stop.

Explicit by design

In OpenGL a global state machine and the driver quietly make most decisions for you. Vulkan removes that magic: you describe almost everything ahead of time (devices, queues, render passes, pipelines, memory and synchronisation), which makes the GPU's real workflow visible and the cost of each operation impossible to ignore.

More than a triangle: the engine around it

Rather than cram everything into one main file, I structured the project like a real engine, borrowing the layered architecture popularised by TheCherno's Hazel[3]: an Application owns a stack of Layers, an event system dispatches window, keyboard and mouse events, and a platform layer wraps GLFW[4] for windowing and input.

A backend-agnostic renderer

All the rendering code sits behind an abstraction. Generic Renderer, Shader, Material, Model and Texture interfaces expose a clean API, while a concrete Vulkan backend implements them. That separation forces a clear split between concepts that are fundamental to rendering and those that are Vulkan-specific, and it would let a second backend slot in later without touching the application code.

Tooling from day one

I wired in a logging system, scope-based profiling instrumentation that times every function, and a Dear ImGui[5] layer with docking enabled. Having proper diagnostics and an in-app UI early made the rest of the engine far more pleasant to build and debug.

Bootstrapping Vulkan by hand

A Device class brings the API up from nothing: it creates the instance, enables validation layers in debug builds with a debug messenger that routes Vulkan's diagnostics straight into my logger, creates the window surface, and then picks a physical device.

Choosing a GPU

A device is only considered 'suitable' if it exposes the queue families I need (graphics and presentation), supports the required swapchain extension with adequate surface formats and present modes, and offers sampler anisotropy. From a suitable device I create a logical device, retrieve the graphics and present queues, and allocate a command pool.

Swapchain, render passes & depth

Presentation is handled by a Swapchain that selects a surface format and present mode, creates the images and image views, allocates a depth buffer, and builds the render pass and framebuffers. When the window is resized it recreates the whole chain, asserting that the image and depth formats stayed compatible.

The graphics pipeline & self-describing shaders

Vulkan graphics pipelines are immutable and fully specified up front. My pipeline configuration describes input assembly, the rasteriser (fill mode, back-face culling), multisampling at the device's maximum usable sample count, colour blending, and depth testing (depth test and write, compare-less). Viewport and scissor are left dynamic so a single pipeline survives window resizes, and shaders are pre-compiled to SPIR-V and loaded as shader modules.

Shaders that describe themselves

Instead of hard-coding which uniforms, textures and push constants each shader expects, the engine reflects the SPIR-V at load time using SPIRV-Reflect[6] and SPIRV-Cross[7]. It discovers every uniform buffer, sampled image, push-constant block and stage input/output (their names, types, descriptor sets and bindings) and builds a property table from them.That makes the material system data-driven: I attach a shader to a material, then set uniforms and textures by name, and the engine already knows how to lay out the descriptors and push constants. Setting up a textured, lit object ends up reading like this:
// Load a SPIR-V shader and build a material from it
auto shader = Shader::CreateShaderFromCompiledFiles({
    "texture_test.vert.spv", "texture_test.frag.spv" });

auto material = Material::CreateMatFromShader(shader);

// Set uniforms and textures by name - reflection knows the rest
material->AddUniform(0, "ModelMatrix",  ConvertToBytes(modelMatrix),  true);
material->AddUniform(1, "NormalMatrix", ConvertToBytes(normalMatrix), true);
material->AddTexture(0, "AlbedoMap",
    Texture::CreateFromFile("uv_checker.png"));

material->CreatePipeline(vertexArray);
Data-driven materials: the engine reflects the shader, so uniforms and textures are set by name.

Memory, descriptors & synchronisation

Staging buffers

Vertex and index data is uploaded the GPU-friendly way: written into a host-visible staging buffer, then copied into fast device-local memory with a one-time command buffer. Per-frame data (the camera matrices and lights) lives in host-visible uniform buffers that stay mapped and are flushed each frame.

Descriptors & push constants

A global descriptor set holds the scene uniform buffer, visible to every stage; a second set binds material textures as combined image samplers. Small, per-object data such as the model and normal matrices travels as push constants, the cheapest way to feed frequently-changing values to the GPU. Builder-style DescriptorPool, DescriptorSetLayout and DescriptorWriter helpers wrap the otherwise very verbose Vulkan boilerplate.

Frames in flight

The renderer keeps several frames in flight, double-buffering command buffers and synchronising the CPU and GPU with semaphores and fences so the CPU can record the next frame while the GPU is still working on the current one, without ever touching a resource that is still in use. Coordinating that by hand makes the real cost of synchronisation impossible to ignore.

Lighting, models & textures

OBJ meshes are loaded with tinyobjloader[8] and de-duplicated (identical vertices are merged and indexed) through a configurable vertex layout of position, colour, normal and UV. Textures are loaded from disk, uploaded through staging buffers with explicit image-layout transitions, and sampled with mipmapping and anisotropic filtering.

Blinn-Phong point lights

Shading is a classic Blinn-Phong[9] model evaluated per fragment: an ambient term plus a diffuse and specular contribution from each point light, with inverse-square attenuation. All of the scene lights and the camera are passed in a single uniform buffer:
for (int i = 0; i < ubo.numLights; i++)
{
    PointLight light = ubo.lights[i];
    vec3  dirToLight  = light.position - fragWorldPos;
    float attenuation = 1.0 / dot(dirToLight, dirToLight);
    dirToLight = normalize(dirToLight);

    vec3  intensity = light.color.rgb * light.color.a * attenuation;

    // Diffuse
    float cosAngle = max(dot(surfaceNormal, dirToLight), 0.0);
    diffuseLight  += intensity * cosAngle;

    // Specular (half-vector)
    vec3  halfDir  = normalize(dirToLight + viewDir);
    float spec     = pow(clamp(dot(surfaceNormal, halfDir), 0.0, 1.0), 64.0);
    specularLight += intensity * spec;
}
Per-fragment Blinn-Phong accumulation over every point light.

An editor-style viewport

Rather than drawing straight to the screen, the scene is rendered into an off-screen framebuffer with its own colour and depth attachments. That colour image is then exposed to Dear ImGui as a texture and displayed inside a dockable 'Viewport' panel that resizes the render target to match its size. Around it sit live controls (a light-colour picker and a texture inspector), turning the renderer into a small editor and exercising the framebuffer-to-ImGui integration end to end.
The engine in action: a textured model lit by orbiting point lights whose colours are edited live, rendered off-screen and shown in the ImGui viewport.

What I took away

The payoff is a concrete understanding of the modern graphics stack: what an OpenGL driver quietly does on your behalf, why explicit synchronisation matters, and how descriptors, pipelines and memory fit together. Wrapping it all behind a clean engine abstraction turned out to teach nearly as much about API design as Vulkan itself did.
© 2026 Victor Roeck. All rights reserved.