Loading Models: Managing Multiple Objects
Managing Multiple Objects in a 3D Scene
Introduction to Multi-Object Rendering
In previous chapters, we’ve focused on loading and rendering a single 3D model. However, real-world applications rarely display just one object. Games, simulations, and visualizations typically contain many objects that interact within a shared environment. This chapter explores how to efficiently manage and render multiple objects in a 3D scene.
The ability to render multiple objects is fundamental to creating rich, interactive environments. It involves not just duplicating models, but also managing their unique properties, spatial relationships, and rendering states. As we’ll see, this introduces both challenges and opportunities for optimization.
Approaches to Managing Multiple Objects
There are several strategies for handling multiple objects in a 3D engine, each with different trade-offs:
Object Instances vs. Multiple Models
When creating a scene with multiple similar objects (like trees in a forest or buildings in a city), we have two main approaches:
-
Multiple Model Instances: Load the model once but render it multiple times with different transformations
-
Advantages: Memory efficient, single asset to manage
-
Use cases: Repeated elements like trees, rocks, furniture
-
-
Unique Models: Load separate models for each unique object
-
Advantages: Greater variety, independent modifications
-
Use cases: Main characters, unique structures, varied elements
-
For our engine, we’ll implement the instancing approach, which is more memory-efficient and suitable for many common scenarios.
Scene Organization Strategies
Beyond simply having multiple objects, we need to organize them effectively:
-
Flat Collection: Store all objects in a simple list or array
-
Advantages: Simplicity, easy iteration
-
Disadvantages: No spatial relationships, inefficient for large scenes
-
-
Spatial Partitioning: Organize objects by their location in 3D space
-
Advantages: Efficient culling and queries, better performance for large scenes
-
Examples: Octrees, BSP trees, grid systems
-
-
Scene Graph: Organize objects in a hierarchical tree structure
-
Advantages: Parent-child relationships, hierarchical transformations
-
Use cases: Articulated models, complex object relationships
-
Our implementation will use a simple collection for this example, but in a more advanced engine, you would typically combine this with spatial partitioning and scene graph techniques.
Performance Considerations
Rendering multiple objects efficiently requires careful attention to performance:
Draw Call Optimization
Each object typically requires at least one draw call, which can become a bottleneck:
-
Batching: Combining similar objects into a single draw call
-
Instanced Rendering: Using hardware instancing to draw multiple copies of the same mesh
-
Level of Detail (LOD): Using simpler models for distant objects
Implementing Object Instances
Now let’s implement a system for managing multiple object instances. We’ll start with a simple structure to store instance data:
// Object instances - using the same structure as in our model system
struct ObjectInstance {
glm::vec3 position; // Position in world space
glm::vec3 rotation; // Rotation in Euler angles (degrees)
glm::vec3 scale; // Scale factors for each axis
};
// Collection of object instances
std::vector<ObjectInstance> objectInstances;
This structure stores the position, rotation, and scale for each instance, along with a method to compute the model matrix. The model matrix transforms the object from its local space to world space, combining all three transformations.
Next, we’ll set up several instances with different transformations:
void setupObjectInstances() {
// Create multiple instances of the model with different positions
const int MAX_OBJECTS = 10; // Define how many objects we want
objectInstances.resize(MAX_OBJECTS);
// Instance 1 - Center
objectInstances[0].position = glm::vec3(0.0f, 0.0f, 0.0f);
objectInstances[0].rotation = glm::vec3(0.0f, 0.0f, 0.0f);
objectInstances[0].scale = glm::vec3(1.0f);
// Instance 2 - Left
objectInstances[1].position = glm::vec3(-2.0f, 0.0f, -1.0f);
objectInstances[1].rotation = glm::vec3(0.0f, 45.0f, 0.0f);
objectInstances[1].scale = glm::vec3(0.8f);
// Instance 3 - Right
objectInstances[2].position = glm::vec3(2.0f, 0.0f, -1.0f);
objectInstances[2].rotation = glm::vec3(0.0f, -45.0f, 0.0f);
objectInstances[2].scale = glm::vec3(0.8f);
// Instance 4 - Back Left
objectInstances[3].position = glm::vec3(-1.5f, 0.0f, -3.0f);
objectInstances[3].rotation = glm::vec3(0.0f, 30.0f, 0.0f);
objectInstances[3].scale = glm::vec3(0.7f);
// Instance 5 - Back Right
objectInstances[4].position = glm::vec3(1.5f, 0.0f, -3.0f);
objectInstances[4].rotation = glm::vec3(0.0f, -30.0f, 0.0f);
objectInstances[4].scale = glm::vec3(0.7f);
// Instance 6 - Front Left
objectInstances[5].position = glm::vec3(-1.5f, 0.0f, 1.5f);
objectInstances[5].rotation = glm::vec3(0.0f, -30.0f, 0.0f);
objectInstances[5].scale = glm::vec3(0.6f);
// Instance 7 - Front Right
objectInstances[6].position = glm::vec3(1.5f, 0.0f, 1.5f);
objectInstances[6].rotation = glm::vec3(0.0f, 30.0f, 0.0f);
objectInstances[6].scale = glm::vec3(0.6f);
// Instance 8 - Above
objectInstances[7].position = glm::vec3(0.0f, 2.0f, -2.0f);
objectInstances[7].rotation = glm::vec3(45.0f, 0.0f, 0.0f);
objectInstances[7].scale = glm::vec3(0.5f);
// Instance 9 - Below
objectInstances[8].position = glm::vec3(0.0f, -1.0f, -2.0f);
objectInstances[8].rotation = glm::vec3(-30.0f, 0.0f, 0.0f);
objectInstances[8].scale = glm::vec3(0.5f);
// Instance 10 - Far Back
objectInstances[9].position = glm::vec3(0.0f, 0.5f, -5.0f);
objectInstances[9].rotation = glm::vec3(0.0f, 180.0f, 0.0f);
objectInstances[9].scale = glm::vec3(1.2f);
}
This function creates ten instances of our model, each with a unique position, rotation, and scale. This allows us to create a more interesting scene with varied object placements.
Rendering Multiple Objects
Now that we have our object instances set up, we need to render them. Here’s how we can modify our rendering loop to handle multiple objects:
void drawFrame() {
// ... (standard Vulkan frame setup)
// Begin command buffer recording
commandBuffer.begin({});
// Transition image layout for rendering
transition_image_layout(
imageIndex,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eColorAttachmentOptimal,
{},
vk::AccessFlagBits2::eColorAttachmentWrite,
vk::PipelineStageFlagBits2::eTopOfPipe,
vk::PipelineStageFlagBits2::eColorAttachmentOutput
);
// Set up rendering attachments
vk::ClearValue clearColor = vk::ClearColorValue(0.0f, 0.0f, 0.0f, 1.0f);
vk::ClearValue clearDepth = vk::ClearDepthStencilValue(1.0f, 0);
vk::RenderingAttachmentInfo colorAttachmentInfo = {
.imageView = swapChainImageViews[imageIndex],
.imageLayout = vk::ImageLayout::eColorAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.clearValue = clearColor
};
vk::RenderingAttachmentInfo depthAttachmentInfo = {
.imageView = depthImageView,
.imageLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.clearValue = clearDepth
};
vk::RenderingInfo renderingInfo = {
.renderArea = { .offset = { 0, 0 }, .extent = swapChainExtent },
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachmentInfo,
.pDepthAttachment = &depthAttachmentInfo
};
// Begin dynamic rendering
commandBuffer.beginRendering(renderingInfo);
// Bind pipeline
commandBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, graphicsPipeline);
// Set viewport and scissor
commandBuffer.setViewport(0, vk::Viewport(0.0f, 0.0f, static_cast<float>(swapChainExtent.width), static_cast<float>(swapChainExtent.height), 0.0f, 1.0f));
commandBuffer.setScissor(0, vk::Rect2D(vk::Offset2D(0, 0), swapChainExtent));
// Bind descriptor set with uniform buffer and textures
commandBuffer.bindDescriptorSets(
vk::PipelineBindPoint::eGraphics,
pipelineLayout,
0,
1,
&descriptorSets[currentFrame],
0,
nullptr
);
// Update view and projection in uniform buffer
UniformBufferObject ubo{};
ubo.view = camera.getViewMatrix();
ubo.proj = camera.getProjectionMatrix(swapChainExtent.width / (float)swapChainExtent.height);
ubo.proj[1][1] *= -1; // Vulkan's Y coordinate is inverted
// Copy to uniform buffer (per frame-in-flight)
memcpy(uniformBuffers[currentFrame].mapped, &ubo, sizeof(ubo));
// Render each object instance
for (size_t i = 0; i < objectInstances.size(); i++) {
const auto& instance = objectInstances[i];
// Create model matrix for this instance
glm::mat4 modelMatrix = glm::mat4(1.0f);
modelMatrix = glm::translate(modelMatrix, instance.position);
modelMatrix = glm::rotate(modelMatrix, glm::radians(instance.rotation.x), glm::vec3(1.0f, 0.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, glm::radians(instance.rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, glm::radians(instance.rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));
modelMatrix = glm::scale(modelMatrix, instance.scale);
// Render all nodes in the model
renderNode(commandBuffer, model.nodes, modelMatrix);
}
// End dynamic rendering
commandBuffer.endRendering();
// Transition image layout for presentation
transition_image_layout(
imageIndex,
vk::ImageLayout::eColorAttachmentOptimal,
vk::ImageLayout::ePresentSrcKHR,
vk::AccessFlagBits2::eColorAttachmentWrite,
{},
vk::PipelineStageFlagBits2::eColorAttachmentOutput,
vk::PipelineStageFlagBits2::eBottomOfPipe
);
// End command buffer recording
commandBuffer.end();
// ... (submit command buffer and present)
}
// Helper function to recursively render all nodes in the model
void renderNode(const vk::raii::CommandBuffer& commandBuffer, const std::vector<Node*>& nodes, const glm::mat4& parentMatrix) {
for (const auto node : nodes) {
// Calculate global matrix for this node
glm::mat4 nodeMatrix = parentMatrix * node->getLocalMatrix();
// If this node has a mesh, render it
if (!node->mesh.vertices.empty() && !node->mesh.indices.empty() &&
node->vertexBufferIndex >= 0 && node->indexBufferIndex >= 0) {
// Set up push constants for material properties
PushConstantBlock pushConstants{};
if (node->mesh.materialIndex >= 0 && node->mesh.materialIndex < static_cast<int>(model.materials.size())) {
const auto& material = model.materials[node->mesh.materialIndex];
pushConstants.baseColorFactor = material.baseColorFactor;
pushConstants.metallicFactor = material.metallicFactor;
pushConstants.roughnessFactor = material.roughnessFactor;
pushConstants.baseColorTextureSet = material.baseColorTextureIndex >= 0 ? 1 : -1;
pushConstants.physicalDescriptorTextureSet = material.metallicRoughnessTextureIndex >= 0 ? 2 : -1;
pushConstants.normalTextureSet = material.normalTextureIndex >= 0 ? 3 : -1;
pushConstants.occlusionTextureSet = material.occlusionTextureIndex >= 0 ? 4 : -1;
pushConstants.emissiveTextureSet = material.emissiveTextureIndex >= 0 ? 5 : -1;
} else {
// Default material properties
pushConstants.baseColorFactor = glm::vec4(1.0f);
pushConstants.metallicFactor = 1.0f;
pushConstants.roughnessFactor = 1.0f;
pushConstants.baseColorTextureSet = 1;
pushConstants.physicalDescriptorTextureSet = -1;
pushConstants.normalTextureSet = -1;
pushConstants.occlusionTextureSet = -1;
pushConstants.emissiveTextureSet = -1;
}
// Update model matrix in push constants
commandBuffer.pushConstants(pipelineLayout, vk::ShaderStageFlagBits::eFragment, 0, sizeof(PushConstantBlock), &pushConstants);
// Bind vertex and index buffers
commandBuffer.bindVertexBuffers(0, *vertexBuffers[node->vertexBufferIndex], {0});
commandBuffer.bindIndexBuffer(*indexBuffers[node->indexBufferIndex], 0, vk::IndexType::eUint32);
// Draw the mesh
commandBuffer.drawIndexed(static_cast<uint32_t>(node->mesh.indices.size()), 1, 0, 0, 0);
}
// Recursively render children
if (!node->children.empty()) {
renderNode(commandBuffer, node->children, nodeMatrix);
}
}
}
This rendering approach leverages our model system to efficiently render multiple instances of a model:
-
It uses the scene graph structure to handle complex models with multiple parts
-
It properly handles parent-child relationships and hierarchical transformations
-
It applies material properties to each mesh using push constants
-
It supports animations through the node transformation system
While this approach is more sophisticated than a simple flat list of objects, it does have some limitations:
-
It still requires a separate draw call for each mesh in each instance, which can be inefficient for large numbers of objects
-
It doesn’t implement any culling or batching optimizations
-
For very large scenes, additional spatial partitioning would be beneficial
Advanced Techniques: Hardware Instancing
For more efficient rendering of many similar objects, we can use hardware instancing. This allows us to draw multiple instances of the same model with a single draw call:
// Instance data for GPU instancing
struct InstanceData {
glm::mat4 model; // Model matrix for this instance
};
// Create buffers to hold instance data for each node with a mesh
std::vector<vk::raii::Buffer> instanceBuffers;
std::vector<vk::raii::DeviceMemory> instanceBufferMemories;
std::vector<void*> instanceBuffersMapped;
void setupInstanceBuffers() {
// Create an instance buffer for each node with a mesh
for (auto node : model.linearNodes) {
if (node->mesh.vertices.empty() || node->mesh.indices.empty()) {
continue;
}
// Calculate buffer size
vk::DeviceSize bufferSize = sizeof(InstanceData) * objectInstances.size();
// Create the buffer
vk::raii::Buffer instanceBuffer = nullptr;
vk::raii::DeviceMemory instanceBufferMemory = nullptr;
createBuffer(
bufferSize,
vk::BufferUsageFlagBits::eVertexBuffer,
vk::MemoryPropertyFlagBits::eHostVisible | vk::MemoryPropertyFlagBits::eHostCoherent,
instanceBuffer,
instanceBufferMemory
);
// Map the buffer memory
void* instanceBufferMapped = device.mapMemory(instanceBufferMemory, 0, bufferSize, {});
// Store buffer and memory
instanceBuffers.push_back(std::move(instanceBuffer));
instanceBufferMemories.push_back(std::move(instanceBufferMemory));
instanceBuffersMapped.push_back(instanceBufferMapped);
// Set the instance buffer index for this node
node->instanceBufferIndex = static_cast<int>(instanceBuffers.size() - 1);
}
// Update all instance buffers
updateInstanceBuffers();
}
void updateInstanceBuffers() {
// For each node with an instance buffer
for (auto node : model.linearNodes) {
if (node->instanceBufferIndex < 0) {
continue;
}
// Prepare instance data for this node
std::vector<InstanceData> instanceData(objectInstances.size());
for (size_t i = 0; i < objectInstances.size(); i++) {
// Create model matrix for this instance
glm::mat4 modelMatrix = glm::mat4(1.0f);
modelMatrix = glm::translate(modelMatrix, objectInstances[i].position);
modelMatrix = glm::rotate(modelMatrix, glm::radians(objectInstances[i].rotation.x), glm::vec3(1.0f, 0.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, glm::radians(objectInstances[i].rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));
modelMatrix = glm::rotate(modelMatrix, glm::radians(objectInstances[i].rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));
modelMatrix = glm::scale(modelMatrix, objectInstances[i].scale);
// Combine with node's local matrix
instanceData[i].model = modelMatrix * node->getLocalMatrix();
}
// Copy to instance buffer
memcpy(instanceBuffersMapped[node->instanceBufferIndex], instanceData.data(), sizeof(InstanceData) * instanceData.size());
}
}
// Modify vertex input state to include instance data
vk::PipelineVertexInputStateCreateInfo vertexInputInfo{};
// ... (standard vertex input setup)
// Add instance data bindings and attributes
vk::VertexInputBindingDescription instanceBindingDescription{};
instanceBindingDescription.binding = 1; // Use binding point 1 for instance data
instanceBindingDescription.stride = sizeof(InstanceData);
instanceBindingDescription.inputRate = vk::VertexInputRate::eInstance; // Advance per instance
// Four attributes for the 4x4 matrix (one per row)
std::array<vk::VertexInputAttributeDescription, 4> instanceAttributeDescriptions{};
for (uint32_t i = 0; i < 4; i++) {
instanceAttributeDescriptions[i].binding = 1;
instanceAttributeDescriptions[i].location = 4 + i; // Start after vertex attributes
instanceAttributeDescriptions[i].format = vk::Format::eR32G32B32A32Sfloat;
instanceAttributeDescriptions[i].offset = sizeof(float) * 4 * i;
}
// Combine vertex and instance bindings/attributes
std::array<vk::VertexInputBindingDescription, 2> bindingDescriptions = {
vertexBindingDescription,
instanceBindingDescription
};
std::vector<vk::VertexInputAttributeDescription> attributeDescriptions;
// Add vertex attributes
for (const auto& attr : vertexAttributeDescriptions) {
attributeDescriptions.push_back(attr);
}
// Add instance attributes
for (const auto& attr : instanceAttributeDescriptions) {
attributeDescriptions.push_back(attr);
}
// Update vertex input info
vertexInputInfo.vertexBindingDescriptionCount = static_cast<uint32_t>(bindingDescriptions.size());
vertexInputInfo.pVertexBindingDescriptions = bindingDescriptions.data();
vertexInputInfo.vertexAttributeDescriptionCount = static_cast<uint32_t>(attributeDescriptions.size());
vertexInputInfo.pVertexAttributeDescriptions = attributeDescriptions.data();
With hardware instancing set up, we can modify our rendering loop to draw all instances in a single call:
The same five steps apply here; the difference is in step 4 where we bind the instance buffer and draw N instances:
-
Begin and describe attachments
-
Begin rendering, bind pipeline, set viewport/scissor
-
Update camera UBO (view/projection)
-
Bind per‑mesh vertex + index buffers and a per‑mesh instance buffer, then draw instanced
-
End rendering and present
void drawFrame() {
// ... (standard Vulkan frame setup)
// Begin command buffer recording
commandBuffer.begin({});
// Transition image layout for rendering
transition_image_layout(
imageIndex,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eColorAttachmentOptimal,
{},
vk::AccessFlagBits2::eColorAttachmentWrite,
vk::PipelineStageFlagBits2::eTopOfPipe,
vk::PipelineStageFlagBits2::eColorAttachmentOutput
);
// Set up rendering attachments
vk::ClearValue clearColor = vk::ClearColorValue(0.0f, 0.0f, 0.0f, 1.0f);
vk::ClearValue clearDepth = vk::ClearDepthStencilValue(1.0f, 0);
vk::RenderingAttachmentInfo colorAttachmentInfo = {
.imageView = swapChainImageViews[imageIndex],
.imageLayout = vk::ImageLayout::eColorAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.clearValue = clearColor
};
vk::RenderingAttachmentInfo depthAttachmentInfo = {
.imageView = depthImageView,
.imageLayout = vk::ImageLayout::eDepthStencilAttachmentOptimal,
.loadOp = vk::AttachmentLoadOp::eClear,
.storeOp = vk::AttachmentStoreOp::eStore,
.clearValue = clearDepth
};
vk::RenderingInfo renderingInfo = {
.renderArea = { .offset = { 0, 0 }, .extent = swapChainExtent },
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachmentInfo,
.pDepthAttachment = &depthAttachmentInfo
};
// Begin dynamic rendering
commandBuffer.beginRendering(renderingInfo);
// Bind pipeline
commandBuffer.bindPipeline(vk::PipelineBindPoint::eGraphics, graphicsPipeline);
// Set viewport and scissor
commandBuffer.setViewport(0, vk::Viewport(0.0f, 0.0f, static_cast<float>(swapChainExtent.width), static_cast<float>(swapChainExtent.height), 0.0f, 1.0f));
commandBuffer.setScissor(0, vk::Rect2D(vk::Offset2D(0, 0), swapChainExtent));
// Update view and projection in uniform buffer
UniformBufferObject ubo{};
ubo.view = camera.getViewMatrix();
ubo.proj = camera.getProjectionMatrix(swapChainExtent.width / (float)swapChainExtent.height);
ubo.proj[1][1] *= -1; // Vulkan's Y coordinate is inverted
// Copy to uniform buffer (per frame-in-flight)
memcpy(uniformBuffers[currentFrame].mapped, &ubo, sizeof(ubo));
// Bind descriptor set
commandBuffer.bindDescriptorSets(
vk::PipelineBindPoint::eGraphics,
pipelineLayout,
0,
1,
&descriptorSets[currentFrame],
0,
nullptr
);
// Render all nodes in the model with instancing
renderNodeInstanced(commandBuffer, model.nodes);
// End dynamic rendering
commandBuffer.endRendering();
// Transition image layout for presentation
transition_image_layout(
imageIndex,
vk::ImageLayout::eColorAttachmentOptimal,
vk::ImageLayout::ePresentSrcKHR,
vk::AccessFlagBits2::eColorAttachmentWrite,
{},
vk::PipelineStageFlagBits2::eColorAttachmentOutput,
vk::PipelineStageFlagBits2::eBottomOfPipe
);
// End command buffer recording
commandBuffer.end();
// ... (submit command buffer and present)
}
// Helper function to recursively render all nodes in the model with instancing
void renderNodeInstanced(const vk::raii::CommandBuffer& commandBuffer, const std::vector<Node*>& nodes) {
for (const auto node : nodes) {
// If this node has a mesh and an instance buffer, render it
if (!node->mesh.vertices.empty() && !node->mesh.indices.empty() &&
node->vertexBufferIndex >= 0 && node->indexBufferIndex >= 0 &&
node->instanceBufferIndex >= 0) {
// Set up push constants for material properties
PushConstantBlock pushConstants{};
if (node->mesh.materialIndex >= 0 && node->mesh.materialIndex < static_cast<int>(model.materials.size())) {
const auto& material = model.materials[node->mesh.materialIndex];
pushConstants.baseColorFactor = material.baseColorFactor;
pushConstants.metallicFactor = material.metallicFactor;
pushConstants.roughnessFactor = material.roughnessFactor;
pushConstants.baseColorTextureSet = material.baseColorTextureIndex >= 0 ? 1 : -1;
pushConstants.physicalDescriptorTextureSet = material.metallicRoughnessTextureIndex >= 0 ? 2 : -1;
pushConstants.normalTextureSet = material.normalTextureIndex >= 0 ? 3 : -1;
pushConstants.occlusionTextureSet = material.occlusionTextureIndex >= 0 ? 4 : -1;
pushConstants.emissiveTextureSet = material.emissiveTextureIndex >= 0 ? 5 : -1;
} else {
// Default material properties
pushConstants.baseColorFactor = glm::vec4(1.0f);
pushConstants.metallicFactor = 1.0f;
pushConstants.roughnessFactor = 1.0f;
pushConstants.baseColorTextureSet = 1;
pushConstants.physicalDescriptorTextureSet = -1;
pushConstants.normalTextureSet = -1;
pushConstants.occlusionTextureSet = -1;
pushConstants.emissiveTextureSet = -1;
}
// Update push constants
commandBuffer.pushConstants(pipelineLayout, vk::ShaderStageFlagBits::eFragment, 0, sizeof(PushConstantBlock), &pushConstants);
// Bind vertex and instance buffers
vk::Buffer vertexBuffers[] = {*vertexBuffers[node->vertexBufferIndex], *instanceBuffers[node->instanceBufferIndex]};
vk::DeviceSize offsets[] = {0, 0};
commandBuffer.bindVertexBuffers(0, 2, vertexBuffers, offsets);
commandBuffer.bindIndexBuffer(*indexBuffers[node->indexBufferIndex], 0, vk::IndexType::eUint32);
// Draw all instances of this mesh in a single call
commandBuffer.drawIndexed(
static_cast<uint32_t>(node->mesh.indices.size()),
static_cast<uint32_t>(objectInstances.size()), // Instance count
0, 0, 0
);
}
// Recursively render children
if (!node->children.empty()) {
renderNodeInstanced(commandBuffer, node->children);
}
}
}
This approach is much more efficient for rendering large numbers of similar objects, as it reduces the number of draw calls and uniform buffer updates.
Vertex Shader Modifications for Instancing
To support hardware instancing, we need to modify our vertex shader to use the instance data:
#version 450
// Vertex attributes
layout(location = 0) in vec3 inPosition;
layout(location = 1) in vec3 inNormal;
layout(location = 2) in vec3 inColor;
layout(location = 3) in vec2 inTexCoord;
// Instance attributes (model matrix, one row per attribute)
layout(location = 4) in vec4 instanceModelRow0;
layout(location = 5) in vec4 instanceModelRow1;
layout(location = 6) in vec4 instanceModelRow2;
layout(location = 7) in vec4 instanceModelRow3;
// Uniform buffer for view and projection matrices
layout(binding = 0) uniform UniformBufferObject {
mat4 model;
mat4 view;
mat4 proj;
// PBR parameters (not used in this shader but included for compatibility)
vec4 lightPositions[4];
vec4 lightColors[4];
vec4 camPos;
float exposure;
float gamma;
float prefilteredCubeMipLevels;
float scaleIBLAmbient;
} ubo;
// Output to fragment shader
layout(location = 0) out vec3 fragPosition;
layout(location = 1) out vec3 fragNormal;
layout(location = 2) out vec2 fragTexCoord;
layout(location = 3) out vec3 fragColor;
void main() {
// Reconstruct model matrix from instance attributes
mat4 instanceModel = mat4(
instanceModelRow0,
instanceModelRow1,
instanceModelRow2,
instanceModelRow3
);
// Calculate world position
vec4 worldPos = instanceModel * vec4(inPosition, 1.0);
// Output position in clip space
gl_Position = ubo.proj * ubo.view * worldPos;
// Pass data to fragment shader
fragPosition = worldPos.xyz;
fragNormal = mat3(instanceModel) * inNormal; // This is simplified; should use normal matrix
fragTexCoord = inTexCoord;
fragColor = inColor;
}
Beyond Basic Instancing: Material Variations
So far, we’ve focused on positioning multiple instances of the same model with the same material. In a real application, you might want to vary the materials as well:
// Create materials with variations for each instance
void createMaterialVariations() {
// Resize the materials vector to hold one material per instance
model.materials.resize(objectInstances.size());
for (size_t i = 0; i < objectInstances.size(); i++) {
// Get reference to this instance's material
Material& material = model.materials[i];
// Vary materials based on position or other factors
float distanceFromCenter = glm::length(objectInstances[i].position);
float angle = atan2(objectInstances[i].position.z, objectInstances[i].position.x);
// Vary color based on angle
float hue = (angle + glm::pi<float>()) / (2.0f * glm::pi<float>());
glm::vec3 color = hsvToRgb(glm::vec3(hue, 0.7f, 0.9f));
material.baseColorFactor = glm::vec4(color, 1.0f);
// Vary metallic/roughness based on distance
material.metallicFactor = glm::clamp(distanceFromCenter / 5.0f, 0.0f, 1.0f);
material.roughnessFactor = glm::clamp(1.0f - distanceFromCenter / 5.0f, 0.1f, 0.9f);
// Vary emissive strength for some objects
material.emissiveFactor = (i % 3 == 0) ? glm::vec3(1.0f) : glm::vec3(0.0f); // Every third object glows
}
// Update material indices for all nodes
for (auto node : model.linearNodes) {
// For demonstration, we'll assign materials based on node index
// In a real application, you might use more sophisticated logic
if (!node->mesh.vertices.empty()) {
size_t materialIndex = node->index % objectInstances.size();
node->mesh.materialIndex = static_cast<int>(materialIndex);
}
}
}
// Helper function to convert HSV to RGB
glm::vec3 hsvToRgb(glm::vec3 hsv) {
float h = hsv.x;
float s = hsv.y;
float v = hsv.z;
float r, g, b;
int i = floor(h * 6);
float f = h * 6 - i;
float p = v * (1 - s);
float q = v * (1 - f * s);
float t = v * (1 - (1 - f) * s);
switch (i % 6) {
case 0: r = v, g = t, b = p; break;
case 1: r = q, g = v, b = p; break;
case 2: r = p, g = v, b = t; break;
case 3: r = p, g = q, b = v; break;
case 4: r = t, g = p, b = v; break;
case 5: r = v, g = p, b = q; break;
}
return glm::vec3(r, g, b);
}
// To use these material variations, call createMaterialVariations() after loading the model
// The renderNode() and renderNodeInstanced() methods will automatically use the assigned materials
This approach allows for much more visual variety in your scene, even when using the same base model for all instances.
Conclusion and Next Steps
In this chapter, we’ve explored how to manage and render multiple objects in a 3D scene. We’ve covered:
-
Different approaches to organizing multiple objects
-
Performance considerations for multi-object rendering
-
Basic implementation of object instances
-
Advanced techniques like hardware instancing
-
Material variations for visual diversity
These techniques form the foundation for creating complex, visually rich 3D scenes. In the next chapter, we’ll build upon this foundation to implement a complete scene rendering system that integrates all the components we’ve developed so far.