I E

Two weeks of graphics programming

I want to move my career in the direction of computer graphics, so lately I’ve been working on a real-time renderer using Vulkan. I won’t make the code public until I consider it portfolio-worthy, but I will publish updates on my progress. Here are the first 14 entries of the offline log I kept.

Entry 0: Hello, world!

2026-10-04

I think it’d be interesting to keep a record of my thoughts, challenges, and what I’ve learned.

I did the Vulkan tutorial 7.5 years ago (https://github.com/LightAndLight/vulkan-hello-triangle) and I remember having to write an exhausting amount of code to get something on the screen. This time around I can still see that it’ll be a solid chunk of code, but I’m less intimidated by it. I think my previous rounds of WebGPU practise (https://blog.ielliott.io/2022-project-review#wgpu-mandelbrot, https://blog.ielliott.io/2023-project-review#3d-graphics-fundamentals) left behind a rough mental model of what’s going on, which has made the Vulkan API feel more approachable. Because of that (and I suppose this is evidence of the confidence I’ve gained) I’m not really following a Vulkan tutorial to get this project off the ground. Let’s see how far I can get with the Vulkan API docs and my prior GPU experience.

What I built

What I learned

Next steps

References

Entry 1: Validation layers and swapchain

2026-10-05

I ran into more trouble than last time, but I’m still feeling pretty comfortable. Very low WTFs/m. Curious about how I’ll go with the graphics pipeline.

The search experience on Vulkan Reference Pages is pretty bad, and I didn’t like that I needed a web browser to read the reference, so I used Claude Opus 5.5 to add a manpage Nix derivation to Vulkan-Docs. This let me read the Vulkan reference using man in my Nix dev shell. This was satisfying for a while, but now I have a couple of gripes:

So I’ll probably switch back to a HTML reference at some point (unless there’s a good way to do hypertext in the terminal). But it won’t be the official Vulkan reference site.

What I built

What I learned

Next steps

Entry 2: Graphics pipeline setup

2026-10-06

Today was a bit harder. The Vulkan graphics pipeline has a lot of settings. I felt like a pilot in a movie, doing an epic startup sequence in the aircraft’s cockpit, flicking switches and pressing buttons, but more buttons and switches keep appearing, and I’m starting to sweat with exertion, and is the aircraft going to take off yet or what?

It was a lot of typing, but it’s mostly over now. Why don’t I just generate the boilerplate with large language model? I intend for this to be an intense learning experience, so I can’t afford to take shortcuts. I havent’ got any colours on the screen yet, but I’m closer than I was at the start of the day.

What I built

What I learned

Next steps

Entry 3: Clearing the framebuffer

2026-10-07

What I built

What I learned

Next steps

Entry 4: Shading

2026-10-08

I got shaders running today. I think I’ve made the through the worst parts?

I spent the rest of my time trying to get into the “GPU-driven rendering” mindset. It feels like the right way to approach Vulkan is to develop mechanical sympathy for GPUs and use that to guide my implementation. I found two resource recently that inspired this view: “It’s not about the API” by Mason Remaley and “No graphics API” by Sebastian Aaltonen. Treating the GPU as just another computational substrate, as Mason suggests at around 24:30, makes the graphics API feel more approachable. It emphasises the fact that there are organising principles behind the API (how GPUs actually work), and the more I understand those principles, the easier it will be for me to find the relevant parts of a graphics API and wire them together.

In the spirit of GPU-driven rendering, I plan to only use indirect draws in this project. After I got my quad shading, I wanted to draw a cube in perspective. I got stuck on how to provide the cube’s transformation matrix to the vertex shader. The naive approach is to use a uniform, but that only works for 1 object. For many objects I’d then I’d have to update/rebind (not sure what the right term is) the uniform and send a draw call for each object. That doesn’t work for me because part of the point of using vkCmdDrawIndirect is to batch the draw calls. I want to do an indirect draw with drawCount set to the number of objects, and have the vertex shader take an array of drawCount model transformation matrices which is indexed by the “draw number” the vertex shader is run in. I think I can do this by putting the matrices in a Shader Storage Buffer Object (SSBO) and indexing that buffer with gl_DrawID. We’ll see.

What I built

What I learned

Next steps

References

Entry 5: More quads

2026-10-09

I’ve rendered two quads in perspective:

Two grey squares on a bright green background. The squares are drawn in perspective, the camera tilted down from above, so they look trapezoidal.

One quad is in front, and one behind. The camera is looking slightly down at them, which is why they’re stretched at the top.

My gl_DrawID trick from last entry worked; I had vkCmdDrawIndirect issue two draw calls against the same vertex buffer, and each draw call pulled a different transformation matrix from a storage buffer. I think it would be more correct to do this with instancing, though, seeing as it’s the same repeated.

What I built

Next steps

Entry 6: Loading a model

2026-10-10

Finally, something decent on the screen! The only surprise was how well things worked on my first try. It was pretty straightforward to adapt my quad-rendering code to use the indices, positions, and normals provided by the GLB file.

What I built

What I learned

Next steps

References

Entry 7: Texturing

2026-10-11

It’s starting to look cool :)

What I built

Next steps

References

Entry 8: PBR materials

2026-10-12

I made a first attempt at physically-based materials today. Here’s Damaged Helmet with a directional light from above:

It doesn’t look very cool. I think the problem is that there’s no ambient light. The directional light illuminates the top of the model, so vertical surfaces reflect no light. If I rotate the model so that its side is facing up a bit, we can see that the shading is working:

Another consequence of the lack of ambient light is that the metallic surfaces (such as the visor) barely read. I guess this is what the lighting situation would be if the model was in outer space with just the sun and no stars.

After I did this, I spent a lot of time learning about radiometry for computer graphics (see references) to understand how I should approach lighting beyond my simple directional light.

What I built

Next steps

References

Entry 9: Ambient diffuse

2026-10-13

Today I added ambient diffuse lighting.

I basically did a whole lot of calculus to figure out that I just needed to add in a factor of the base colour for dielectric materials. After simplifying the rendering equation for the diffuse component of my dielectric BRDF, I was left with a multiplication by 1 - [an integral over the hemisphere, parameterised by dot(view vector, surface normal)]. I tried to approximate the integral with quadrature and when I plotted it for various values of dot(view vector, surface normal) I saw that it was small (0.01 - 0.03) over all angles, so I decided to leave it out.

Next, I want to add the ambient specular term. More integral simplification leads to the split-sum approximation (see references). In the case of ambient light it’s actually exact. There are two involved integrals to compute, parameterised over roughness and dot(view vector, surface normal). The split-sum approach precomputes the integral for various parameter values and stores them in a lookup table texture for use in the shader. I tried doing this with quadrature but the resulting texture seems off. I guess I should try using it just for fun. Apparently Monte Carlo integration with importance sampling is the right way to evaluate this integral. I find importance sampling less intuitive than quadrature, so I’ll spend a bit of time studing Monte Carlo integration to try and gain enough knowledge to implement the LUT generator myself.

Next steps

References

Entry 10: Ambient specular

2026-10-14

I’ve implemented an ambient specular term following the approach of Unreal Engine 4 (see references). I had to learn about numerical integration, Monte Carlo methods, importance sampling, and a little bit of vector calculus. My brain hurts! But it’s worth it:

Importance sampling a GGX BDRF for ambient specular radiance

fr(i‾,o‾,n‾)=F(i‾,h‾)G(i‾,o‾,h‾)D(h‾)4(i‾⋅n‾)(o‾⋅n‾) f_r(\bar{i}, \bar{o}, \bar{n}) = \frac {F(\bar{i}, \bar{h}) G(\bar{i}, \bar{o}, \bar{h}) D(\bar{h})} {4(\bar{i} \cdot \bar{n})(\bar{o} \cdot \bar{n})}

where h‾=i‾+o‾|i‾+o‾|\bar{h} = \frac{\bar{i} + \bar{o}}{|\bar{i} + \bar{o}|} is the halfway vector between i‾\bar{i} and o‾\bar{o}. frf_r is implicitly parameterised over a roughness parameter.

The goal is solve the following integral

∫ΩLi(i‾)fr(i‾,o‾,n‾)(i‾⋅n‾)dωi \int_\Omega L_i(\bar{i})f_r(\bar{i}, \bar{o}, \bar{n})(\bar{i} \cdot \bar{n}) d\omega_i

at a particular roughness and viewing angle i‾⋅n‾\bar{i} \cdot \bar{n}.

I want to supply ambient light, which means constant direction-independent radiance:

Li(i‾)=Ambient L_i(\bar{i}) = \text{Ambient}

The integral simplifies to

Ambient∫Ωfr(i‾,o‾,n‾)(i‾⋅n‾)dωi \text{Ambient} \int_\Omega f_r(\bar{i}, \bar{o}, \bar{n})(\bar{i} \cdot \bar{n}) d\omega_i

Inline frf_r and the i‾⋅n‾\bar{i} \cdot \bar{n} terms cancel

Ambient∫ΩF(i‾,h‾)G(i‾,o‾,h‾)D(h‾)4(o‾⋅n‾)dωi \text{Ambient} \int_\Omega \frac {F(\bar{i}, \bar{h}) G(\bar{i}, \bar{o}, \bar{h}) D(\bar{h})} {4(\bar{o} \cdot \bar{n})} d\omega_i

I’m using Schlick’s approximation for FF

F(i‾,h‾)=F0+(1−F0)(1−(i‾⋅h‾))5=F0+(1−(i‾⋅h‾))5−F0(1−(i‾⋅h‾))5)=F0−F0(1−(i‾⋅h‾))5)+(1−(i‾⋅h‾))5=F0(1−(1−(i‾⋅h‾))5)+(1−(i‾⋅h‾))5 \begin{aligned} F(\bar{i}, \bar{h}) & = F_0 + (1 - F_0)(1 - (\bar{i} \cdot \bar{h}))^5 \\ & = F_0 + (1 - (\bar{i} \cdot \bar{h}))^5 - F_0(1 - (\bar{i} \cdot \bar{h}))^5) \\ & = F_0 - F_0(1 - (\bar{i} \cdot \bar{h}))^5) + (1 - (\bar{i} \cdot \bar{h}))^5 \\ & = F_0(1 - (1 - (\bar{i} \cdot \bar{h}))^5) + (1 - (\bar{i} \cdot \bar{h}))^5 \end{aligned}

Which splits the integral into two parts

Ambient(F0∫Ω(1−(1−(i‾⋅h‾))5)G(i‾,o‾,h‾)D(h‾)4(o‾⋅n‾)dωi+∫Ω(1−(i‾⋅h‾))5G(i‾,o‾,h‾)D(h‾)4(o‾⋅n‾)dωi) \text{Ambient} \lparen F_0 \int_\Omega \frac {(1 - (1 - (\bar{i} \cdot \bar{h}))^5) G(\bar{i}, \bar{o}, \bar{h}) D(\bar{h})} {4(\bar{o} \cdot \bar{n})} d\omega_i + \int_\Omega \frac {(1 - (\bar{i} \cdot \bar{h}))^5 G(\bar{i}, \bar{o}, \bar{h}) D(\bar{h})} {4(\bar{o} \cdot \bar{n})} d\omega_i \rparen

When I try to estimate these integrals with Monte Carlo integration I get poor results for small roughness values. The estimates have very high variance. My intuitive guess for this is that at low roughness, the microfacet normals converge on the surface normal, so the radiance will only be propagated from directions near the reflection of v‾\bar{v} around n‾\bar{n}, of which there are very few.

The typical solution is to sample from the hemisphere around h‾\bar{h} using a distribution based on DD.

These two integrals have the general shape

∫Ωstuff(i‾,o‾,h‾)4(o‾⋅n‾)D(h‾)dωi \int_\Omega \frac {\text{stuff}(\bar{i}, \bar{o}, \bar{h})} {4(\bar{o} \cdot \bar{n})} D(\bar{h}) d\omega_i

Monte Carlo integration says that

1N∑iNf(x‾i)≈∫Ωf(x‾)p(x‾)dωx \frac{1}{N} \sum_i^N f(\bar{x}_i) \approx \int_\Omega f(\bar{x}) p(\bar{x}) d\omega_x

when x‾i\bar{x}_i are sampled according to the PDF pp.

Our integral can be rearranged into this form. D(h‾)(n‾⋅h‾)D(\bar{h})(\bar{n} \cdot \bar{h}) is a PDF, so

∫Ωstuff(i‾,o‾,h‾)4(o‾⋅n‾)(n‾⋅h‾)D(h‾)(n‾⋅h‾)dωi \int_\Omega \frac {\text{stuff}(\bar{i}, \bar{o}, \bar{h})} {4(\bar{o} \cdot \bar{n})(\bar{n} \cdot \bar{h})} D(\bar{h})(\bar{n} \cdot \bar{h}) d\omega_i

Now we have to change the integral from solid angles around i‾\bar{i} to solid angles around h‾\bar{h}.

We have i‾=2(o‾⋅h‾)h‾−o‾\bar{i} = 2(\bar{o} \cdot \bar{h})\bar{h} - \bar{o} (derived from the definition of h‾\bar{h}) and dωi=4(o‾⋅h‾)dωhd\omega_i = 4(\bar{o} \cdot \bar{h}) d\omega_h (which is required for the change of variables, but I don’t understand how to derive it yet) (EDIT: the “Roughness Using Microfacet Theory” section in PBRT has a good explanation of this quantity]).

This gives

∫Ωstuff(i‾,o‾,h‾)4(o‾⋅n‾)(n‾⋅h‾)D(h‾)(n‾⋅h‾)4(o‾⋅h‾)dωh=∫Ω(o‾⋅h‾)stuff(i‾,o‾,h‾)(o‾⋅n‾)(n‾⋅h‾)D(h‾)(n‾⋅h‾)dωh \begin{aligned} & \int_\Omega \frac {\text{stuff}(\bar{i}, \bar{o}, \bar{h})} {4(\bar{o} \cdot \bar{n})(\bar{n} \cdot \bar{h})} D(\bar{h})(\bar{n} \cdot \bar{h}) 4(\bar{o} \cdot \bar{h})d\omega_h \\ = & \int_\Omega \frac { (\bar{o} \cdot \bar{h}) \text{stuff}(\bar{i}, \bar{o}, \bar{h}) } {(\bar{o} \cdot \bar{n})(\bar{n} \cdot \bar{h})} D(\bar{h})(\bar{n} \cdot \bar{h}) d\omega_h \end{aligned}

where i‾\bar{i} is now defined in terms of o‾\bar{o} and h‾\bar{h}.

Walter et al. (2007) give equations for sampling h‾\bar{h} from a particular D(h‾)(h‾⋅n‾)D(\bar{h})(\bar{h} \cdot \bar{n}). If we use those, then

1N∑kN(o‾⋅hk‾)stuff(i‾,o‾,hk‾)(o‾⋅n‾)(n‾⋅hk‾)≈∫Ω(o‾⋅h‾)stuff(i‾,o‾,h‾)(o‾⋅n‾)(n‾⋅h‾)D(h‾)(n‾⋅h‾)dωh \frac{1}{N} \sum_k^N \frac { (\bar{o} \cdot \bar{h_k}) \text{stuff}(\bar{i}, \bar{o}, \bar{h_k}) } {(\bar{o} \cdot \bar{n})(\bar{n} \cdot \bar{h_k})} \approx \int_\Omega \frac { (\bar{o} \cdot \bar{h}) \text{stuff}(\bar{i}, \bar{o}, \bar{h}) } {(\bar{o} \cdot \bar{n})(\bar{n} \cdot \bar{h})} D(\bar{h})(\bar{n} \cdot \bar{h}) d\omega_h

Next steps

References

Entry 11: Playing around

2026-10-15

I spent most of my time learning more about vector calculus and Jacobian matrics and their determinants in an attempt to understand the dωi=4(o‾⋅h‾)dωhd\omega_i = 4(\bar{o} \cdot \bar{h}) d\omega_h that came up yesterday. I tried to apply what I learned but I couldn’t get close to the answer. Then I found a good explanation in the “Roughness Using Microfacet Theory” section in PBRT. Here’s how I understand it now:

Consider the unit hemisphere with o‾\bar{o} as the pole. i‾\bar{i} and h‾\bar{h} are also directions in this hemisphere, with spherical coordinates (θi,ϕi)(\theta_i, \phi_i) and (θh,ϕh)(\theta_h, \phi_h) respectively. h‾\bar{h} is halfway between o‾\bar{o} and i‾\bar{i}, but because o‾\bar{o} is the pole, h‾\bar{h} and i‾\bar{i} share the same azimuthal angle. So we have θi=2θh\theta_i = 2\theta_h and ϕi=ϕh\phi_i = \phi_h.

The Jacobian we’re looking for is

dωhdωi=sin(θh)dθhdϕhsin(θi)dθidϕi \frac{d\omega_h}{d\omega_i} = \frac {\text{sin}(\theta_h)d\theta_{h}d\phi_h} {\text{sin}(\theta_i)d\theta_{i}d\phi_i}

ϕi=ϕh\phi_i = \phi_h implies dϕi=dϕhd\phi_i = d\phi_h, and θi=2θh\theta_i = 2\theta_h implies dϕi=2dϕhd\phi_i = 2d\phi_h, giving

dωhdωi=sin(θh)dθhdϕhsin(2θh)2dθhdϕh=sin(θh)2sin(2θh)=sin(θh)4sin(θh)cos(θh)=14cos(θh)=14(o‾⋅h‾) \begin{aligned} \frac{d\omega_h}{d\omega_i} & = \frac {\text{sin}(\theta_h)d\theta_{h}d\phi_h} {\text{sin}(2\theta_h)2d\theta_{h}d\phi_h} \\ & = \frac {\text{sin}(\theta_h)} {2\text{sin}(2\theta_h)} \\ & = \frac {\text{sin}(\theta_h)} {4\text{sin}(\theta_h)\text{cos}(\theta_h)} \\ & = \frac{1}{4\text{cos}(\theta_h)} \\ & = \frac{1}{4(\bar{o} \cdot \bar{h})} \end{aligned}

At the end of the day I tried loading a different model, and the program crashed because I forgot that my code is specialised to the Damaged Helmet model. So general GLTF loading definitely next on the list.

What I built

Next steps

Entry 12: Generic model loading

2026-10-16

I’m generalising my GLTF loader to work with arbitrary models. One problem I’ve run into is binding the material textures at the right time. A mesh has many primitives, and each primitive has a material. The material can reference textures for properties like base colour and metallic-roughness, which are used in the fragment shader. Until now I had set all this up at the start of the program, because I was using a model with a single mesh that contained a single primitive. But now that I want to work with an arbitrary number of primitives, I need to allow the material properties to change during a render pass.

I think the traditional way to handle this is to have a separate descriptor set layout for material properties, and create a descriptor set with that layout per material. To render, loop over all primitives and bind the material descriptor set before drawing the primitive. But I don’t like it, because it’s not compatible with draw-indirect.

A draw-indirect friendly version needs use gl_DrawID to lookup the material texture for the fragment. For data like per-object transforms, I just index into a storage buffer, but I can’t do that for samplers or textures. There are descriptor arrays, but they seem like a hack. A dynamic texture atlas looks like the right way to do it, but it’s a lot of work.

For now, I’ve just gone with the traditional way to get the model loader working. Later on I can figure out how to make it draw-indirect-compatible.

Next steps

Entry 13: Loading a second model

2026-10-17

I’ve generalised model loading enough to load a second model: Car Concept. The most obvious thing mine lacks is support for transparent materials.

What I learned

Next steps

References

← Local Hoogle Newest