VKDriven #8 - Skybox
An HDR skybox, and why I stopped short of image based lighting
October 11, 2026
The plan for today was image based lighting (IBL), which on the surface seems very similar to environment mapping which we previously did here. I only got as far as the first step, the skybox, so that’s what we’ll be covering today.
Skybox
We need to first setup the skybox, in this case we use an .hdr file in order to have a larger colour range. There’s not much to say other than the fact that we load the HDR file and then display it behind everything.
The way we do that is by first covering our entire screen via a triangle (this is how you do post processing for example).
// One triangle that covers the whole screen, placed on the far plane (depth 0 with reverse-Z),
// so the depth test only lets it through where the scene drew nothing.
[shader("vertex")]
VSOutput main(uint vertexID : SV_VulkanVertexID)
{
float2 uv = float2((vertexID << 1) & 2, vertexID & 2);
VSOutput output;
output.NDC = uv * 2.0 - 1.0;
output.Pos = float4(output.NDC, 0.0, 1.0);
return output;
} Now you’ll notice we also set the depth to 0, there are a few things we do to make sure it renders in the background of the scene.
- z=0 (as shown above), which is the far plane since we use reverse-Z.
- The
GREATER_OR_EQUALdepth test. The depth buffer is cleared to 0, so the sky only passes where nothing else was drawn. - We draw it after the scene.
We then need to turn each sky pixel into a direction in the world, then look up what the environment image shows in that direction.
// Longitude around the Y axis becomes u, latitude from straight up (v = 0) to straight down (v = 1) becomes v.
float2 equirectUV(float3 direction)
{
return float2(atan2(direction.z, direction.x) / (2.0 * PI) + 0.5, acos(clamp(direction.y, -1.0, 1.0)) / PI);
}
[shader("fragment")]
float4 main(VSOutput input)
{
FrameData* frame = constants.frame;
// Undo the projection to get the view-space ray through this pixel, then rotate it into world space.
// The view matrix's rotation is orthonormal, so mul(v, M) (which multiplies by the transpose) inverts it.
float3 viewDirection = float3(input.NDC.x / frame->projection[0][0], input.NDC.y / frame->projection[1][1], -1.0);
float3 worldDirection = normalize(mul(viewDirection, (float3x3)frame->view));
// Mip 0 explicitly: at the u = 0/1 seam the UV derivatives jump, which would otherwise pick a tiny mip
// and draw a visible line down the sky.
float3 radiance = textures[frame->environmentTextureIndex].SampleLevel(equirectUV(worldDirection), 0.0).rgb;
return float4(radiance, 1.0);
}
In our previous skybox/cubemap blogs we rendered it as a full on cube (mapping each side of the skybox texture to a different side of the cube). In this case, it’s just a full screen triangle where we instead figure out based on the direction of the camera, what part of the texture we should be showing.
Stopping here
I decided to stop at the skybox rather than go through the rest of IBL (irradiance maps, prefiltered environment maps and a BRDF lookup table). It’s a lot of extra work for a fairly subtle difference in a scene like Sponza, and nothing coming up next depends on it, so I’m moving on to other things instead.