Rendering Multiple Objects
Introduction
In this chapter, we’ll extend our Vulkan application to render multiple objects in the scene. So far, we’ve been rendering a single model, but real-world applications typically need to display many objects. This tutorial will show you how to efficiently manage and render multiple objects while reusing as many resources as possible.
Overview
When rendering multiple objects, we need to consider which resources should be: 1. Shared across all objects - to minimize memory usage and state changes 2. Duplicated for each object - to allow for independent positioning and appearance
Here’s a quick reference for what typically falls into each category:
Shared resources:
-
Vertex and index buffers (when objects use the same mesh)
-
Textures and samplers (when objects use the same textures)
-
Pipeline objects and pipeline layouts
-
Render passes
-
Command pools
Per-object resources:
-
Transformation matrices (position, rotation, scale)
-
Uniform buffers containing those matrices
-
Descriptor sets that reference those uniform buffers
-
Push constants (for small, frequently changing data)
Implementation
Let’s walk through the key changes needed to render multiple objects:
Define a GameObject Structure
First, we’ll create a structure to hold per-object data:
// Define a structure to hold per-object data
struct GameObject {
// Transform properties
glm::vec3 position = {0.0f, 0.0f, 0.0f};
glm::vec3 rotation = {0.0f, 0.0f, 0.0f};
glm::vec3 scale = {1.0f, 1.0f, 1.0f};
// Uniform buffer for this object (one per frame in flight)
std::vector<vk::raii::Buffer> uniformBuffers;
std::vector<vk::raii::DeviceMemory> uniformBuffersMemory;
std::vector<void*> uniformBuffersMapped;
// Descriptor sets for this object (one per frame in flight)
std::vector<vk::raii::DescriptorSet> descriptorSets;
// Calculate model matrix based on position, rotation, and scale
glm::mat4 getModelMatrix() const {
glm::mat4 model = glm::mat4(1.0f);
model = glm::translate(model, position);
model = glm::rotate(model, rotation.x, glm::vec3(1.0f, 0.0f, 0.0f));
model = glm::rotate(model, rotation.y, glm::vec3(0.0f, 1.0f, 0.0f));
model = glm::rotate(model, rotation.z, glm::vec3(0.0f, 0.0f, 1.0f));
model = glm::scale(model, scale);
return model;
}
};
This structure encapsulates: * The object’s transform (position, rotation, scale) * Per-object uniform buffers (one for each frame in flight) * Per-object descriptor sets (one for each frame in flight) * A helper method to calculate the model matrix
Create an Array of GameObjects
In our application class, we’ll replace the single set of uniform buffers and descriptor sets with an array of GameObjects:
// Define the number of objects to render
constexpr int MAX_OBJECTS = 3;
// In the VulkanApplication class:
// Array of game objects to render
std::array<GameObject, MAX_OBJECTS> gameObjects;
Initialize the GameObjects
We’ll add a new method to set up our game objects with different positions, rotations, and scales:
// Initialize the game objects with different positions, rotations, and scales
void setupGameObjects() {
// Object 1 - Center
gameObjects[0].position = {0.0f, 0.0f, 0.0f};
gameObjects[0].rotation = {0.0f, 0.0f, 0.0f};
gameObjects[0].scale = {1.0f, 1.0f, 1.0f};
// Object 2 - Left
gameObjects[1].position = {-2.0f, 0.0f, -1.0f};
gameObjects[1].rotation = {0.0f, glm::radians(45.0f), 0.0f};
gameObjects[1].scale = {0.75f, 0.75f, 0.75f};
// Object 3 - Right
gameObjects[2].position = {2.0f, 0.0f, -1.0f};
gameObjects[2].rotation = {0.0f, glm::radians(-45.0f), 0.0f};
gameObjects[2].scale = {0.75f, 0.75f, 0.75f};
}
This method is called from initVulkan()
after loading the model but before creating uniform buffers.
Create Uniform Buffers for Each Object
Instead of creating a single set of uniform buffers, we’ll create them for each object:
// Create uniform buffers for each object
void createUniformBuffers() {
// For each game object
for (auto& gameObject : gameObjects) {
gameObject.uniformBuffers.clear();
gameObject.uniformBuffersMemory.clear();
gameObject.uniformBuffersMapped.clear();
// Create uniform buffers for each frame in flight
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vk::DeviceSize bufferSize = sizeof(UniformBufferObject);
vk::raii::Buffer buffer({});
vk::raii::DeviceMemory bufferMem({});
createBuffer(bufferSize, vk::BufferUsageFlagBits::eUniformBuffer,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
buffer, bufferMem);
gameObject.uniformBuffers.emplace_back(std::move(buffer));
gameObject.uniformBuffersMemory.emplace_back(std::move(bufferMem));
gameObject.uniformBuffersMapped.emplace_back(gameObject.uniformBuffersMemory[i].mapMemory(0, bufferSize));
}
}
}
Update the Descriptor Pool Size
We need to increase the descriptor pool size to accommodate all objects:
void createDescriptorPool() {
// We need MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT descriptor sets
std::array poolSize {
vk::DescriptorPoolSize(vk::DescriptorType::eUniformBuffer, MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT),
vk::DescriptorPoolSize(vk::DescriptorType::eCombinedImageSampler, MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT)
};
vk::DescriptorPoolCreateInfo poolInfo{
.flags = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet,
.maxSets = MAX_OBJECTS * MAX_FRAMES_IN_FLIGHT,
.poolSizeCount = static_cast<uint32_t>(poolSize.size()),
.pPoolSizes = poolSize.data()
};
descriptorPool = vk::raii::DescriptorPool(device, poolInfo);
}
Create Descriptor Sets for Each Object
Similarly, we’ll create descriptor sets for each object:
void createDescriptorSets() {
// For each game object
for (auto& gameObject : gameObjects) {
// Create descriptor sets for each frame in flight
std::vector<vk::DescriptorSetLayout> layouts(MAX_FRAMES_IN_FLIGHT, *descriptorSetLayout);
vk::DescriptorSetAllocateInfo allocInfo{
.descriptorPool = *descriptorPool,
.descriptorSetCount = static_cast<uint32_t>(layouts.size()),
.pSetLayouts = layouts.data()
};
gameObject.descriptorSets.clear();
gameObject.descriptorSets = device.allocateDescriptorSets(allocInfo);
for (size_t i = 0; i < MAX_FRAMES_IN_FLIGHT; i++) {
vk::DescriptorBufferInfo bufferInfo{
.buffer = *gameObject.uniformBuffers[i],
.offset = 0,
.range = sizeof(UniformBufferObject)
};
vk::DescriptorImageInfo imageInfo{
.sampler = *textureSampler,
.imageView = *textureImageView,
.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal
};
std::array descriptorWrites{
vk::WriteDescriptorSet{
.dstSet = *gameObject.descriptorSets[i],
.dstBinding = 0,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk::DescriptorType::eUniformBuffer,
.pBufferInfo = &bufferInfo
},
vk::WriteDescriptorSet{
.dstSet = *gameObject.descriptorSets[i],
.dstBinding = 1,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.pImageInfo = &imageInfo
}
};
device.updateDescriptorSets(descriptorWrites, {});
}
}
}
Update Uniform Buffers for All Objects
We’ll modify the uniform buffer update to handle all objects:
void updateUniformBuffers() {
static auto startTime = std::chrono::high_resolution_clock::now();
auto currentTime = std::chrono::high_resolution_clock::now();
float time = std::chrono::duration<float>(currentTime - startTime).count();
// Camera and projection matrices (shared by all objects)
glm::mat4 view = glm::lookAt(glm::vec3(2.0f, 2.0f, 6.0f), glm::vec3(0.0f, 0.0f, 0.0f), glm::vec3(0.0f, 1.0f, 0.0f));
glm::mat4 proj = glm::perspective(glm::radians(45.0f),
static_cast<float>(swapChainExtent.width) / static_cast<float>(swapChainExtent.height),
0.1f, 20.0f);
proj[1][1] *= -1; // Flip Y for Vulkan
// Update uniform buffers for each object
for (auto& gameObject : gameObjects) {
// Apply continuous rotation to the object
gameObject.rotation.y += 0.001f; // Slow rotation around Y axis
// Get the model matrix for this object
glm::mat4 initialRotation = glm::rotate(glm::mat4(1.0f), glm::radians(-90.0f), glm::vec3(1.0f, 0.0f, 0.0f));
glm::mat4 model = gameObject.getModelMatrix() * initialRotation;
// Create and update the UBO
UniformBufferObject ubo{
.model = model,
.view = view,
.proj = proj
};
// Copy the UBO data to the mapped memory
memcpy(gameObject.uniformBuffersMapped[currentFrame], &ubo, sizeof(ubo));
}
}
Note that we’re sharing the view and projection matrices across all objects, but each object has its own model matrix.
Modify the Command Buffer Recording
Finally, we’ll update the command buffer recording to draw each object:
void recordCommandBuffer(uint32_t imageIndex) {
// ... (beginning of the method remains the same)
// Bind vertex and index buffers (shared by all objects)
commandBuffers[currentFrame].bindVertexBuffers(0, *vertexBuffer, {0});
commandBuffers[currentFrame].bindIndexBuffer(*indexBuffer, 0, vk::IndexType::eUint32);
// Draw each object with its own descriptor set
for (const auto& gameObject : gameObjects) {
// Bind the descriptor set for this object
commandBuffers[currentFrame].bindDescriptorSets(
vk::PipelineBindPoint::eGraphics,
*pipelineLayout,
0,
*gameObject.descriptorSets[currentFrame],
nullptr
);
// Draw the object
commandBuffers[currentFrame].drawIndexed(indices.size(), 1, 0, 0, 0);
}
// ... (end of the method remains the same)
}
Performance Considerations
When rendering multiple objects, keep these performance considerations in mind:
-
Minimize state changes: Group objects by material/texture to reduce binding changes.
-
Use instancing for many identical objects (not covered in this tutorial).
-
Consider push constants for small, frequently changing data instead of uniform buffers.
-
Batch draw calls where possible to reduce CPU overhead.
-
Use indirect drawing for large numbers of objects (not covered here).
Conclusion
You’ve now learned how to render multiple objects in Vulkan by:
-
Creating a structure to hold per-object data
-
Duplicating the necessary resources with (uniform buffers, descriptor sets) for each object
-
Sharing resources that can be reused (vertex/index buffers, pipeline, textures)
-
Updating the rendering loop to draw each object with its own transformation
This approach gives you the flexibility to position, rotate, and scale objects independently while maintaining good performance by sharing resources where appropriate.
In a real-world application, you might extend this system with:
-
Object hierarchies (parent-child relationships)
-
Different meshes and materials for different objects
-
Frustum culling to avoid rendering objects outside the camera view
-
Level-of-detail systems for objects at different distances
The foundation you’ve built here will serve as a solid starting point for these more advanced techniques.