# Vulkan - Cube Mapping & Skyboxes
This has been a long time coming, figuring out how to implement cube mapping using vulkan. To say that this was trivial would be a slap in the face, nothing about vulkan is trivial, and I think that is what makes it very interesting.
Cube mapping surprised me, I expected there to be a fair amount of pages describing the process intuitively, but I was wrong. Sadly I couldn't find that many resources, which is why I decided to write a tutorial on how I implemented cube mapping.
> [!attention] Disclaimer
> Unlike most resources this implementation is written in rust. If you are implementing this in C/C++ or any other language, then you will have to perform that conversion yourself.
>
Just as a visualisation we are aiming to achieve the following, a 3D rendered scene with a cube map texture that surrounds the entire 3D environment. That is to say, we want to create a skybox.
![[Screenshot 2025-09-24 at 14.19.24.png]]
# Implementation
> [!attention] Assumptions
> Before we get going I am assuming that you are aware of what [skyboxes](https://learnopengl.com/Advanced-OpenGL/Cubemaps) are, and that you have completed all off the vulkan tutorials over at [vulkan-tutorial.com](https://vulkan-tutorial.com/Introduction).>
So how do I go about when I want to implement cube mapping in vulkan?
The core thing to remember is that a lot of the work that you're going to do has to do with how you read and handle textures. The pipeline and shader implementations are pretty straight forward.
Let's get started.
## 1. Texture Loading
Data, data, data, we cannot make bricks without clay! And we cannot implement cube maps without having textures.
I get most of my cube maps either from [humus.name](https://www.humus.name/index.php?page=Textures) or good ol' [space3d](https://tools.wwwtyro.net/space-3d/index.html#animationSpeed=1&fov=80&nebulae=true&pointStars=true&resolution=1024&seed=5bl222r86js0&stars=true&sun=true). For this implementation we are going to need 6 separate textures, one for every side of our "cube".
### 1.1 Loading our textures into memory
For loading images into memory there are many different libraries that you can use. I chose to use the crate called [image](https://crates.io/crates/image).
Because we might not know what size the texture is or how many channels the image uses it is a good idea to get this information from the textures itself. Remember that all textures in the cube map has to have the same size and number of channels. We also need to load them in order of: PosX, NegX, PosY, NegY, PosZ, NegZ. [This is a historic thing and tied to how Pixar's RenderMan engine handled cube maps](https://stackoverflow.com/a/33775530)?
So a code example would look something like this:
``` rust
let mut cube_map_texture: Vec<Rgba32FImage> = Vec::with_capacity(6);
let mut width: u32 = 0;
let mut height: u32 = 0;
let color_channels = 4;
let sides = 6;
let mut index: usize = 0;
for file in vulkan_config.cubemap.as_ref().expect("no cubemap array defined") {
let image = ImageReader::open(file)
.unwrap()
.decode()
.unwrap()
.into_rgba32f();
height = image.height();
width = image.width();
cube_map_texture.insert(index, image);
index += 1;
}
```
*NOTE: vulkan_config.cubemap.. is an array of texture paths*
Now, you should have each individual cube map texture loaded into a vector, that leads us to...
### 1.2 Mapping textures into a staging buffer
I expect that you've implemented some form of abstraction for creating staging buffers, and if that is the case then this part isn't too strange.
We simply have to create a staging buffer the size of all our textures, and then memcpy over the each texture into our buffer:
``` rust
let cube_map_size = size_of::<f32>() * width * height * color_channels * sides;
let layer_size = cubemap_size / 6;
let buffer = buffer::new(cube_map_size as DeviceSize, device, instance, &physical_device);
let mut i = 0;
let memory = map_memory!(device, staging_buffer, cubemap_size as DeviceSize);
for texture in cube_map_texture {
let raw_data = texture.into_raw();
let ptr = raw_data.as_ptr();
mem_cpy(
ptr,
// NOTE: increment the pointer by the size of the a single layer
memory.add((layer_size * i as u32) as usize).cast(),
raw_data.len()
);
i += 1;
}
device.unmap_memory(staging_buffer.buffer_memory);
```
### 1.3 Creation of vulkan images
Now we can setup the vulkan cube map array image. Before we do that it is important to note that vulkan cube map arrays are really just textures(of the same size and channel count) stored in different layers on the same "vulkan image".
``` rust
let image = VkImage::create_cube_map(
instance,
device,
physical_device,
&texture_extent,
).create_cube_map_image_sampler(
&device,
physical_device,
&instance,
).transition_image_layout(
&device,
&command_pool,
(
ImageLayout::UNDEFINED,
ImageLayout::TRANSFER_DST_OPTIMAL,
),
&graphics_queue,
None,
6,
);
```
The code above does not really do anything out of the ordinary when it comes to loading cube maps.
There are three methods:
* `create_cube_map`, simply creates a vulkan image and a vulkan image_view. The vulkan image is set to have an `array_layers` value of 6(one for each side) and `image_create_flags` to [CUBE_COMPATIBLE_BIT](https://docs.vulkan.org/spec/latest/chapters/resources.html#VUID-VkImageCreateInfo-flags-00949). For the image view we only need to set the `layer_count` value to 6(again one for each side), the `view_type` to [CUBE](https://docs.vulkan.org/spec/latest/chapters/resources.html#resources-image-views), and set the `mip_map_level_count` to 1(if you aren't using a mipmapped image, I'm not). In practice your boilerplate would look something like this:
``` rust
ImageCreateInfo::default()
.flags(ImageCreateFlags::CUBE_COMPATIBLE)
.image_type(ImageType::TYPE_2D)
.extent(Insert the image extent!)
.mip_levels(1)
.array_layers(6)
.tiling(ImageTiling::OPTIMAL)
.format(Format::R32G32B32A32_SFLOAT)
.initial_layout(ImageLayout::UNDEFINED)
.usage(ImageUsageFlags::TRANSFER_SRC
| ImageUsageFlags::TRANSFER_DST
| ImageUsageFlags::SAMPLED)
.samples(INSERT DESIRED SAMPLE COUNT)
.sharing_mode(SharingMode::EXCLUSIVE)
ImageViewCreateInfo::default()
.image(self.image) // Set this to our cube map image!
.format(Format::R32G32B32A32_SFLOAT)
.subresource_range(
ImageSubresourceRange::default()
.aspect_mask(ImageAspectFlags::COLOR)
.base_array_layer(0)
.layer_count(6) // Remember one for everyside
.base_mip_level(0)
.level_count(1),
)
.view_type(ImageViewType::CUBE);
```
* `create_cube_map_image_sampler`, we need to of course have an image sampler, so here it is. Nothing too crazy.
``` rust
SamplerCreateInfo::default()
.mag_filter(vk::Filter::LINEAR)
.min_filter(vk::Filter::LINEAR)
.address_mode_u(vk::SamplerAddressMode::CLAMP_TO_EDGE)
.address_mode_v(vk::SamplerAddressMode::CLAMP_TO_EDGE)
.address_mode_w(vk::SamplerAddressMode::CLAMP_TO_EDGE)
.anisotropy_enable(true)
.max_anisotropy(device_props.limits.max_sampler_anisotropy)
.border_color(BorderColor::INT_OPAQUE_WHITE)
.unnormalized_coordinates(false)
.compare_enable(false)
.compare_op(CompareOp::NEVER)
.mipmap_mode(SamplerMipmapMode::LINEAR)
.mip_lod_bias(0.0f32)
.min_lod(0.0f32)
.max_lod(0.0f32);
```
* `transition_image_layout` here we only need to change our definition of the `ImageSubresourceRange` to match that of the ImageView i.e
``` rust
ImageSubresourceRange::default()
.aspect_mask(ImageAspectFlags::COLOR)
.base_mip_level(0)
.level_count(1)
.base_array_layer(0)
.layer_count(6),
```
We now only have three things left to do:
* Copy the staging buffer data over to the vulkan image
When copying the data from our staging buffer over to our image resource we will need to update the `BufferImageCopyStruct` to reflect the layer count as we did for the `transition_image_layout` above. I.e:
``` rust
BufferImageCopy::default()
.buffer_image_height(0)
.buffer_row_length(0)
.buffer_offset(0)
.image_subresource(
ImageSubresourceLayers::default()
.aspect_mask(ImageAspectFlags::COLOR)
.mip_level(0)
.layer_count(6)
.base_array_layer(0),
)
.image_extent(Insert the image extent!)
```
* Transition our image layout from `TRANSFER_DST_OPTIMAL` to `SHADER_READ_ONLY_OPTIMAL`.
This is a call to the function `.transition_image_layout` but with different image layouts, for more info go back and read the [vulkan tutorial on texture mapping](https://vulkan-tutorial.com/en/Texture_mapping/Images).
* Update our descriptor with an image writer
Nothing too out of the ordinary here either, we need to have a `descriptor_set_layout` that contains matches our shader description. The layout should have the following bindings(they are pretty standard texture bindings):
``` rust
DescriptorSetLayoutBinding::default()
.binding(1)
.descriptor_type(DescriptorType::CombinedImageSampler)
.descriptor_count(1)
.stage_flags(ShaderStage::Fragment);
```
Now milage like with all tutorials may vary, but you should be able to view the you newly created resources in [renderdoc](https://renderdoc.org) under the [Resource Inspector](https://renderdoc.org/docs/window/resource_inspector.html).
## 2. Shaders
You should now have some texture resources allocated on the GPU. That's one small step for man. Now we need to write some shaders for mankind!
These shaders are very simple:
* Vertex Shader:
``` glsl
# version 450
layout (location = 0) in vec3 Position;
layout(binding = 0) uniform UniformBufferObject {
mat4 view;
mat4 proj;
} ubo;
layout(location = 0) out vec3 TexCoord0;
void main()
{
// NOTE: mat4(mat3(ubo.view)) this strips away the translation vectors!
vec4 WVP_Pos = ubo.proj * mat4(mat3(ubo.view)) * vec4(Position, 1.0);
gl_Position = WVP_Pos.xyww;
TexCoord0 = Position;
}
```
* Fragment Shader:
``` glsl
# version 450
layout(set = 1, binding = 1) uniform samplerCube cubeSampler;
layout(location = 0) in vec3 TexCoord0;
layout(location = 0) out vec4 FragColor;
void main() {
FragColor = texture(cubeSampler, TexCoord0);
}
```
The setup is very simple. øWe only have a vertex shader that pushes the input vertex position to the fragment shader, which is used as sampling coordinates. One thing and the only thing of note is that the `ubo.view` matrix is converted from a Mat4 -> Mat3 -> Mat4. This is because the final row and column of the view matrix needs to be cleared of all vector translations, if they aren't cleared then you may end up with undesirable results.
For me it looked like this, a whacky cube at (0.0, 0.0, 0.0).
![[Pasted image 20250925134038.png]]
## 3. Pipeline
Let's go pipelines! We of course need to update them.
In this case, we need to
* Create new `VertexBindingDescriptions`
Because we are only providing 'positions' then we can't really reuse the "default" `VertexBinding` that that has a stride the size of positions + normals + uvs.
The `VertexBindingDescription` should be:
``` rust
VertexInputBindingDescription {
binding: 0,
stride: (size_of::<f32>() * 3) as u32,
input_rate: VertexInputRate::VERTEX,
}
```
* Create new `VertexAttributeDescriptions`
The reasoning is the same here as the step before:
``` rust
VertexInputAttributeDescription {
location: 0,
binding: 0,
format: Format::R32G32B32_SFLOAT,
offset: 0,
}
```
* Set `DepthStencilState` to "disabled"
In order to be able to view the cube map we are forced to disable the `DepthStencilState` for our new skybox pipeline. Reasoning is that we drawing our texture beyond our far plane, and if we have `DepthStencil` enabled we are always going to overwrite the cube map with our clear color.
Anywho, here is what it should look like:
``` rust
PipelineDepthStencilStateCreateInfo::default()
.depth_test_enable(false)
.depth_write_enable(false)
.depth_compare_op(CompareOp::NEVER)
.depth_bounds_test_enable(false)
.min_depth_bounds(0.0)
.max_depth_bounds(1.0)
.stencil_test_enable(false)
```
## 4. Cube
I'm not going to go into great detail on how to go about loading the cube into memory, you should already have an abstraction for that. I will only provide you with a hard coded cube that includes correct indices.
``` rust
let positions = vec![
Vector3::new(-1.0f32, -1.0f32, 1.0f32), // A1
Vector3::new(-1.0f32, 1.0f32, 1.0f32), // A2
Vector3::new(-1.0f32, -1.0f32, -1.0f32), // A3
Vector3::new(-1.0f32, 1.0f32, -1.0f32), // A4
Vector3::new(1.0f32, -1.0f32, 1.0f32), // A5
Vector3::new(1.0f32, 1.0f32, 1.0f32), // A6
Vector3::new(1.0f32, -1.0f32, -1.0f32), // A7
Vector3::new(1.0f32, 1.0f32, -1.0f32), // A8
];
let indices = vec![
6,7,5, // FRONT FACE
6,5,4, // FRONT FACE
0,1,3, // BACK FACE
0,3,2, // BACK FACE
7,3,1, // TOP FACE
7,1,5, // TOP FACE
2,6,4, // BACK FACE
2,4,0, // BACK FACE
4,5,1, // RIGHT FACE
4,1,0, // RIGHT FACE
2,3,7, // LEFT FACE
2,7,6, // LEFT FACE
];
```
## 5. The render loop / Recording your command buffers
So the final thing that you need to do is setup the render loop!
When drawing skyboxes it is important to draw the skybox first and then draw everything else, this is due to render orders!
Now, actually recording the command buffers aren't too complicated. In my loop I record my command buffers in the following way:
``` rust
// Bind Skybox pipeline
self.device.cmd_bind_pipeline(
*command_buffer,
PipelineBindPoint::GRAPHICS,
skyboxpipe.pipeline,
);
//bind Skybox pipeline descriptors, i.e UBO descriptor
for descriptors_to_bind in &skyboxpipe.descriptors {
self.device.cmd_bind_descriptor_sets(
*command_buffer,
PipelineBindPoint::GRAPHICS,
skyboxpipe.pipeline_layout,
0,
&[self.descriptor_sets[descriptors_to_bind][index]],
&[],
);
}
// bind skybox material descriptors i.e texture descriptor
device.cmd_bind_descriptor_sets(
*command_buffer,
PipelineBindPoint::GRAPHICS,
*skyboxpipe.pipeline_layout,
1,
&[skybox.material_descriptor],
&[],
);
// Bind Skybox Cube Mesh
device.cmd_bind_vertex_buffers(
*cmd_buffer,
0,
&[skybox.cube.vertex_buffer],
&[0],
);
device.cmd_bind_index_buffer(
*cmd_buffer,
skybox.cube.index_buffer,
0,
IndexType::UINT32,
);
self.device.cmd_draw_indexed(
*command_buffer,
self.skybox.cube.mesh.indices_count as u32,
1,
0,
0,
0,
);
```
## Conclusion
So now I've gone through and described how I went about implementing skyboxes. I didn't provide all of the code, instead you should understand the general consensus on how to implement cube mapping. Hopefully it will smoothly fit into your engine!
Here is a gif on how it turned out for me:
![[skybox example.gif]]
# Links:
Key resource: https://satellitnorden.wordpress.com/2018/01/23/vulkan-adventures-cube-map-tutorial/
https://github.com/SaschaWillems/Vulkan/blob/master/examples/texturecubemaparray/texturecubemaparray.cpp#L463
https://www.ogldev.org/www/tutorial25/tutorial25.html
https://www.youtube.com/watch?v=G2X3Exgi3co&t=495s
https://learnopengl.com/Advanced-OpenGL/Cubemaps