vulkan_engine_tutorial
vulkan_engine_tutorial
This series targets readers who have completed the Vulkan Tutorial and feel comfortable with the
fundamentals. We’ll emphasize architectural concepts and design patterns over exhaustive API
permutations, so you develop an engine mindset rather than a collection of snippets. Expect to do
more independent work: fill in smaller gaps, experiment, and lean on the Vulkan Guide, Samples,
and Specification as primary references. If a topic feels too advanced, revisit the original tutorial
and return when ready.
What to Expect
The "Building a Simple Engine" series is designed as a starting point for your journey into engine
development, not a finishing point. We’ll cover:
1. Engine Architecture - How to structure your code for flexibility, maintainability, and
extensibility.
Throughout this series, we encourage you to experiment, extend the provided examples, and even
challenge some of our design decisions. The best way to learn engine development is by doing, and
sometimes by making (and learning from) mistakes.
1
Throughout our engine implementation, we’re using vk::raii dynamic rendering and C20 modules.
The vk::raii namespace provides Resource Acquisition Is Initialization (RAII) wrappers for Vulkan
objects, which helps with resource management and makes the code cleaner. Dynamic rendering
simplifies the rendering process by eliminating the need for explicit render passes and
framebuffers. C20 modules improve code organization, compilation times, and encapsulation
compared to traditional header files.
Let’s begin our journey into engine development with these chapters:
1. Engine Architecture - How to structure your code for flexibility, maintainability, and
extensibility.
5. Loading Models - More sophisticated approaches to handling models, textures, and other assets.
7. Tooling - CI/CD, Debugging, Crash minidump, Distribution, and Vulkan extensions for
robustness.
9. Advanced Topics - Short, focused tutorials that extend the Simple Engine with specific features
and optimizations.
[The Bistro scene - a detailed outdoor café environment demonstrating the engine's rendering
capabilities] | images/[Link]
$ cd attachments/simple_engine
2
$ ./fetch_bistro_assets.sh
> cd attachments\simple_engine
> fetch_bistro_assets.bat
The scripts use SSH (git@[Link]:gpx1000/[Link]) and fall back to HTTPS if SSH is
unavailable. If Git LFS is installed, large files will be pulled automatically.
Next, take advantage of the install_dependencies_* scripts to ensure you have all necessary
dependencies.
Once assets are available and dependencies are ready, build and run the Simple Engine examples
under attachments/simple_engine. See the later chapters for details on scene loading and
subsystems referenced by the example code. :pp: ++
While this series focuses primarily on building a rendering engine with Vulkan, the architectural
concepts we’ll discuss are applicable to both rendering engines and full game engines. We’ll clarify
which patterns are particularly well-suited for rendering-focused systems versus more general
game engine development.
We’ll start by taking a step back and considering the overall structure of our engine. A well-
designed architecture is crucial for creating a flexible, maintainable, and extensible rendering
system.
From there, we’ll dive into component systems, which provide the flexibility to build modular,
reusable code. You’ll see how component-based architecture allows different parts of your engine
to work together while remaining loosely coupled, making your codebase easier to maintain and
extend.
3
Resource management forms another crucial pillar of engine architecture. We’ll examine strategies
for efficiently handling textures, models, shaders, and other assets, ensuring your engine can scale
from simple scenes to complex, asset-heavy applications without performance bottlenecks.
The rendering pipeline design we’ll cover shows you how to create a flexible system that can
accommodate various rendering techniques and effects. This foundation will serve you well as you
add more advanced rendering features in later chapters.
Finally, we’ll implement event systems that enable clean communication between different engine
components. This event-driven approach reduces tight coupling and makes your engine more
maintainable as it grows in complexity.
Prerequisites
This chapter builds directly on the foundation established in the main Vulkan tutorial series, so
completing that series is essential. The architectural concepts we’ll discuss assume you’re
comfortable with Vulkan’s core rendering concepts and have hands-on experience implementing
them.
Beyond Vulkan knowledge, you’ll benefit from familiarity with object-oriented programming
principles, as modern engine architecture relies heavily on encapsulation, inheritance, and
polymorphism to manage complexity. Experience with common design patterns like Observer,
Factory, and Singleton will help you recognize when and why we apply these patterns in our engine
design.
Modern C++ features play a crucial role in our implementation approach. Smart pointers help us
manage resource lifetimes safely, templates enable flexible, reusable components, and other
C++11/14/17 features allow us to write more expressive and maintainable code. If you’re not
comfortable with these concepts, consider reviewing them before proceeding.
You should also be familiar with the following chapters from the main tutorial:
◦ Command buffers
◦ Graphics pipelines
• Uniform buffers
Maintainability emerges from clean separation of concerns—when each component has a clear,
focused responsibility, you can update or fix individual pieces without worrying about cascading
effects throughout the system. This becomes invaluable when debugging graphics issues or
4
implementing new rendering techniques.
Extensibility flows naturally from modular design. When your architecture provides clear
extension points and interfaces, adding new features becomes a matter of implementing new
components rather than rewriting existing systems. This allows your engine to evolve with your
project’s needs.
Reusability multiplies your development effort. Well-encapsulated components can move between
projects or serve different purposes within the same project. A thoughtfully designed material
system, for example, might work equally well for both game objects and UI elements.
Performance opportunities often emerge from architectural decisions made early in development.
Good architecture enables optimizations like multithreading (by avoiding tight coupling between
systems), batching (through predictable interfaces), and caching (via clear data flow patterns).
While premature optimization is dangerous, premature architecture decisions can make later
optimization impossible.
Let’s begin our exploration of engine architecture with an overview of common architectural
patterns used in modern rendering engines.
Before diving into specific patterns, it’s important to clarify that while we’re building a Vulkan-
based rendering engine in this tutorial, many of the architectural patterns we’ll discuss are
commonly used in both rendering engines and full game engines. A rendering engine focuses
primarily on graphics rendering capabilities, while a full game engine typically includes additional
systems like physics, audio, AI, and gameplay logic.
Layered Architecture
Layered architecture divides the system into distinct layers, each with a specific responsibility.
Typical layers include platform abstraction, resource management, rendering, scene management,
5
and application layers.
Key Benefits:
For detailed information and implementation examples, see the Appendix: Layered Architecture.
Data-Oriented Design
Data-Oriented Design (DOD) focuses on organizing data for efficient processing rather than
organizing code around objects. It emphasizes cache-friendly memory layouts and bulk processing
of data.
Key Benefits:
• Easier to parallelize
For detailed information and implementation examples, see the Appendix: Data-Oriented Design.
The Service Locator pattern provides a global point of access to services without coupling
consumers to concrete implementations.
Key Benefits:
For detailed information and implementation examples, see the Appendix: Service Locator Pattern.
Component-Based Architecture
Component-based architecture is widely used in modern game engines and forms the foundation of
our Vulkan rendering engine. It promotes composition over inheritance and allows for more
6
flexible entity design.
Diagram Legend:
• Boxes: Blue boxes represent Entities, orange boxes represent Components, and
green boxes represent Systems
• Line Types:
• Text: All text elements use dark colors for visibility in both light and dark modes
Key Concepts
Implementation Example
7
public:
// Methods to manipulate transform
};
public:
// Methods to render the mesh
};
// Entity class
class Entity {
private:
std::vector<std::unique_ptr<Component>> components;
public:
template<typename T, typename... Args>
T* AddComponent(Args&&... args) {
static_assert(std::is_base_of<Component, T>::value, "T must derive from
Component");
auto component = std::make_unique<T>(std::forward<Args>(args)...);
T* componentPtr = [Link]();
components.push_back(std::move(component));
return componentPtr;
}
template<typename T>
T* GetComponent() {
for (auto& component : components) {
if (T* result = dynamic_cast<T*>([Link]())) {
return result;
}
}
return nullptr;
}
8
Why We’re Focusing on Component Systems
For our Vulkan rendering engine, we’ve chosen to focus on component-based architecture for
several key reasons:
1. Flexibility for Graphics Features: Component systems allow us to easily add, remove, or swap
rendering features (like different shading models, post-processing effects, or lighting
techniques) without major refactoring.
3. Industry Standard: Most modern rendering engines and graphics frameworks use component-
based approaches because they provide the right balance of flexibility, maintainability, and
performance.
While other architectural patterns have their merits, component-based architecture provides the
best foundation for a modern, flexible rendering engine. That said, we’ll incorporate aspects of
other patterns where appropriate - using layered architecture for our overall engine structure,
data-oriented design for performance-critical systems, and service locators for cross-cutting
concerns.
Conclusion
We’ve provided a brief overview of common architectural patterns, with a focus on Component-
Based Architecture which we’ll use throughout this tutorial. For more detailed information about
other architectural patterns, including implementation examples and comparative analysis, see the
Appendix: Detailed Architectural Patterns.
In the next section, we’ll dive deeper into component systems and how to implement them
effectively in your engine.
9
focusing on component-based architecture for our Vulkan rendering engine. As we established,
component systems provide the ideal balance of flexibility, modularity, and performance for
modern rendering engines. Now, let’s dive deeper into how to implement effective component
systems in your rendering engine.
2. Code Duplication - Similar functionality may be duplicated across different branches of the
hierarchy.
4. Reusability - Components should be designed for reuse across different entity types.
// Forward declarations
class Entity;
10
Entity* owner = nullptr;
public:
virtual ~Component() = default;
// Entity class
class Entity {
private:
std::string name;
bool active = true;
std::vector<std::unique_ptr<Component>> components;
public:
explicit Entity(const std::string& entityName) : name(entityName) {}
void Initialize() {
for (auto& component : components) {
component->Initialize();
}
}
void Render() {
if (!active) return;
11
static_assert(std::is_base_of<Component, T>::value, "T must derive from
Component");
template<typename T>
T* GetComponent() {
for (auto& component : components) {
if (T* result = dynamic_cast<T*>([Link]())) {
return result;
}
}
return nullptr;
}
template<typename T>
bool RemoveComponent() {
for (auto it = [Link](); it != [Link](); ++it) {
if (dynamic_cast<T*>(it->get())) {
[Link](it);
return true;
}
}
return false;
}
};
// Transform component
// Handles the position, rotation, and scale of an entity in 3D space
// AffineTransform or "Pose" matrix.
class TransformComponent : public Component {
private:
glm::vec3 position = glm::vec3(0.0f);
glm::quat rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f); // Identity quaternion
glm::vec3 scale = glm::vec3(1.0f);
12
public:
void SetPosition(const glm::vec3& pos) {
position = pos;
transformDirty = true;
}
// Mesh component
// Manages the visual representation of an entity by handling its 3D mesh and material
class MeshComponent : public Component {
private:
Mesh* mesh = nullptr;
Material* material = nullptr;
public:
MeshComponent(Mesh* m, Material* mat) : mesh(m), material(mat) {}
13
void Render() override {
if (!mesh || !material) return;
// Camera component
// Defines a viewpoint for rendering the scene by managing view and projection
matrices
class CameraComponent : public Component {
private:
float fieldOfView = 45.0f;
float aspectRatio = 16.0f / 9.0f;
float nearPlane = 0.1f;
float farPlane = 1000.0f;
public:
void SetPerspective(float fov, float aspect, float near, float far) {
fieldOfView = fov;
aspectRatio = aspect;
nearPlane = near;
farPlane = far;
projectionDirty = true;
}
14
}
return glm::mat4(1.0f);
}
Component Communication
Components often need to communicate with each other. There are several approaches to
component communication:
Direct References
This approach is straightforward but creates tight coupling between components. Tight coupling
makes it challenging or impossible to create unit tests and properly test the engine, so this
approach should be avoided in production code.
Event System
15
// Specific event types
class CollisionEvent : public Event {
private:
Entity* entity1;
Entity* entity2;
public:
CollisionEvent(Entity* e1, Entity* e2) : entity1(e1), entity2(e2) {}
// Event system
class EventSystem {
private:
std::vector<EventListener*> listeners;
public:
void AddListener(EventListener* listener) {
listeners.push_back(listener);
}
16
// Register as event listener
GetEventSystem().AddListener(this);
}
~PhysicsComponent() override {
// Unregister as event listener
GetEventSystem().RemoveListener(this);
}
private:
EventSystem& GetEventSystem() {
// Get event system from somewhere (e.g., service locator)
static EventSystem eventSystem;
return eventSystem;
}
};
This approach decouples components but adds complexity. Crucially, a decoupled component is a
component that can be tested independently of any other component.
class Component {
public:
enum class State {
Uninitialized,
Initializing,
Active,
Destroying,
Destroyed
};
private:
State state = State::Uninitialized;
Entity* owner = nullptr;
public:
virtual ~Component() {
if (state != State::Destroyed) {
OnDestroy();
state = State::Destroyed;
17
}
}
void Initialize() {
if (state == State::Uninitialized) {
state = State::Initializing;
OnInitialize();
state = State::Active;
}
}
void Destroy() {
if (state == State::Active) {
state = State::Destroying;
OnDestroy();
state = State::Destroyed;
}
}
protected:
virtual void OnInitialize() {}
virtual void OnDestroy() {}
virtual void Update(float deltaTime) {}
virtual void Render() {}
public:
template<typename T>
static size_t GetTypeID() {
static size_t typeID = nextTypeID++;
return typeID;
}
18
};
size_t ComponentTypeIDSystem::nextTypeID = 0;
template<typename T>
static size_t GetTypeID() {
return ComponentTypeIDSystem::GetTypeID<T>();
}
};
public:
template<typename T, typename... Args>
T* AddComponent(Args&&... args) {
static_assert(std::is_base_of<Component, T>::value, "T must derive from
Component");
template<typename T>
T* GetComponent() {
size_t typeID = Component::GetTypeID<T>();
auto it = [Link](typeID);
if (it != [Link]()) {
return static_cast<T*>(it->second);
}
return nullptr;
19
}
template<typename T>
bool RemoveComponent() {
size_t typeID = Component::GetTypeID<T>();
auto it = [Link](typeID);
if (it != [Link]()) {
Component* componentPtr = it->second;
[Link](it);
Conclusion
Component systems provide a flexible and modular approach to building game objects in your
engine. By following the principles outlined in this section, you can create a robust component
system that:
In the next section, we’ll explore resource management systems, which are crucial for efficiently
handling assets in your engine.
20
explore strategies for managing various types of resources, such as textures, meshes, shaders, and
materials.
1. Loading and Unloading - Resources need to be loaded from disk and unloaded when no longer
needed.
3. Reference Counting - Track how many objects are using a resource to know when it can be
safely unloaded.
4. Hot Reloading - Allow resources to be updated while the application is running (useful during
development).
5. Streaming - Load resources asynchronously to avoid blocking the main thread. It’s good to
realize that "streaming" here is meant in terms of sending data from one location to another in
chunks. It’s the same type of algorithm that might be familiar in networking or internet
downloading, however, it only differs in the sense that it relates to transferring data between
the system memory and the GPU memory.
Resource Handles
Instead of directly exposing resource pointers, it’s often better to use resource handles:
// Resource handle
template<typename T>
class ResourceHandle {
private:
std::string resourceId;
ResourceManager* resourceManager;
public:
ResourceHandle() : resourceManager(nullptr) {}
T* Get() const {
if (!resourceManager) return nullptr;
return resourceManager->GetResource<T>(resourceId);
}
21
const std::string& GetId() const {
return resourceId;
}
// Convenience operators
T* operator->() const {
return Get();
}
1. Indirection - The resource manager can move resources in memory without invalidating
references.
3. Automatic Resource Management - The resource manager can track which resources are in
use.
22
public:
explicit Resource(const std::string& id) : resourceId(id) {}
virtual ~Resource() = default;
void Unload() {
doUnload();
loaded = false;
}
protected:
virtual bool doLoad() = 0;
virtual bool doUnload() = 0;
};
The Resource base class provides the foundational contract that all resource types must fulfill. The
resource ID serves as a unique identifier that allows the resource manager to locate and reference
specific resources without ambiguity. This string-based approach enables human-readable resource
names like "main_character_texture" or "level_1_audio" while maintaining the flexibility to use file
paths or other naming schemes.
The loading state management through the boolean flag provides essential lifecycle tracking. This
simple approach allows systems to quickly determine whether a resource is ready for use without
expensive validation checks. The virtual loading interface enables polymorphic behavior where
different resource types can implement their own specialized loading logic while presenting a
consistent interface to the management system.
// Resource manager
class ResourceManager {
private:
// Two-level storage system: organize by type first, then by unique identifier
// This approach enables type-safe resource access while maintaining efficient
lookup
23
std::unordered_map<std::type_index,
std::unordered_map<std::string, std::shared_ptr<Resource>>>
resources;
The storage architecture uses a sophisticated two-level mapping system that solves several critical
problems in resource management. The outer map keyed by std::type_index ensures complete type
separation, preventing name collisions between different resource types. For example, you could
have both a texture named "stone" and a sound effect named "stone" without conflicts, as they’re
stored in separate type-specific containers.
The inner maps provide O(1) average-case lookup performance for individual resources, which is
crucial when the rendering system needs to access hundreds or thousands of resources per frame.
The use of std::shared_ptr provides automatic memory management and enables safe sharing of
resources between different systems without manual lifetime management.
The reference counting system operates independently of the shared_ptr reference counting to
provide application-level lifecycle control. This separation allows the resource manager to
implement custom policies for resource retention and cleanup that go beyond simple memory
management, such as keeping frequently used resources loaded even when not immediately
referenced.
public:
template<typename T>
ResourceHandle<T> Load(const std::string& resourceId) {
static_assert(std::is_base_of<Resource, T>::value, "T must derive from
Resource");
if (it != [Link]()) {
// Resource exists in cache - increment reference count and return handle
24
refCounts[resourceId]++;
return ResourceHandle<T>(resourceId, this);
}
The loading logic implements a sophisticated caching strategy that balances performance with
memory efficiency. The cache-first approach prevents redundant I/O operations and resource
processing, which can be expensive for large textures, complex meshes, or compiled shaders. This
strategy is particularly important in rendering engines where the same resources may be
referenced by multiple objects or systems.
The template-based design with compile-time type checking ensures type safety while maintaining
the flexibility to work with any resource type that derives from the base Resource class. The static
assertion provides clear error messages during development, preventing runtime type errors that
could be difficult to debug in complex rendering scenarios.
Error handling follows the principle of graceful degradation, where loading failures return invalid
handles rather than throwing exceptions or corrupting the resource cache. This approach allows
rendering systems to continue operating with fallback resources or alternative rendering paths
when specific assets are unavailable or corrupted.
template<typename T>
T* GetResource(const std::string& resourceId) {
// Access type-specific resource container using compile-time type information
auto& typeResources = resources[std::type_index(typeid(T))];
auto it = [Link](resourceId);
if (it != [Link]()) {
// Resource found - perform safe downcast and return typed pointer
25
return static_cast<T*>(it->[Link]());
}
template<typename T>
bool HasResource(const std::string& resourceId) {
// Efficient existence check without resource access overhead
auto resourceIt = [Link](std::type_index(typeid(T)));
return resourceIt != [Link]();
}
The resource access interface prioritizes safety and performance in equal measure. The template-
based approach ensures that clients always receive correctly typed resource pointers, eliminating
the need for manual casting and reducing the potential for type-related runtime errors. The
static_cast is safe because the type_index-based storage guarantees that only objects of type T are
stored in each type-specific container.
The existence check provides an efficient way to validate resource availability without the
overhead of full resource access. This capability is valuable for conditional rendering logic, where
systems can choose alternative rendering paths based on resource availability without triggering
expensive cache misses or I/O operations.
26
}
}
void UnloadAll() {
// Emergency cleanup method for system shutdown or major state changes
for (auto& [type, typeResources] : resources) {
for (auto& [id, resource] : typeResources) {
resource->Unload(); // Ensure all resources clean up properly
}
[Link](); // Clear type-specific containers
}
[Link](); // Reset all reference counts
}
};
The reference counting system provides automatic garbage collection for resources that are no
longer actively used. This approach prevents memory leaks while avoiding the overhead of
constantly monitoring resource usage across the entire application. The decrement-and-check
pattern ensures that resources are unloaded immediately when they become unused, helping to
keep memory usage optimal.
The cleanup process is designed to be thorough and safe, ensuring that resources have the
opportunity to properly release their internal data (GPU memory, file handles, etc.) before being
removed from the cache. This two-phase cleanup approach prevents resource leaks and maintains
system stability even under error conditions.
The global unload functionality provides a safety valve for major state transitions like level changes
or application shutdown, where you want to ensure all resources are properly cleaned up
regardless of their reference counts. This capability is essential for preventing resource leaks that
could accumulate over long application runs.
27
break this implementation into logical phases that demonstrate both the technical challenges and
design solutions.
// Texture resource
class Texture : public Resource {
private:
// Core Vulkan GPU resources for texture representation
vk::Image image; // GPU image object containing pixel data
vk::DeviceMemory memory; // GPU memory allocation backing the image
vk::DeviceSize offset; // Offset within the memory allocation for this
texture
vk::ImageView imageView; // Shader-accessible view into the image
vk::Sampler sampler; // Sampling configuration (filtering, wrapping,
etc.)
public:
explicit Texture(const std::string& id) : Resource(id) {}
~Texture() override {
Unload(); // Ensure proper cleanup when object is destroyed
}
The Vulkan texture pipeline requires four distinct GPU objects that work together to provide
complete texture functionality. The vk::Image represents the actual pixel data storage on the GPU,
while vk::DeviceMemory provides the backing memory allocation. The separation between image
and memory allows for advanced memory management techniques like suballocation and memory
pooling.
The vk::ImageView serves as the interface between shaders and the image data, defining how
shaders interpret the pixel format, mipmap levels, and array layers. The vk::Sampler encapsulates
filtering and addressing modes that control how the GPU interpolates between pixels and handles
texture coordinates outside the [0,1] range. This separation of concerns allows the same image to be
used with different sampling configurations simultaneously.
28
Texture Resource: Loading Pipeline and Data
Acquisition
Next, we implement the texture loading pipeline that transforms disk-based image files into GPU-
ready resources through careful error handling and format conversion.
// Step 2b: Load raw image data from disk with format detection
unsigned char* data = LoadImageData(filePath, &width, &height, &channels);
if (!data) {
return false; // Failed to load - return failure without partial
state
}
// Step 2c: Transform raw pixel data into Vulkan GPU resources
CreateVulkanImage(data, width, height, channels);
The loading pipeline follows a clear sequence that handles the complex transformation from file-
based data to GPU resources. The file path construction assumes a standard naming convention
that maps resource IDs to physical files, enabling consistent asset organization across the project.
Using the KTX format provides several advantages including GPU-native format storage, mipmap
support, and compression compatibility.
Error handling at each stage prevents partial loading states that could leave the resource in an
inconsistent condition. If image data loading fails, the function returns immediately without
creating GPU resources, ensuring that the Texture object remains in a clean, unloaded state. This
approach prevents resource leaks and makes error recovery more predictable for calling code.
The temporary nature of the CPU-side image data reflects the typical texture loading workflow
where pixel data is needed only long enough to upload to the GPU. Once the GPU resources are
created and populated, the CPU copy can be safely discarded, reducing memory pressure and
preventing unnecessary data duplication.
29
applications. Please note that if you have vk::raii objects, the destructor of the vk::raii objects will
automatically handle the cleanup of the GPU resources. If, however, you have a vk::Device object,
you must manually destroy the GPU resources to prevent memory leaks. Additionally, you need to
have initialized the defaultDispatcher for the vk::Device object types. In the event that you are
using vk::Device objects, the Unload function below details explicit releasing of the objects.
The cleanup sequence follows Vulkan’s object dependency requirements, where objects must be
destroyed in reverse order of their creation to avoid validation errors and potential driver crashes.
The sampler and image view depend on the image, so they must be destroyed first. The memory
allocation is released last since it backs the image object.
The conditional cleanup check prevents double-destruction errors that could occur if Unload() is
called multiple times. This safety mechanism is particularly important in resource management
systems where multiple code paths might trigger cleanup operations during error handling or
shutdown sequences.
The public getter interface provides controlled access to the internal Vulkan resources without
exposing the implementation details or allowing external code to modify the resource state. This
encapsulation ensures that the Texture object maintains complete control over its GPU resources
throughout their lifetime.
30
of image loading and Vulkan resource creation.
private:
unsigned char* LoadImageData(const std::string& filePath, int* width, int* height,
int* channels) {
// Implementation using stb_image or ktx library
// This method abstracts the details of different image format support
// and provides a consistent interface for pixel data loading
// ...
return nullptr; // Placeholder
}
void CreateVulkanImage(unsigned char* data, int width, int height, int channels) {
// Implementation to create Vulkan image, allocate memory, and upload data
// This involves complex Vulkan operations including:
// - Format selection based on channel count and data type
// - Memory allocation with appropriate usage flags
// - Image creation with optimal tiling and layout
// - Data upload via staging buffers for efficiency
// - Image view creation for shader access
// - Sampler creation with appropriate filtering settings
// ...
}
vk::Device GetDevice() {
// Get device from somewhere (e.g., singleton or parameter)
// Production code would use dependency injection or service location
// to provide the Vulkan device handle without tight coupling
// ...
return vk::Device(); // Placeholder
}
};
The helper methods abstract away the platform-specific and library-specific details of texture
loading and GPU resource creation. The LoadImageData method encapsulates support for different
image formats and loading libraries, providing a consistent interface regardless of whether you’re
using STB Image, DevIL, FreeImage, or other image loading solutions.
The CreateVulkanImage method represents one of the most complex operations in texture
management, involving multiple Vulkan API calls with careful attention to format selection,
memory alignment, and performance optimization. Production implementations typically use
staging buffers for efficient data transfer and may include mipmap generation, format conversion,
31
and compression support.
The device access pattern shown here as a placeholder represents a common design challenge in
resource management systems: how to provide access to core engine services without creating tight
coupling. Production systems typically use dependency injection, service locators, or context
objects to provide access to the Vulkan device and other core resources.
// Mesh resource
class Mesh : public Resource {
private:
// Vertex data management - stores per-vertex attributes like position, normal, UV
coordinates
vk::Buffer vertexBuffer; // GPU buffer containing vertex attribute
data
vk::DeviceMemory vertexBufferMemory; // GPU memory backing the vertex buffer
vk::DeviceSize vertexBufferOffset; // Offset within the memory allocation for
vertex buffer
uint32_t vertexCount = 0; // Number of vertices in this mesh
public:
explicit Mesh(const std::string& id) : Resource(id) {}
~Mesh() override {
Unload(); // Ensure GPU resources are cleaned up
}
The mesh resource architecture separates vertex and index data into distinct GPU buffers,
32
following modern graphics API best practices. Vertex buffers contain per-vertex attributes such as
positions, normals, texture coordinates, and color information, while index buffers define how
vertices connect to form triangles. This separation enables efficient vertex reuse, where a single
vertex can be referenced by multiple triangles, significantly reducing memory usage for typical 3D
models.
The buffer-memory pairing reflects Vulkan’s explicit memory management model, where buffer
objects and their backing memory allocations are managed separately. This approach provides fine-
grained control over memory allocation strategies, enabling techniques like memory pooling,
suballocation, and custom alignment requirements that can significantly impact rendering
performance.
The count tracking serves dual purposes: it provides essential information for rendering calls that
specify how many vertices or indices to process, and it enables validation and debugging by
allowing systems to verify that buffer contents match expected data sizes.
// Step 2b: Parse geometric data from file format into CPU-accessible
structures
std::vector<Vertex> vertices; // Temporary CPU storage for vertex
attributes
std::vector<uint32_t> indices; // Temporary CPU storage for triangle
indices
if (!LoadMeshData(filePath, vertices, indices)) {
return false; // Failed to parse file - abort loading
}
// Step 2c: Transform CPU data into optimized GPU buffer resources
CreateVertexBuffer(vertices); // Upload vertex attributes to GPU
CreateIndexBuffer(indices); // Upload triangle connectivity to GPU
The loading pipeline follows a structured approach that separates file parsing from GPU resource
33
creation, enabling better error handling and code reusability. The choice of glTF format provides
several advantages including industry-standard mesh representation, embedded material
information, and support for advanced features like skeletal animations and morph targets.
The temporary CPU-side storage approach enables validation and processing of geometric data
before committing to GPU resources. This intermediate step allows for mesh optimization
techniques such as vertex cache optimization, triangle strip generation, or level-of-detail processing
that can significantly improve rendering performance.
The metadata caching strategy stores frequently accessed information locally to avoid expensive
GPU queries during rendering. These counts are essential for draw calls, where the GPU needs to
know exactly how many vertices to process and how many triangles to render, making local storage
much more efficient than querying the GPU buffers repeatedly.
// Phase 3b: Destroy buffers and free GPU memory in proper sequence
// Index resources cleaned up first to maintain clear dependency order
[Link](indexBuffer); // Destroy index buffer object
[Link](indexBufferMemory); // Release index buffer memory
34
vk::Buffer GetIndexBuffer() const { return indexBuffer; }
uint32_t GetVertexCount() const { return vertexCount; }
uint32_t GetIndexCount() const { return indexCount; }
The cleanup sequence ensures that GPU resources are properly released without causing validation
errors or driver instability. While Vulkan doesn’t impose strict ordering requirements for buffer
destruction, following a consistent pattern (index resources before vertex resources) makes the
code more predictable and easier to debug when issues arise.
The conditional cleanup check prevents double-destruction scenarios that could occur during error
handling or when multiple systems attempt to clean up resources simultaneously. This safety
mechanism is particularly important in complex rendering systems where resource ownership
might be shared between multiple components.
The public access interface provides controlled access to internal GPU resources while maintaining
encapsulation. These getter methods enable rendering systems to bind the appropriate buffers for
draw operations while preventing external code from accidentally modifying the mesh’s internal
state or triggering premature resource destruction.
private:
bool LoadMeshData(const std::string& filePath, std::vector<Vertex>& vertices,
std::vector<uint32_t>& indices) {
// Implementation using tinygltf or similar library
// This method handles the complex task of:
// - Opening and validating the mesh file format
// - Parsing vertex attributes (positions, normals, UVs, etc.)
// - Extracting index data that defines triangle connectivity
// - Converting from file format to engine-specific vertex structures
// - Performing validation to ensure data integrity
// ...
return true; // Placeholder
}
35
}
vk::Device GetDevice() {
// Get device from somewhere (e.g., singleton or parameter)
// Production implementations typically use dependency injection
// to avoid tight coupling between resource classes and core engine systems
// ...
return vk::Device(); // Placeholder
}
};
The helper methods encapsulate the most complex aspects of mesh resource management, hiding
implementation details while providing clean interfaces for the core loading and creation logic. The
LoadMeshData method abstracts the intricacies of different mesh file formats and parsing libraries,
enabling the resource system to support multiple formats through a consistent interface.
The buffer creation methods represent some of the most performance-critical code in the mesh
resource system, as inefficient GPU memory management can significantly impact rendering
performance. Production implementations typically use staging buffers for data upload, implement
memory pooling to reduce allocation overhead, and carefully select memory types based on GPU
architecture characteristics.
The device access pattern illustrates a common architectural challenge in resource management
systems: balancing convenience with loose coupling. While direct access to global singletons can
simplify implementation, production systems typically use dependency injection or service locator
patterns to maintain testability and flexibility while providing access to core engine services.
// Shader resource
class Shader : public Resource {
private:
vk::ShaderModule shaderModule;
vk::ShaderStageFlagBits stage;
36
public:
Shader(const std::string& id, vk::ShaderStageFlagBits shaderStage)
: Resource(id), stage(shaderStage) {}
~Shader() override {
Unload();
}
return Resource::Load();
}
[Link](shaderModule);
Resource::Unload();
}
}
private:
37
bool ReadFile(const std::string& filePath, std::vector<char>& buffer) {
// Implementation to read binary file
// ...
return true; // Placeholder
}
vk::Device GetDevice() {
// Get device from somewhere (e.g., singleton or parameter)
// ...
return vk::Device(); // Placeholder
}
};
// Load resources
auto texture = [Link]<Texture>("brick");
auto mesh = [Link]<Mesh>("cube");
auto vertexShader = [Link]<Shader>("basic",
vk::ShaderStageFlagBits::eVertex);
auto fragmentShader = [Link]<Shader>("basic",
vk::ShaderStageFlagBits::eFragment);
// Use resources
if (texture && mesh && vertexShader && fragmentShader) {
// Create material using shaders
Material material(vertexShader, fragmentShader);
38
[Link]([Link]());
For large resources, it’s often beneficial to load them asynchronously to avoid blocking the main
thread:
class AsyncResourceManager {
private:
ResourceManager resourceManager;
std::thread workerThread;
std::queue<std::function<void()>> taskQueue;
std::mutex queueMutex;
std::condition_variable condition;
bool running = false;
public:
AsyncResourceManager() {
Start();
}
~AsyncResourceManager() {
Stop();
}
void Start() {
running = true;
workerThread = std::thread([this]() {
WorkerThread();
});
}
void Stop() {
{
std::lock_guard<std::mutex> lock(queueMutex);
running = false;
}
condition.notify_one();
if ([Link]()) {
[Link]();
}
}
template<typename T>
void LoadAsync(const std::string& resourceId,
std::function<void(ResourceHandle<T>)> callback) {
std::lock_guard<std::mutex> lock(queueMutex);
39
[Link]([this, resourceId, callback]() {
auto handle = [Link]<T>(resourceId);
callback(handle);
});
condition.notify_one();
}
private:
void WorkerThread() {
while (running) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queueMutex);
[Link](lock, [this]() {
return ![Link]() || !running;
});
task = std::move([Link]());
[Link]();
}
task();
}
}
};
// Usage example
AsyncResourceManager asyncResourceManager;
[Link]<Texture>("large_texture", [](ResourceHandle<Texture>
texture) {
// This callback will be called when the texture is loaded
if (texture) {
std::cout << "Texture loaded successfully!" << std::endl;
} else {
std::cout << "Failed to load texture." << std::endl;
}
});
Resource Streaming
For very large resources like high-resolution textures or detailed meshes, you might want to
implement streaming:
1. Level of Detail (LOD) - Load lower-resolution versions first, then progressively load higher-
resolution versions.
40
2. Texture Streaming - Load mipmap levels progressively, starting with the smallest.
3. Mesh Streaming - Load simplified versions of meshes first, then add detail.
Hot Reloading
During development, it’s useful to be able to update resources without restarting the application:
public:
HotReloadResourceManager() {
StartWatcher();
}
~HotReloadResourceManager() {
StopWatcher();
}
void StartWatcher() {
running = true;
watcherThread = std::thread([this]() {
WatcherThread();
});
}
void StopWatcher() {
running = false;
if ([Link]()) {
[Link]();
}
}
template<typename T>
ResourceHandle<T> Load(const std::string& resourceId) {
auto handle = ResourceManager::Load<T>(resourceId);
return handle;
}
41
private:
template<typename T>
std::string GetFilePath(const std::string& resourceId) {
// Determine file path based on resource type and ID
if constexpr (std::is_same_v<T, Texture>) {
return "textures/" + resourceId + ".ktx";
} else if constexpr (std::is_same_v<T, Mesh>) {
return "models/" + resourceId + ".gltf";
} else if constexpr (std::is_same_v<T, Shader>) {
// Simplified for example
return "shaders/" + resourceId + ".spv";
} else {
return "";
}
}
void WatcherThread() {
while (running) {
// Check for file changes
for (auto& [filePath, timestamp] : fileTimestamps) {
try {
auto currentTimestamp =
std::filesystem::last_write_time(filePath);
if (currentTimestamp != timestamp) {
// File has changed, reload resource
ReloadResource(filePath);
timestamp = currentTimestamp;
}
} catch (const std::filesystem::filesystem_error& e) {
// File doesn't exist or can't be accessed
}
}
Conclusion
A well-designed resource management system is crucial for efficiently handling assets in your
rendering engine. By implementing the techniques described in this section, you can create a
42
system that:
In the next section, we’ll explore rendering pipeline design, which will build upon the resource
management system to create a flexible and efficient rendering system.
Diagram Legend:
1. Scene Culling - Determine which objects are visible and need to be rendered.
2. Render Pass Management - Organize rendering into passes with specific purposes.
43
3. Command Generation - Generate commands for the GPU to execute.
Supporting components like Rendergraphs help manage dependencies between render passes,
while Synchronization primitives ensure correct execution order. Different rendering techniques
(Deferred, Forward+, PBR) can be implemented within this pipeline architecture.
Scene Culling
Before rendering, we need to determine which objects are visible to the camera. This process is
called culling and can significantly improve performance by reducing the number of objects that
need to be rendered.
class CullingSystem {
private:
Camera* camera;
std::vector<Entity*> visibleEntities;
public:
explicit CullingSystem(Camera* cam) : camera(cam) {}
44
if (!camera) return;
In this tutorial, we use Vulkan’s dynamic rendering feature with vk::raii instead of traditional
render passes. Dynamic rendering simplifies the rendering process by allowing us to begin and end
rendering operations with a single command, without explicitly creating VkRenderPass and
VkFramebuffer objects.
45
What is a Rendergraph?
46
(products)
std::function<void(vk::raii::CommandBuffer&)> executeFunc; // The actual
rendering code
};
public:
explicit Rendergraph(vk::raii::Device& dev) : device(dev) {}
The data structure architecture reflects the core philosophy of rendergraphs: treating rendering as
a series of transformations on resources rather than imperative GPU commands. The Resource
structure encapsulates everything needed to create and manage GPU resources, while the Pass
structure defines rendering operations in terms of their resource dependencies rather than their
implementation details.
This approach enables powerful optimizations like automatic memory aliasing (where multiple
resources share the same memory if their lifetimes don’t overlap) and optimal resource layout
transitions. The separation between resource description and actual GPU objects allows the
rendergraph to make informed decisions about resource management during the compilation
phase.
47
vk::ImageLayout finalLayout) {
Resource resource;
[Link] = name; // Store human-readable identifier
[Link] = format; // Define pixel format and bit depth
[Link] = extent; // Set resource dimensions
[Link] = usage; // Specify intended usage patterns
[Link] = initialLayout; // Define starting layout state
[Link] = finalLayout; // Define required ending state
The registration interface enables declarative rendergraph construction where developers specify
what they want to achieve rather than how to achieve it. This high-level approach allows the
rendergraph to analyze the entire rendering pipeline before making resource allocation and
scheduling decisions.
The deferred execution model (where passes store function objects rather than immediate GPU
commands) enables powerful compile-time optimizations. The rendergraph can reorder passes,
merge compatible operations, and optimize resource usage based on the complete dependency
graph rather than making local decisions for each pass.
48
// Rendergraph compilation - transforms declarative descriptions into executable
pipeline
// This method performs dependency analysis, resource allocation, and execution
planning
void Compile() {
// Dependency Graph Construction
// Build bidirectional dependency relationships between passes
std::vector<std::vector<size_t>> dependencies([Link]()); // What each
pass depends on
std::vector<std::vector<size_t>> dependents([Link]()); // What depends
on each pass
49
// Cycle detection - circular dependency found
throw std::runtime_error("Cycle detected in rendergraph");
}
if (visited[node]) {
return; // Already processed this node and its dependencies
}
The dependency analysis represents the mathematical core of the rendergraph system,
transforming an abstract description of rendering operations into a concrete execution plan. The
bidirectional dependency tracking enables efficient graph traversal algorithms and provides the
information needed for automatic synchronization.
The topological sort algorithm ensures that passes execute in dependency order while detecting
impossible circular dependencies that would represent logical errors in the rendering pipeline
design. This compile-time validation catches many common rendering pipeline bugs before they
manifest as runtime GPU synchronization issues.
50
// The dependent pass will wait on this semaphore before executing
semaphores.emplace_back([Link]({}));
semaphoreSignalWaitPairs.emplace_back(dep, i); // (producer,
consumer) pair
}
}
vk::MemoryAllocateInfo allocInfo;
[Link]([Link]) // Required
memory size
.setMemoryTypeIndex(FindMemoryType([Link],
51
memory to image
52
// Synchronization Setup - Collect Dependencies for Current Pass
// Determine what this pass must wait for before executing
[Link]();
[Link]();
waitStages.push_back(vk::PipelineStageFlagBits::eColorAttachmentOutput); // Wait at
output stage
}
}
// Collect semaphores that this pass will signal for dependent passes
[Link]();
for (size_t i = 0; i < [Link](); ++i) {
if (semaphoreSignalWaitPairs[i].first == passIdx) {
// Other passes depend on this pass's completion
signalSemaphores.push_back(*semaphores[i]);
// Signal completion for dependents
}
}
vk::ImageMemoryBarrier barrier;
[Link]([Link])
// Current resource layout
.setNewLayout(vk::ImageLayout::eShaderReadOnlyOptimal)
// Target layout for reading
.setSrcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED)
// No queue family transfer
.setDstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED)
.setImage(*[Link])
// Target image
.setSubresourceRange({vk::ImageAspectFlagBits::eColor, 0, 1, 0,
1}) // Full image range
.setSrcAccessMask(vk::AccessFlagBits::eMemoryWrite)
// Previous write access
.setDstAccessMask(vk::AccessFlagBits::eShaderRead);
53
// Required read access
vk::ImageMemoryBarrier barrier;
[Link]([Link])
// Current layout state
.setNewLayout(vk::ImageLayout::eColorAttachmentOptimal)
// Optimal for color output
.setSrcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED)
.setDstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED)
.setImage(*[Link])
.setSubresourceRange({vk::ImageAspectFlagBits::eColor, 0, 1, 0,
1})
.setSrcAccessMask(vk::AccessFlagBits::eMemoryRead)
// Previous read access
.setDstAccessMask(vk::AccessFlagBits::eColorAttachmentWrite);
// Required write access
54
for (const auto& output : [Link]) {
auto& resource = resources[output];
vk::ImageMemoryBarrier barrier;
[Link](vk::ImageLayout::eColorAttachmentOptimal)
// Current writable layout
.setNewLayout([Link])
// Required final layout
.setSrcQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED)
.setDstQueueFamilyIndex(VK_QUEUE_FAMILY_IGNORED)
.setImage(*[Link])
.setSubresourceRange({vk::ImageAspectFlagBits::eColor, 0, 1, 0,
1})
.setSrcAccessMask(vk::AccessFlagBits::eColorAttachmentWrite)
// Previous write operations
.setDstAccessMask(vk::AccessFlagBits::eMemoryRead);
// Enable subsequent reads
vk::SubmitInfo submitInfo;
[Link](static_cast<uint32_t>([Link]())) //
Dependencies to wait for
.setPWaitSemaphores([Link]())
// Dependency semaphores
.setPWaitDstStageMask([Link]())
// Pipeline stages to wait at
.setCommandBufferCount(1)
// Single command buffer
.setPCommandBuffers(&*commandBuffer)
// Command buffer to execute
.setSignalSemaphoreCount(static_cast<uint32_t>([Link]())) //
Semaphores to signal
.setPSignalSemaphores([Link]());
55
// Signal semaphores
The execution engine represents the culmination of the rendergraph system, where all the analysis
and preparation work pays off in coordinated GPU execution. The automatic synchronization
ensures that passes execute in the correct order without manual barrier management, while the
automatic layout transitions handle the complex state management that Vulkan requires for
optimal performance.
This execution model demonstrates the power of the rendergraph abstraction: complex multi-pass
rendering with dozens of resources and dependencies gets reduced to a simple Execute() call, with
all the synchronization and resource management handled automatically based on the declarative
pass and resource descriptions.
private:
uint32_t FindMemoryType(uint32_t typeFilter, vk::MemoryPropertyFlags properties) {
// Implementation to find suitable memory type
// ...
return 0; // Placeholder
}
};
Vulkan Synchronization
Synchronization in Vulkan is one of the most complicated topics. Vulkan provides several
synchronization primitives to ensure correct execution order and memory visibility:
2. Parallel Execution: Modern GPUs execute commands in parallel, which can lead to race
conditions without proper synchronization.
3. Memory Visibility: Changes made by one operation may not be visible to another without
proper barriers.
The vulkan tutorial includes a more detailed discussion of synchronization, the proper uses of the
56
primitives described above.
• Synchronization
• Frames In Flight
• Compute Shader
• Multithreading
Pipeline Barriers
Pipeline barriers are one of the most important synchronization primitives in Vulkan. They ensure
that operations before the barrier are complete before operations after the barrier begin, and they
can also perform layout transitions for images. Let’s examine how to implement proper image
layout transitions through a comprehensive breakdown of the process.
The image memory barrier serves as the fundamental mechanism for coordinating both memory
access patterns and image layout transitions in Vulkan. Unlike OpenGL where these operations
happen automatically, Vulkan requires explicit specification of when and how image layouts
57
change. The queue family settings using VK_QUEUE_FAMILY_IGNORED indicate that we’re not
transferring ownership between different queue families, which simplifies the synchronization
requirements.
The subresource range specification defines exactly which portions of the image are affected by
this barrier. In this case, we’re transitioning the entire color aspect of the image across all mip
levels and array layers, which is the most common scenario for basic texture operations.
58
sourceStage = vk::PipelineStageFlagBits::eTransfer; // Transfer
operations must complete
destinationStage = vk::PipelineStageFlagBits::eFragmentShader; // Fragment
shaders can access
} else {
// Handle unsupported transition combinations
// Production code would include additional common transition patterns
throw std::invalid_argument("Unsupported layout transition!");
}
The pipeline stage and access mask configuration represents the heart of Vulkan’s explicit
synchronization model. By specifying exactly which operations must complete before the barrier
(source stage) and which operations can begin after the barrier (destination stage), we create
precise control over GPU execution timing without unnecessary stalls.
The access mask patterns define the memory visibility requirements for each transition. The
transition from "no access" to "transfer write" enables efficient image upload without waiting for
non-existent previous operations. The transition from "transfer write" to "shader read" ensures that
uploaded data is fully written and visible before shaders attempt to sample from the texture.
The pipeline barrier submission represents the culmination of our synchronization planning,
59
where the configured barrier becomes part of the GPU’s command stream. The ByRegion
dependency flag enables GPU optimizations for cases where different regions of the image can be
processed independently, potentially improving performance on tile-based renderers and other
advanced GPU architectures.
The parameter structure clearly separates different types of barriers (memory, buffer, and image),
allowing the GPU driver to apply the most efficient synchronization strategy for each resource type.
In our case, we only need image barrier synchronization, so the other barrier arrays remain empty,
avoiding unnecessary overhead.
Semaphores and fences are used for coarser-grained synchronization between different stages of
the rendering pipeline and between CPU and GPU operations. Let’s examine how to properly
coordinate frame rendering using these synchronization primitives through a comprehensive
breakdown of the frame rendering process.
The fence-based synchronization serves as the primary mechanism for CPU-GPU coordination in
frame rendering. By waiting for the previous frame’s fence, we ensure that the GPU has completed
processing the previous frame before beginning work on the current frame. This prevents the CPU
from overwhelming the GPU with work and helps maintain stable frame pacing.
The fence reset operation prepares the synchronization object for the current frame. Fences are
binary signals that transition from unsignaled to signaled state when associated GPU work
completes, so they must be explicitly reset before reuse. The timeout value UINT64_MAX effectively
means "wait indefinitely," which is appropriate for frame synchronization where we must ensure
completion.
60
Frame Rendering: Swapchain Image Acquisition and
Resource Preparation
Next, we acquire the next available swapchain image for rendering, coordinating with the
presentation engine to ensure proper image availability.
The swapchain image acquisition represents a critical synchronization point between the
rendering system and the presentation engine. The operation may block if no images are currently
available (for example, if all swapchain images are being displayed or processed), making it
essential for frame pacing. The semaphore signaled by this operation will be used later to ensure
that rendering doesn’t begin until the acquired image is truly available for modification.
The indefinite timeout ensures that acquisition will eventually succeed even under heavy load or
when dealing with variable refresh rate displays. The acquired image index determines which
swapchain image becomes the render target for this frame, affecting descriptor set bindings and
render pass configuration in the subsequent command recording phase.
61
{vk::PipelineStageFlagBits::eColorAttachmentOutput};
The signal semaphore communicates completion of rendering work to other operations that
depend on the rendered result, such as presentation. The fence provides CPU-visible completion
notification, enabling the frame pacing logic we saw in earlier. This three-way synchronization
(wait semaphore, signal semaphore, and fence) creates a complete coordination system for the
frame rendering pipeline.
62
.setPSwapchains(&*swapchain) // Target
swapchain for presentation
.setPImageIndices(&imageIndex); // Present
the image we rendered to
The presentation phase completes the frame rendering cycle by coordinating the transfer from
rendering to display. The wait semaphore ensures that presentation doesn’t begin until all
rendering operations have completed, preventing the display of partially rendered frames. This
synchronization is crucial because presentation and rendering may occur on different GPU queues
with different timing characteristics.
The presentation operation itself is asynchronous—it queues the presentation request and returns
immediately, allowing the CPU to begin preparing the next frame. The presentation engine handles
the actual coordination with the display hardware, including timing synchronization with refresh
rates and managing the transition of the swapchain image from "rendering" to "displaying" to
"available for reuse" states.
1. Automatic Dependency Tracking: The rendergraph knows which passes depend on which
resources, so it can automatically insert the necessary synchronization primitives.
2. Optimal Barrier Placement: The rendergraph can analyze the entire rendering process and
place barriers only where needed, reducing overhead.
3. Layout Transitions: The rendergraph can automatically handle image layout transitions based
on how resources are used.
4. Resource Aliasing: The rendergraph can reuse memory for resources that aren’t used
simultaneously, reducing memory usage.
Dynamic rendering is a modern Vulkan feature that simplifies the rendering process and works
particularly well with rendergraphs. Before diving into implementation examples, let’s understand
what dynamic rendering is and how it relates to our rendering pipeline architecture.
1. Simplified API: No need to create and manage VkRenderPass and VkFramebuffer objects,
reducing code complexity.
2. More Flexible Rendering: Easier to change render targets and attachment formats at runtime.
63
3. Improved Compatibility: Works better with modern rendering techniques that don’t fit well
into the traditional render pass model.
With dynamic rendering, we specify all rendering states (render targets, load/store operations, etc.)
directly within the vkCmdBeginRendering call, rather than setting it up ahead of time in a
VkRenderPass object. This allows for more dynamic rendering workflows and simplifies the
implementation of techniques like deferred rendering.
When combined with rendergraphs, dynamic rendering becomes even more powerful. The
rendergraph handles the resource dependencies and synchronization, while dynamic rendering
simplifies the actual rendering process. This combination provides both flexibility and
performance.
64
vk::ImageUsageFlagBits::eColorAttachment |
vk::ImageUsageFlagBits::eInputAttachment,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eShaderReadOnlyOptimal);
The G-Buffer resource configuration represents the foundation of deferred rendering, where each
buffer stores specific geometric information that will be consumed during lighting calculations. The
format choices reflect a balance between precision requirements and memory efficiency: positions
and normals use 16-bit floating point for accurate lighting calculations, while albedo uses 8-bit
integers for color data where gamma correction naturally reduces precision requirements.
The usage flag combinations enable each resource to serve dual roles: first as render targets during
the geometry pass, then as input textures during the lighting pass. This dual usage pattern is
characteristic of deferred rendering workflows, where the same data moves through multiple
pipeline stages with different access patterns.
65
// This pass renders all geometry and stores intermediate data for lighting
calculations
[Link]("GeometryPass",
{}, // No
input dependencies - first pass in pipeline
{"GBuffer_Position", "GBuffer_Normal", "GBuffer_Albedo", "Depth"},
// Outputs all G-Buffer components
[&](vk::raii::CommandBuffer& cmd) {
.setImageLayout(vk::ImageLayout::eColorAttachmentOptimal)
.setLoadOp(vk::AttachmentLoadOp::eClear)
// Clear to default normal
.setStoreOp(vk::AttachmentStoreOp::eStore);
// Preserve for lighting
.setImageLayout(vk::ImageLayout::eColorAttachmentOptimal)
.setLoadOp(vk::AttachmentLoadOp::eClear)
// Clear to default color
.setStoreOp(vk::AttachmentStoreOp::eStore);
// Preserve for lighting
66
ops
.setLoadOp(vk::AttachmentLoadOp::eClear)
// Clear to far plane
.setStoreOp(vk::AttachmentStoreOp::eStore)
// Preserve for lighting pass
.setClearValue({1.0f, 0});
// Clear to maximum depth
[Link]();
// Complete G-Buffer population
});
The geometry pass configuration demonstrates the power of deferred rendering’s separation of
concerns, where geometric complexity is handled independently of lighting complexity. The
multiple render target setup enables simultaneous output to all G-Buffer components in a single
rendering pass, maximizing GPU efficiency compared to multiple separate passes.
The dynamic rendering approach eliminates the need to pre-configure render pass objects,
providing flexibility to adjust G-Buffer formats or attachment counts based on runtime
requirements. This flexibility is particularly valuable for techniques like adaptive quality settings
or optional G-Buffer components for different material types.
67
// Configure lighting pass for screen-space illumination calculations
// This pass reads G-Buffer data and computes final lighting for each pixel
[Link]("LightingPass",
{"GBuffer_Position", "GBuffer_Normal", "GBuffer_Albedo", "Depth"},
// Read all G-Buffer components
{"FinalColor"}, // Output
final lit result
[&](vk::raii::CommandBuffer& cmd) {
[Link]();
// Complete lighting calculations
});
68
// This analyzes dependencies and generates optimal execution plan
[Link]();
}
The lighting pass represents the core advantage of deferred rendering: decoupling lighting
complexity from geometric complexity. By processing lighting in screen space, the cost becomes
proportional to screen resolution rather than scene complexity, enabling efficient handling of
scenes with many lights or complex lighting models.
The single render target configuration reflects the unified nature of the lighting pass, where all
lighting contributions are accumulated into the final color buffer. This approach enables advanced
lighting techniques like physically-based rendering or global illumination algorithms that would be
prohibitively expensive in forward rendering scenarios with complex geometry.
2. Batch Similar Operations: Group similar operations together to reduce state changes.
3. Use Appropriate Access Flags: Be specific about which access types you need to synchronize.
5. Consider Memory Aliasing: Use the rendergraph’s memory aliasing capabilities to reduce
memory usage.
6. Profile and Optimize: Use GPU profiling tools to identify synchronization bottlenecks.
// Forward declarations
class RenderPass;
class RenderTarget;
public:
template<typename T, typename... Args>
T* AddRenderPass(const std::string& name, Args&&... args) {
static_assert(std::is_base_of<RenderPass, T>::value, "T must derive from
RenderPass");
auto it = [Link](name);
if (it != [Link]()) {
69
return dynamic_cast<T*>(it->[Link]());
}
return passPtr;
}
private:
void SortPasses() {
// Topologically sort render passes based on dependencies
[Link]();
70
std::unordered_set<std::string> visiting;
[Link](name);
[Link](name);
sortedPasses.push_back(pass);
}
};
public:
explicit RenderPass(const std::string& passName) : name(passName) {}
virtual ~RenderPass() = default;
71
const std::vector<std::string>& GetDependencies() const {
return dependencies;
}
BeginPass(commandBuffer);
Render(commandBuffer);
EndPass(commandBuffer);
}
protected:
// With dynamic rendering, BeginPass typically calls vkCmdBeginRendering
// instead of vkCmdBeginRenderPass
virtual void BeginPass(vk::raii::CommandBuffer& commandBuffer) = 0;
virtual void Render(vk::raii::CommandBuffer& commandBuffer) = 0;
// With dynamic rendering, EndPass typically calls vkCmdEndRendering
// instead of vkCmdEndRenderPass
virtual void EndPass(vk::raii::CommandBuffer& commandBuffer) = 0;
};
uint32_t width;
uint32_t height;
72
public:
RenderTarget(uint32_t w, uint32_t h) : width(w), height(h) {
// Create color and depth images
CreateColorResources();
CreateDepthResources();
private:
void CreateColorResources() {
// Implementation to create color image, memory, and view
// With dynamic rendering, we just need to create the image and image view
// that will be used with vkCmdBeginRendering
// ...
}
void CreateDepthResources() {
// Implementation to create depth image, memory, and view
// With dynamic rendering, we just need to create the image and image view
// that will be used with vkCmdBeginRendering
// ...
}
vk::raii::Device& GetDevice() {
// Get device from somewhere (e.g., singleton or parameter)
// ...
static vk::raii::Device device = nullptr; // Placeholder
return device;
}
};
73
private:
CullingSystem* cullingSystem;
// G-buffer textures
RenderTarget* gBuffer;
public:
GeometryPass(const std::string& name, CullingSystem* culling)
: RenderPass(name), cullingSystem(culling) {
// Create G-buffer render target
gBuffer = new RenderTarget(1920, 1080); // Example resolution
SetRenderTarget(gBuffer);
}
~GeometryPass() override {
delete gBuffer;
}
protected:
void BeginPass(vk::raii::CommandBuffer& commandBuffer) override {
// Begin rendering with dynamic rendering
vk::RenderingInfoKHR renderingInfo;
.setImageLayout(vk::ImageLayout::eDepthStencilAttachmentOptimal)
.setLoadOp(vk::AttachmentLoadOp::eClear)
.setStoreOp(vk::AttachmentStoreOp::eStore)
.setClearValue(vk::ClearDepthStencilValue(1.0f, 0));
74
}
// Draw mesh
// ...
}
}
}
public:
LightingPass(const std::string& name, GeometryPass* gPass)
: RenderPass(name), geometryPass(gPass) {
// Add dependency on geometry pass
AddDependency(gPass->GetName());
}
75
}
}
protected:
void BeginPass(vk::raii::CommandBuffer& commandBuffer) override {
// Begin rendering with dynamic rendering
vk::RenderingInfoKHR renderingInfo;
76
// End dynamic rendering
[Link]();
}
};
// Post-processing pass
class PostProcessPass : public RenderPass {
private:
LightingPass* lightingPass;
std::vector<PostProcessEffect*> effects;
public:
PostProcessPass(const std::string& name, LightingPass* lPass)
: RenderPass(name), lightingPass(lPass) {
// Add dependency on lighting pass
AddDependency(lPass->GetName());
}
protected:
void BeginPass(vk::raii::CommandBuffer& commandBuffer) override {
// Begin rendering with dynamic rendering
vk::RenderingInfoKHR renderingInfo;
77
[Link](vk::Rect2D({0, 0}, {GetRenderTarget()->GetWidth(),
GetRenderTarget()->GetHeight()}))
.setLayerCount(1)
.setColorAttachmentCount(1)
.setPColorAttachments(&colorAttachment);
class Renderer {
private:
vk::raii::Device device = nullptr;
vk::Queue graphicsQueue;
vk::raii::CommandPool commandPool = nullptr;
RenderPassManager renderPassManager;
CullingSystem cullingSystem;
public:
Renderer(vk::raii::Device& dev, vk::Queue queue) : device(dev),
graphicsQueue(queue) {
// Create command pool
78
// ...
// Perform culling
[Link](entities);
// Record commands
vk::CommandBufferBeginInfo beginInfo;
[Link](beginInfo);
[Link]();
79
[Link](1);
[Link](&rawImageAvailableSemaphore);
[Link](waitStages);
[Link](1);
[Link](&rawRenderFinishedSemaphore);
private:
void SetupRenderPasses() {
// Create geometry pass
auto geometryPass =
[Link]<GeometryPass>("GeometryPass", &cullingSystem);
Conclusion
A well-designed rendering pipeline is essential for creating a flexible and efficient rendering
engine. By implementing the techniques described in this section, you can create a system that:
In the next section, we’ll explore event systems, which provide a flexible way for different parts of
80
your engine to communicate with each other.
Without an event system, these interactions would require direct references between subsystems,
creating tight coupling and making the code harder to maintain and extend.
We start with a minimal base event interface and a helper macro to define strongly typed events
without boilerplate.
81
// Base event class
class Event {
public:
virtual ~Event() = default;
This lets us identify and copy events generically while keeping concrete event classes small.
Keep event payloads focused and lightweight; they should represent facts, not behavior.
public:
WindowResizeEvent(int w, int h) : width(w), height(h) {}
DEFINE_EVENT_TYPE(WindowResizeEvent)
};
public:
KeyPressEvent(int key, bool isRepeat) : keyCode(key), repeat(isRepeat) {}
82
DEFINE_EVENT_TYPE(KeyPressEvent)
};
Listeners receive events; the dispatcher routes a generic Event to a typed handler when types
match.
// Event dispatcher
class EventDispatcher {
private:
const Event& event;
public:
explicit EventDispatcher(const Event& e) : event(e) {}
The bus can deliver immediately (low latency) or queue for later (deterministic ordering across
frames).
// Event bus
class EventBus {
private:
std::vector<EventListener*> listeners;
std::queue<std::unique_ptr<Event>> eventQueue;
std::mutex queueMutex;
bool immediateMode = true;
83
public:
void SetImmediateMode(bool immediate) {
immediateMode = immediate;
}
void ProcessEvents() {
if (immediateMode) return;
std::queue<std::unique_ptr<Event>> currentEvents;
{
std::lock_guard<std::mutex> lock(queueMutex);
std::swap(currentEvents, eventQueue);
}
while (![Link]()) {
auto& event = *[Link]();
[Link]();
}
}
};
84
Using the Event System
Here’s how you might use the event system in your application:
public:
void Initialize() override {
camera = GetOwner()->GetComponent<CameraComponent>();
// Handle movement
glm::vec3 movement(0.0f);
85
// Handle key press events
[Link]<KeyPressEvent>([this](const KeyPressEvent& e) {
switch ([Link]()) {
case KEY_W: moveForward = true; break;
case KEY_S: moveBackward = true; break;
case KEY_A: moveLeft = true; break;
case KEY_D: moveRight = true; break;
}
return false;
});
~CameraController() override {
// Unregister as event listener
GetEventBus().RemoveListener(this);
}
private:
EventBus& GetEventBus() {
// Get event bus from somewhere (e.g., singleton or parameter)
static EventBus eventBus;
return eventBus;
}
};
86
// Key states
std::unordered_map<int, bool> keyStates;
public:
explicit InputSystem(EventBus& bus) : eventBus(bus) {}
void Update() {
// Poll input events from the platform
// ...
if (!keyState || repeat) {
// Key was not pressed before or this is a repeat
KeyPressEvent event(keyCode, repeat);
[Link](event);
}
keyState = true;
}
if (keyState) {
// Key was pressed before
KeyReleaseEvent event(keyCode);
[Link](event);
}
keyState = false;
}
};
Events can be categorized to allow listeners to filter which types of events they receive:
// Event categories
enum class EventCategory {
None = 0,
Application = 1 << 0,
87
Input = 1 << 1,
Keyboard = 1 << 2,
Mouse = 1 << 3,
MouseButton = 1 << 4,
Window = 1 << 5
};
public:
KeyPressEvent(int key, bool isRepeat) : keyCode(key), repeat(isRepeat) {}
DEFINE_EVENT_TYPE_CATEGORY(KeyPressEvent,
static_cast<int>(EventCategory::Input) |
static_cast<int>(EventCategory::Keyboard))
};
88
Event Filtering
std::vector<ListenerInfo> listeners;
std::queue<std::unique_ptr<Event>> eventQueue;
std::mutex queueMutex;
bool immediateMode = true;
public:
void AddListener(EventListener* listener, int categoryFilter = -1) {
listeners.push_back({listener, categoryFilter});
}
89
Event Priorities
std::vector<ListenerInfo> listeners;
// Rest of the implementation...
public:
void AddListener(EventListener* listener, int categoryFilter = -1, int priority =
0) {
listeners.push_back({listener, categoryFilter, priority});
In hierarchical systems like UI, events can propagate through the hierarchy in two ways:
• Event Bubbling - The event starts at the target element and "bubbles up" through parent
elements in the hierarchy. For example, a click event on a button first triggers on the button,
then on its container, and continues up to the root element.
• Event Capturing - The event starts at the root element and travels down the hierarchy to the
target element (the opposite direction of bubbling).
This approach allows parent elements to intercept and handle events triggered on their children,
while also giving children the ability to stop propagation if needed. For hierarchical systems like UI,
this provides a flexible way to handle events at the appropriate level:
90
UIElement* target;
bool bubbles;
bool cancelBubble = false;
public:
UIEvent(UIElement* targetElement, bool bubbling = true)
: target(targetElement), bubbles(bubbling) {}
void StopPropagation() {
cancelBubble = true;
}
DEFINE_EVENT_TYPE_CATEGORY(UIEvent, static_cast<int>(EventCategory::UI))
};
while (current) {
path.push_back(current);
current = current->GetParent();
}
if ([Link]()) {
break;
}
}
}
};
91
Conclusion
A well-designed event system is crucial for creating a flexible and maintainable engine
architecture. By implementing the techniques described in this section, you can create a system
that:
1. Decouples subsystems, making your code more modular and easier to maintain
4. Can be extended with advanced features like filtering, priorities, and bubbling
This concludes our exploration of engine architecture. In this chapter, we’ve covered:
With these foundations in place, you’re well-equipped to build a robust and flexible rendering
engine that can be extended to support a wide range of features and techniques.
The component system implementation demonstrated how composition can replace deep
inheritance hierarchies, creating more maintainable and flexible code. This approach allows you to
92
build diverse game objects by combining reusable components rather than creating complex class
hierarchies that become difficult to extend and modify.
Resource management emerged as a critical foundation that affects every other system. Our
implementation showcases how reference counting, caching, and hot reloading work together to
optimize memory usage while improving development workflow. These techniques become
essential as your projects scale beyond simple scenes to complex, asset-heavy applications.
The rendering pipeline structure provides the framework for accommodating different rendering
techniques and effects. By organizing stages for scene culling, render pass management, command
generation, and post-processing, we’ve created a system that can evolve with your rendering needs
without requiring fundamental architectural changes.
Finally, the event system implementation shows how to enable communication between engine
subsystems without creating tight coupling. Features like event filtering, priorities, and bubbling
create a flexible communication layer that scales from simple notifications to complex interaction
patterns.
Interface design becomes your most powerful tool for managing complexity as your engine grows.
Well-defined interfaces act as contracts between subsystems, allowing you to modify or completely
replace implementations without cascading changes throughout your codebase. This separation of
concerns proves invaluable when optimizing performance, adding features, or adapting to new
requirements.
Performance considerations need to inform architectural decisions from the beginning, though this
differs from premature optimization. Certain structural choices—like data layout patterns, memory
allocation strategies, and inter-system communication mechanisms—create performance ceilings
that become extremely expensive to change later. Understanding these implications helps you
make informed trade-offs during initial design phases.
Successful engine development requires embracing iteration and refactoring as core activities
rather than necessary evils. Your understanding of requirements will evolve as you build and use
your engine, and rigid adherence to initial designs often leads to increasingly awkward
workarounds. Regular refactoring keeps your architecture aligned with actual needs rather than
theoretical ideals.
The balance between flexibility and complexity represents perhaps the most challenging aspect of
engine architecture. Every abstraction layer and configurable system adds cognitive overhead and
potential failure points, but insufficient flexibility leads to brittle, hard-to-extend code. Finding the
right balance depends on understanding your specific project constraints, team size, timeline, and
93
performance requirements.
Next Steps
The architectural foundation we’ve established in this chapter will support everything we build in
subsequent chapters. As we progress through camera systems, lighting, model loading, and
advanced features, each new system will integrate with these core patterns rather than existing as
isolated components.
Active implementation proves far more valuable than passive reading when learning engine
architecture. Build the code examples as you encounter them, but don’t stop there—experiment
with variations to understand how different approaches affect your engine’s behavior. This
experimentation develops the intuitive understanding that separates competent engine developers
from those who merely copy implementations.
The architectural concepts we’ve covered provide a foundation, but production engines require
additional sophistication. The Appendix explores advanced rendering techniques and architectural
patterns that build on these fundamentals, helping you understand how simple patterns scale to
handle complex real-world requirements.
Studying existing open-source engines like Hazel or examining the architectural decisions in
established frameworks like LWJGL provides valuable perspective on how these concepts apply in
practice. Look for patterns we’ve discussed and notice how different engines make different trade-
offs based on their specific goals and constraints.
The graphics programming community offers tremendous value for learning and problem-solving.
Engaging with forums, Discord servers, and GitHub discussions exposes you to diverse approaches
and helps you get feedback on your architectural decisions. Often, discussing your implementation
choices with others reveals assumptions you didn’t realize you were making.
Remember that engine development is an iterative process. Your architecture will evolve as you
gain experience and as your requirements change. The concepts we’ve covered provide a
foundation, but the best architecture for your engine will depend on your specific goals and
constraints.
Final Thoughts
Building a rendering engine is a challenging but rewarding endeavor. By applying the architectural
patterns and design principles we’ve explored in this chapter, you’ll be well-equipped to create a
robust, flexible, and maintainable engine that can grow with your needs.
94
Camera & Transformations:
Introduction
Introduction
Welcome to the "Camera & Transformations" chapter of our "Building a Simple Engine" series! In
this chapter, we’ll dive into the essential mathematics and techniques needed to implement a 3D
camera system in Vulkan.
By the end of this chapter, you’ll have a solid understanding of 3D transformations and a reusable
camera system that can be integrated into your Vulkan applications.
Prerequisites
Before starting this chapter, you should have completed the main Vulkan tutorial. You should also
be familiar with:
◦ Command buffers
◦ Graphics pipelines
• Uniform buffers
95
Camera & Transformations:
Mathematical Foundations
Mathematical Foundations for 3D Graphics
Before diving into camera implementation, let’s review the essential mathematical concepts that
form the foundation of 3D graphics programming. Understanding these concepts is crucial for
implementing a robust camera system.
Vectors in 3D Graphics
Vectors are fundamental to 3D graphics as they represent positions, directions, and movements in
space. In our Vulkan application, we’ll primarily work with:
• Addition and Subtraction: Used for calculating relative positions and movements
• Dot Product: Calculates the dot product between two vectors. For normalized vectors, this
equals the cosine of the angle between them.
◦ Applications: Determining if objects are facing the camera, calculating lighting intensity
The right-hand rule is a convention used in 3D graphics and mathematics to determine the
orientation of coordinate systems and the direction of cross-products.
96
2. Point your middle finger in the direction of vector B (perpendicular to A)
1. Point your right hand’s index finger along the positive X-axis
◦ Applications: Generating the camera’s "right" vector from "forward" and "up" vectors
97
Matrices and Transformations
Matrices are used to represent transformations in 3D space. In Vulkan and other graphics APIs, we
typically use 4×4 matrices to represent transformations in homogeneous coordinates.
1. They allow us to represent translation (movement) along with rotation and scaling
3. They work with homogeneous coordinates (x, y, z, w) which are required for perspective
projection
◦ Less commonly used for cameras, but important for objects in the scene
98
// Order matters! The rightmost transformation is applied first
glm::mat4 modelMatrix = translationMatrix * rotationMatrix * scaleMatrix;
The order of matrix multiplication is crucial because transformations are applied from right to left.
Getting the order wrong can completely change your object’s final position and orientation.
Consider this practical example: if you want to rotate a cube around its own center and then move
it to a new position, you must apply the transformations in the correct order:
The following diagram illustrates the difference between correct and incorrect matrix
multiplication order when transforming a cube:
[Matrix Order Comparison showing correct T×R×S vs incorrect R×T×S transformation sequences] |
../../../images/[Link]
Figure 1. Matrix Transformation Order Comparison
When working with matrices in graphics programming, it’s important to understand the difference
between row-major and column-major representations:
99
// Row-major vs Column-major representation of a 3x3 matrix
// For a matrix:
// [ a b c ]
// [ d e f ]
// [ g h i ]
// When passing matrices to Vulkan shaders, you need to be aware of the layout
// Vulkan expects column-major by default, matching GLM's default
Vulkan works with both row-major and column-major formats, but you need to specify which one
you’re using:
• You can specify row-major format in your shaders using the row_major qualifier
• GLM (commonly used with Vulkan) uses column-major by default, but can be configured for
row-major
• When debugging, matrix elements may appear transposed compared to mathematical notation
• When porting code between different APIs, matrix layouts may need to be transposed
Affine Transformations
Affine transformations are a fundamental concept in computer graphics that preserve parallel lines
(but not necessarily angles or distances). They’re essential for representing most common
operations in 3D graphics.
• Translation (movement)
100
In mathematical terms, an affine transformation can be expressed as:
f(x) = Ax + b
In our Vulkan application, almost all transformations we perform are affine: * Moving objects
around the scene (translation) * Rotating objects to face different directions * Scaling objects to
make them larger or smaller * Combining these operations to position and orient objects
Pose Matrices
A pose matrix (also called a transformation matrix or rigid body transformation) is a specific type
of affine transformation that represents both the position and orientation of an object in 3D space.
101
Structure of a Pose Matrix
• Object Positioning: Defining where objects are located and how they’re oriented
◦ Example: Placing a character model in the world with the correct position and facing
direction
◦ Example: The view matrix is the inverse of the camera’s pose matrix
◦ Example: A character’s hand position depends on the arm position, which depends on the
torso position
102
// Extracting up direction
glm::vec3 extractUpDirection(const glm::mat4& poseMatrix) {
return glm::vec3(poseMatrix[1]); // Y axis (second column)
}
• Establishes the camera’s local coordinate system (right, up, forward vectors)
• Creates a view matrix that transforms world coordinates into camera space
• Simplifies camera control by focusing on a target rather than managing rotation angles
Mathematical Principles
The look-at function works by constructing an orthonormal basis (three perpendicular unit vectors)
that defines the camera’s orientation:
1. Forward Vector (Z): Points from the camera position to the target position
2. Right Vector (X): Perpendicular to both the forward vector and the world up vector
These three vectors, along with the camera position, form the view matrix that transforms world
coordinates into camera space.
Step-by-Step Implementation
glm::mat4 createLookAtMatrix(
const glm::vec3& cameraPosition, // Where the camera is
const glm::vec3& targetPosition, // What the camera is looking at
const glm::vec3& worldUpVector // Which way is "up" (usually Y axis)
) {
// Step 1: Calculate the camera's forward direction (Z axis)
// Note: We negate this because in OpenGL/Vulkan, the camera looks down the
negative Z-axis
glm::vec3 forward = glm::normalize(cameraPosition - targetPosition);
103
// Step 2: Calculate the camera's right direction (X axis)
// Using cross product between world up and forward direction
glm::vec3 right = glm::normalize(glm::cross(worldUpVector, forward));
In practice, we typically use GLM’s built-in lookAt function, which implements the same algorithm:
Practical Applications
104
• Third-Person Camera: Following a character while looking at them
• Cinematic Camera: Creating smooth camera movements that focus on important objects
Here’s how you might use the look-at function to implement an orbit camera that circles around a
target:
The look-at function can also be used to create smooth transitions between different camera
positions and targets:
105
currentTarget,
glm::vec3(0.0f, 1.0f, 0.0f)
);
}
By understanding how the look-at function works, you gain insight into how cameras are oriented
in 3D space and how the view matrix transforms the world from the camera’s perspective.
Raycasting in 3D Graphics
Raycasting is a fundamental technique in 3D graphics that involves projecting rays from a point
into the scene and determining what they intersect with. It’s used for a wide range of applications,
from picking objects in a scene to implementing collision detection and visibility determination.
Ray Representation
struct Ray {
glm::vec3 origin; // Starting point of the ray
glm::vec3 direction; // Normalized direction vector
};
// Creating a ray
Ray createRay(const glm::vec3& origin, const glm::vec3& direction) {
Ray ray;
[Link] = origin;
[Link] = glm::normalize(direction); // Ensure direction is normalized
return ray;
}
Ray-Object Intersection
The core of raycasting is determining if and where a ray intersects with objects in the scene. Let’s
look at some common intersection tests:
Ray-Sphere Intersection
struct Sphere {
glm::vec3 center;
float radius;
};
106
glm::vec3 oc = [Link] - [Link];
// Discriminant
float discriminant = b * b - 4 * a * c;
if (discriminant < 0) {
// No intersection
return false;
}
if (t1 > 0) {
t = t1;
return true;
}
Ray-Triangle Intersection
struct Triangle {
glm::vec3 v0, v1, v2; // Vertices
};
107
float a = glm::dot(edge1, h);
float f = 1.0f / a;
glm::vec3 s = [Link] - triangle.v0;
float u = f * glm::dot(s, h);
Ray-AABB Intersection
Axis-Aligned Bounding Box (AABB) intersection is useful for broad-phase collision detection:
struct AABB {
glm::vec3 min; // Minimum corner
glm::vec3 max; // Maximum corner
};
bool rayIntersectsAABB(const Ray& ray, const AABB& aabb, float& tMin, float& tMax) {
// Compute intersection with each slab
glm::vec3 invDir = 1.0f / [Link];
glm::vec3 t0 = ([Link] - [Link]) * invDir;
108
glm::vec3 t1 = ([Link] - [Link]) * invDir;
One of the most common uses of raycasting is to create rays from the camera into the scene, which
is essential for picking objects or implementing ray tracing:
Ray createCameraRay(
const glm::vec2& screenCoord, // Normalized screen coordinates (-1 to 1)
const glm::mat4& viewMatrix, // Camera view matrix
const glm::mat4& projectionMatrix // Camera projection matrix
) {
// Convert to clip space
glm::vec4 clipCoords(screenCoord.x, screenCoord.y, -1.0f, 1.0f);
// Create ray
Ray ray;
[Link] = glm::vec3(invView[3]); // Camera position in world space
[Link] = glm::normalize(glm::vec3(worldCoords));
return ray;
}
109
• Object Picking: Determining which object the user clicked on in a 3D scene
◦ Cast a ray from the camera through the mouse position and find the nearest intersection
For complex scenes with many objects, raycasting can become computationally expensive. Here are
some optimization techniques:
• Spatial Partitioning: Use data structures like octrees, BVHs, or k-d trees to quickly eliminate
objects that can’t possibly intersect with the ray
• Bounding Volume Hierarchies: Test against simple bounding volumes (spheres, AABBs) before
performing more expensive tests against detailed geometry
• Ray Batching: Process multiple rays together to take advantage of SIMD instructions
• Early Termination: Stop testing once you’ve found any intersection (if that’s all you need)
Projection in 3D Graphics
Projection is the process of transforming 3D coordinates in view space to 2D coordinates on the
screen. In computer graphics, we use projection matrices to perform this transformation.
Types of Projection
• Perspective Projection: Objects appear smaller as they get farther away, simulating how we
see the world
• Orthographic Projection: Objects maintain their size regardless of distance, useful for
technical drawings, 2D games, and UI elements
110
Perspective Projection
Perspective projection creates a realistic view where distant objects appear smaller, creating the
illusion of depth:
1. Scales the view frustum based on the field of view and aspect ratio
2. Maps the view volume to a canonical view volume (a cube from -1 to 1 in each dimension)
Orthographic Projection
Orthographic projection maintains the size of objects regardless of their distance from the camera:
111
return glm::ortho(left, right, bottom, top, nearPlane, farPlane);
}
The orthographic projection matrix simply scales and translates the view volume to the canonical
view volume without applying any perspective division:
The view frustum is the volume of space visible to the camera. For perspective projection, it’s a
truncated pyramid:
• Near Plane: The closest plane to the camera where rendering begins
• Far Plane: The farthest plane from the camera where rendering ends
• Field of View (FOV): The angle that determines how wide the view is
112
corners[2] = glm::vec3( nearWidth/2, nearHeight/2, -nearPlane); // Top-right
corners[3] = glm::vec3(-nearWidth/2, nearHeight/2, -nearPlane); // Top-left
Projection converts 3D world coordinates to 2D screen coordinates, while unprojection does the
reverse. The following code examples demonstrate these concepts for educational purposes:
These utility functions are provided to help understand the mathematical concepts
behind projection and unprojection. While they may not be directly used in the
NOTE
basic rendering pipeline, they are valuable for implementing features like object
picking, mouse interaction with 3D objects, and custom rendering techniques.
// Perspective division
glm::vec3 ndcSpace = glm::vec3(clipSpace) / clipSpace.w;
// Map to viewport
glm::vec2 screenPos;
screenPos.x = (ndcSpace.x + 1.0f) * 0.5f * viewport.z + viewport.x;
screenPos.y = (1.0f - ndcSpace.y) * 0.5f * viewport.w + viewport.y; // Y is
flipped
return screenPos;
}
113
// Convert to normalized device coordinates
glm::vec3 ndcPos;
ndcPos.x = 2.0f * (screenPoint.x - viewport.x) / viewport.z - 1.0f;
ndcPos.y = 1.0f - 2.0f * (screenPoint.y - viewport.y) / viewport.w; // Y is
flipped
ndcPos.z = -1.0f; // Near plane
• Shadow Mapping: Projecting the scene from a light’s perspective to determine shadows
The choice between perspective and orthographic projection depends on the application:
◦ Realistic 3D visualizations
114
Why Use Quaternions?
• Avoids gimbal lock issues that can occur with Euler angles (pitch, yaw, roll)
// Rotate a vector using a quaternion (e.g., rotating the camera's forward vector)
glm::vec3 original(1.0f, 0.0f, 0.0f);
glm::vec3 rotated = rotation * original;
◦ The camera is at the origin (0,0,0) looking down the negative Z-axis
• Clip Space: Coordinates after projection, in the range [-w, w] for each axis
Graphics APIs and engines use either right-handed or left-handed coordinate systems:
115
◦ X-axis points right
◦ Y-axis points up
◦ Y-axis points up
The handedness of your coordinate system affects how you set up your camera:
◦ The view matrix is constructed using the right-hand rule for cross products
The transformation pipeline typically follows this sequence: Local Space → World Space → View
Space → Clip Space → Screen Space
116
gl_Position = projectionMatrix * viewMatrix * modelMatrix * vec4(vertexPosition, 1.0);
In the next section, we’ll implement these mathematical concepts to create a flexible camera system
for our Vulkan application.
Further Resources
If you’re finding some of the mathematical concepts challenging or want to deepen your
understanding, here are some helpful resources organized by topic:
• Books:
◦ "3D Math Primer for Graphics and Game Development" by Fletcher Dunn and Ian Parberry -
Excellent beginner-friendly introduction
◦ "Essential Mathematics for Games and Interactive Applications" by James M. Van Verth and
Lars M. Bishop - Practical approach with code examples
• Online Courses:
◦ Khan Academy Linear Algebra - Free course covering vector and matrix fundamentals
◦ Mathematics for Machine Learning: Linear Algebra - Covers vectors, matrices, and
transformations
• Interactive Tools:
• Tutorials:
• Interactive Tools:
◦ GeoGebra: Vector Operations - Interactive vector addition, subtraction, dot and cross
products
• Tutorials:
117
◦ LearnOpenGL: Transformations - Practical guide to transformations in graphics
• Interactive Tools:
Quaternions
• Tutorials:
• Interactive Tools:
• Tutorials:
• References:
• Documentation:
• Tutorials:
• Visualizations:
• Practice Problems:
118
◦ Khan Academy: Vectors and Spaces - Practice problems for vector math
These resources should help you gain a deeper understanding of the mathematical concepts used in
3D graphics and camera systems. If you’re struggling with a particular concept, try looking at
multiple resources as different explanations might resonate better with your learning style.
Model Matrix
The model matrix transforms vertices from object space to world space. It positions, rotates, and
scales objects in the world.
glm::mat4 createModelMatrix(
const glm::vec3& position,
const glm::vec3& rotation,
const glm::vec3& scale
) {
// Start with identity matrix
glm::mat4 model = glm::mat4(1.0f);
119
// Apply rotations around each axis
model = glm::rotate(model, glm::radians(rotation.x), glm::vec3(1.0f, 0.0f, 0.0f));
model = glm::rotate(model, glm::radians(rotation.y), glm::vec3(0.0f, 1.0f, 0.0f));
model = glm::rotate(model, glm::radians(rotation.z), glm::vec3(0.0f, 0.0f, 1.0f));
// Apply scaling
model = glm::scale(model, scale);
return model;
}
View Matrix
The view matrix transforms vertices from world space to view space (camera space). It represents
the position and orientation of the camera.
glm::mat4 createViewMatrix(
const glm::vec3& cameraPosition,
const glm::vec3& cameraTarget,
const glm::vec3& upVector
) {
return glm::lookAt(cameraPosition, cameraTarget, upVector);
}
The lookAt function creates a view matrix that positions the camera at cameraPosition, looking at
cameraTarget, with upVector defining the up direction.
Projection Matrix
The projection matrix transforms vertices from view space to clip space. It defines how 3D
coordinates are projected onto the 2D screen.
Perspective Projection
Perspective projection simulates how objects appear smaller as they get farther away, which is how
our eyes naturally perceive the world.
glm::mat4 createPerspectiveMatrix(
float fovY,
float aspectRatio,
float nearPlane,
float farPlane
) {
return glm::perspective(glm::radians(fovY), aspectRatio, nearPlane, farPlane);
}
120
Parameters:
Orthographic Projection
Orthographic projection doesn’t have perspective distortion, making it useful for 2D rendering or
technical drawings.
glm::mat4 createOrthographicMatrix(
float left,
float right,
float bottom,
float top,
float nearPlane,
float farPlane
) {
return glm::ortho(left, right, bottom, top, nearPlane, farPlane);
}
Normal Matrix
When applying non-uniform scaling to objects, normals can become incorrect if transformed with
the model matrix. The normal matrix solves this issue:
// Vertex shader
#version 450
121
} ubo;
void main() {
// Apply MVP transformation
gl_Position = [Link] * [Link] * [Link] * vec4(inPosition, 1.0);
fragTexCoord = inTexCoord;
}
Hierarchical Transformations
For complex objects or scenes with parent-child relationships, we use hierarchical transformations:
// Parent transformation
glm::mat4 parentModel = createModelMatrix(parentPosition, parentRotation,
parentScale);
// Combined transformation
glm::mat4 childWorldModel = parentModel * localModel;
In the next section, we’ll implement a camera system that uses these transformation concepts to
navigate our 3D scenes.
122
Camera Implementation
Now that we understand the mathematical foundations and transformation matrices, let’s
implement a flexible camera system for our Vulkan application. We’ll create a camera class that can
be used to navigate our 3D scenes. This implementation is designed for a general-purpose 3D
application or game engine, and the concepts can be applied to various types of applications, from
first-person games to architectural visualization tools.
Camera Types
There are several types of cameras commonly used in 3D applications:
• First-Person Camera: Simulates viewing the world through the eyes of a character.
• Third-Person Camera: Follows a character from behind or another fixed relative position.
• Orbit Camera: Rotates around a fixed point, useful for object inspection.
For our implementation, we’ll focus on a versatile camera that can be configured for different use
cases.
class Camera {
private:
// Spatial positioning and orientation vectors
// These form the camera's local coordinate system in world space
glm::vec3 position; // Camera's location in world coordinates
glm::vec3 front; // Forward direction (where camera is looking)
glm::vec3 up; // Camera's local up direction (for roll control)
glm::vec3 right; // Camera's local right direction (perpendicular to front
and up)
glm::vec3 worldUp; // Global up vector reference (typically Y-axis)
The spatial representation uses a right-handed coordinate system where the camera maintains its
own local coordinate frame within the world space. The position vector defines where the camera
123
exists, while front, up, and right vectors form an orthonormal basis that defines the camera’s
orientation. This approach provides intuitive control where moving along the front vector moves
the camera forward, right moves sideways, and up moves vertically relative to the camera’s current
orientation.
The worldUp vector serves as a reference point for maintaining proper orientation, typically
pointing along the world’s Y-axis. This reference prevents the camera from becoming disoriented
during complex rotations and ensures that operations like "level the horizon" have a consistent
reference point.
Euler angles provide an intuitive interface for camera rotation that maps naturally to user input
devices. Yaw controls horizontal rotation (looking left-right), while pitch controls vertical rotation
(looking up-down). We deliberately avoid roll for most applications as it can be disorienting for
users, though the system could be extended to support it.
The parameter system allows fine-tuning of camera behavior for different use cases. Movement
speed can be adjusted for different scene scales, mouse sensitivity can accommodate user
preferences and different input devices, and zoom provides dynamic field-of-view control for
gameplay or cinematic effects.
124
// Internal coordinate system maintenance
// Ensures mathematical consistency when orientation changes occur
void updateCameraVectors();
public:
The updateCameraVectors method serves as the mathematical foundation of the camera system,
recalculating the front, right, and up vectors whenever the Euler angles change. This process
involves trigonometric calculations that convert the intuitive Euler angle representation into the
orthonormal vector basis required for matrix operations and movement calculations.
This approach separates the user-friendly angle interface from the computationally efficient vector
operations, allowing the camera to present simple controls while maintaining the mathematical
rigor required for accurate 3D transformations.
The constructor design reflects common 3D graphics conventions and practical defaults. The
default position at the origin provides a predictable starting point, while the Y-axis world up aligns
with the standard mathematical coordinate system. The initial yaw of -90° follows OpenGL
conventions where the default view looks down the negative Z-axis, creating a right-handed
coordinate system that feels natural to users.
The parameter defaults eliminate the need for complex initialization in simple use cases while still
allowing full customization when needed for specialized applications.
125
// Matrix generation for graphics pipeline integration
// These methods bridge between the camera's spatial representation and GPU
requirements
glm::mat4 getViewMatrix() const;
glm::mat4 getProjectionMatrix(float aspectRatio, float nearPlane = 0.1f, float
farPlane = 100.0f) const;
The matrix generation methods serve as the critical bridge between our intuitive camera
representation and the mathematical requirements of 3D graphics pipelines. The view matrix
transforms world coordinates into camera space, effectively positioning the world relative to the
camera’s viewpoint. The projection matrix then transforms camera space into clip space, handling
perspective effects and preparing coordinates for rasterization.
The separation of view and projection matrices follows standard graphics pipeline architecture,
allowing independent control over camera positioning and perspective characteristics. This design
enables techniques like changing field-of-view for zoom effects without recalculating the camera’s
spatial relationships.
The input processing architecture recognizes that different input modalities serve different
purposes in camera control. Keyboard input typically handles discrete directional movement,
mouse movement provides continuous rotation control, and scroll wheels offer intuitive zoom
adjustment. Each method is designed to handle its specific input type with appropriate
126
mathematical transformations and timing considerations.
The getter methods provide controlled access to internal state, allowing external systems (like
audio systems that need listener position, or culling systems that need view direction) to access
camera properties without exposing the internal implementation details or allowing uncontrolled
modification of the camera’s state.
Camera Movement
We’ll define an enum for camera movement directions:
switch (direction) {
case CameraMovement::FORWARD:
position += front * velocity;
break;
case CameraMovement::BACKWARD:
position -= front * velocity;
break;
case CameraMovement::LEFT:
position -= right * velocity;
break;
case CameraMovement::RIGHT:
position += right * velocity;
break;
case CameraMovement::UP:
position += up * velocity;
break;
case CameraMovement::DOWN:
position -= up * velocity;
break;
}
}
127
Handling Input Events
The camera class provides methods to process input, but integrating these with your application’s
input system requires careful consideration of different input modalities and their unique
characteristics. Let’s break down the input handling implementation to demonstrate both the
technical integration and the design principles behind effective camera controls.
The keyboard input processing follows established conventions from first-person games, where
WASD keys control horizontal movement and Space/Control handle vertical movement. This
mapping feels intuitive to users and provides complete 6-degrees-of-freedom movement control.
The frame-rate independence achieved through deltaTime ensures consistent movement speed
regardless of rendering performance, which is crucial for predictable user experience across
different hardware configurations.
128
Each movement command uses the camera’s local coordinate system rather than world
coordinates. Meaning "forward" always moves in the direction the camera is facing, "right" moves
perpendicular to the view direction, and "up" moves along the camera’s local vertical axis. This
approach provides intuitive controls that respond naturally to camera orientation changes.
The mouse callback demonstrates the complexities of handling continuous input in event-driven
systems. The static variables maintain state between callback invocations, which is necessary
because mouse movement is reported as absolute positions rather than relative deltas. The first-
129
mouse handling prevents jarring camera jumps when the mouse cursor is first captured.
The Y-axis inversion (lastY - ypos) addresses the coordinate system mismatch between screen
space (where Y increases downward) and camera space (where positive pitch looks upward). This
inversion ensures that moving the mouse upward rotates the camera to look up, matching user
expectations from other 3D applications.
The scroll callback maintains simplicity by directly passing the scroll delta to the camera’s zoom
processing method. This design delegates the mathematical details of zoom control to the camera
class while providing a clean interface for scroll wheel events. The scroll direction convention
(positive for zoom in, negative for zoom out) follows standard user interface patterns.
130
interference
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
}
The system integration demonstrates how camera controls integrate with the broader application
architecture. The callback registration creates the event-driven connection between hardware
input and camera responses, while the cursor disabling provides the seamless mouse control
expected in 3D applications.
The GLFW_CURSOR_DISABLED mode captures the mouse cursor, allowing unlimited mouse movement
without the cursor hitting screen boundaries. This configuration is essential for first-person camera
controls where users expect to be able to turn the camera continuously in any direction without
cursor limitations.
Camera Rotation
For camera rotation, we’ll use mouse input to adjust the yaw and pitch angles:
yaw += xOffset;
pitch += yOffset;
void Camera::updateCameraVectors() {
// Calculate the new front vector
glm::vec3 newFront;
131
newFront.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
newFront.y = sin(glm::radians(pitch));
newFront.z = sin(glm::radians(yaw)) * cos(glm::radians(pitch));
front = glm::normalize(newFront);
View Matrix
The view matrix transforms world coordinates into view coordinates (camera space):
Projection Matrix
The projection matrix transforms view coordinates into clip coordinates:
Let’s extend our camera class to support these features by building a specialized
ThirdPersonCamera that addresses the unique challenges of the character-following camera
132
systems.
The getter methods provide controlled access to internal state, allowing external systems (like
audio systems that need listener position, or culling systems that need a view direction) to query
the camera without tightly coupling to the implementation. This keeps the camera easy to maintain
and extend as features are added.
Before we automate camera behaviors, let’s build an intuition for “look-at.” The idea is simple:
given we know where the camera is: "the eye," we also know what point it should face: "the target,"
and we also know which way is "up." We want to use that information to construct an orientation
that makes the camera face the target while keeping the horizon stable.
• Up: “Which direction should the top of the frame point (so the picture isn’t tilted)?”
Thus, when we get to the output of "look at," we will have a view orientation. We usually for
convenience will use an affine matrix, but it is only an orientation. After all, rotating to "look at"
something shouldn’t involve translating to a new position; so the eye will maintain the position
throughout our look-at code.
Key takeaways:
• “Look-at” defines an orientation, not a position. The position comes from the eye; look-at figures
out the directions (forward/right/up) from eye→target and the chosen up.
• The up direction should not be parallel to the eye→target direction. If they’re nearly aligned, the
camera won’t know how to keep the horizon level (it can “roll unpredictably.”)
• You can use look-at for both cameras and objects. Characters can face each other, or you can
point a spotlight or turret at a target with the same concept.
In the next section, we’ll take this one-off “point at a target” idea and turn it into a behavior:
smooth, continuous camera target tracking that follows moving subjects without jitter or sudden
snaps.
133
Implementation for camera target relationship
First, establish the fundamental relationship between the camera and its target, managing the
spatial tracking information that drives all third-person camera behaviors.
The target tracking system forms the foundation of third-person camera behavior by maintaining a
continuous connection between the camera and the character being followed. The targetPosition
provides the spatial anchor that the camera revolves around, while targetForward enables context-
aware camera positioning that can anticipate where the character is moving or looking.
This approach allows the camera to make intelligent positioning decisions based on the character’s
state and orientation, creating more dynamic and responsive camera behavior than simple fixed-
offset following.
The behavioral parameters provide artistic control over the camera’s personality and functional
characteristics. Follow distance affects both the visual intimacy with the character and the amount
of surrounding environment visible in the frame. Height offset ensures the camera provides good
visibility of both the character and the surrounding terrain or obstacles.
The smoothness parameter controls the camera’s responsiveness to target movement, allowing
designers to balance between immediate response, (which can feel jerky,) and smooth motion
134
(which can feel laggy). This parameter is crucial for creating camera behavior that feels natural and
responsive to different gameplay situations.
The occlusion management system addresses one of the most challenging aspects of third-person
camera implementation: maintaining visibility when environmental geometry interferes with the
desired camera position. The minimum distance prevents the camera from getting uncomfortably
close to the character during collision situations, while the raycast distance defines how far the
camera looks ahead for potential occlusion issues.
This system enables the camera to proactively respond to environmental constraints, smoothly
adjusting its position to maintain optimal visibility without jarring transitions or sudden position
changes that can be disorienting to players.
public:
The internal state management separates the desired camera behavior from the actual camera
position, allowing the system to handle complex scenarios where multiple forces influence camera
135
positioning. The desired position represents where the camera would ideally be placed based on
the follow parameters, while the smooth damp velocity enables sophisticated interpolation
algorithms that create natural, physics-inspired camera motion.
This separation of concerns allows the camera system to handle conflicts between desired
positioning and environmental constraints gracefully, maintaining smooth motion even when the
camera must deviate significantly from its preferred location.
The public interface design balances ease of use with powerful functionality, providing sensible
defaults that work well for common third-person scenarios while allowing full customization when
needed. The default values are chosen based on common third-person game requirements: medium
distance for good character visibility, moderate height for environmental awareness, and balanced
smoothing for responsive yet stable motion.
The method organization separates the core update functionality (which typically runs every
frame) from configuration methods (which are called less frequently) and specialized behaviors
136
like orbiting (which might be triggered by specific user input). This design makes it easy to integrate
the camera into different game loop architectures while maintaining a clear separation of concerns.
The core of a third-person camera is the algorithm that positions the camera relative to the
character. Here’s an implementation of the updatePosition method:
void ThirdPersonCamera::updatePosition(
const glm::vec3& targetPos,
const glm::vec3& targetFwd,
float deltaTime
) {
// Update target properties
targetPosition = targetPos;
targetForward = glm::normalize(targetFwd);
This implementation:
1. Positions the camera behind the character based on the character’s forward direction
Occlusion Avoidance
One of the most challenging aspects of a third-person camera is handling occlusion - when objects
in the environment block the view of the character. Here’s an implementation of occlusion
avoidance:
137
void ThirdPersonCamera::handleOcclusion(const Scene& scene) {
// Cast a ray from the target to the desired camera position
Ray ray;
[Link] = targetPosition;
[Link] = glm::normalize(desiredPosition - targetPosition);
This implementation:
2. If the ray hits an object, moves the camera to the hit point (with a small offset)
• Use simplified collision geometry: For raycasting, use simpler collision shapes than your
rendering geometry
• Limit the frequency of occlusion checks: You may not need to check every frame on slower
devices
• Consider spatial partitioning: Use structures like octrees to accelerate raycasts by quickly
eliminating objects that can’t possibly intersect with the ray
138
Implementing Orbit Controls
Many third-person games allow the player to orbit the camera around the character. Here’s how to
implement this functionality:
This implementation:
To create a complete third-person camera system, we need to integrate it with character movement.
Here’s an example of how to use the third-person camera in a game loop:
139
// Update camera position to follow the character
[Link](
[Link](),
[Link](),
deltaTime
);
For more advanced camera techniques, refer to the Advanced Camera Techniques
NOTE
section in the Appendix.
In the next section, we’ll integrate our camera system with Vulkan to render 3D scenes.
• GLM (OpenGL Mathematics): A mathematics library for graphics programming that provides
vector and matrix operations similar to GLSL. We use it for all our 3D math operations.
140
[[Link]
Now that we have a camera system and understand transformation matrices, let’s integrate them
with our Vulkan application. We’ll focus on how to set up uniform buffers for our matrices and
update them each frame based on camera movement.
• Create a descriptor set layout and allocate descriptor sets for the UBO
• Write descriptor sets and persistently map the buffers for fast updates
• Update the UBO each frame from the camera (view/proj) and model transform
• Bind the descriptor set and draw using the updated matrices
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
Next, we’ll create the uniform buffer and its descriptor set:
struct UniformBufferObject {
glm::mat4 model;
glm::mat4 view;
glm::mat4 proj;
};
// Keep the mapped pointer alongside the buffer for clarity and safety
struct UboBuffer {
vk::raii::Buffer buffer{nullptr};
vk::raii::DeviceMemory memory{nullptr};
141
void* mapped = nullptr;
};
void createUniformBuffers() {
vk::DeviceSize bufferSize = sizeof(UniformBufferObject);
// Create the buffer
vk::BufferCreateInfo bufferInfo{
.size = bufferSize,
.usage = vk::BufferUsageFlagBits::eUniformBuffer,
.sharingMode = vk::SharingMode::eExclusive
};
for (size_t i = 0; i < maxConcurrentFrames; i++) {
uniformBuffers[i].buffer = vk::raii::Buffer(device, bufferInfo);
vk::MemoryAllocateInfo allocInfo{
.allocationSize = [Link],
.memoryTypeIndex = findMemoryType(
[Link],
vk::MemoryPropertyFlagBits::eHostVisible |
vk::MemoryPropertyFlagBits::eHostCoherent
)
};
void createDescriptorSetLayout() {
vk::DescriptorSetLayoutBinding uboLayoutBinding{
.binding = 0,
.descriptorType = vk::DescriptorType::eUniformBuffer,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eVertex,
.pImmutableSamplers = nullptr
};
142
vk::DescriptorSetLayoutCreateInfo layoutInfo{
.bindingCount = 1,
.pBindings = &uboLayoutBinding
};
descriptorSetLayout = [Link](layoutInfo);
}
Descriptor Sets
Now we’ll create descriptor sets that point to our uniform buffers:
void createDescriptorSets() {
std::array<vk::DescriptorSetLayout, maxConcurrentFrames> layouts{};
[Link](*descriptorSetLayout);
vk::DescriptorSetAllocateInfo allocInfo{
.descriptorPool = *descriptorPool,
.descriptorSetCount = maxConcurrentFrames,
.pSetLayouts = [Link]()
};
descriptorSets = [Link](allocInfo);
vk::DescriptorBufferInfo bufferInfo{
.offset = 0,
.range = sizeof(UniformBufferObject)
};
vk::WriteDescriptorSet descriptorWrite{
.dstSet = descriptorSets[i],
.dstBinding = 0,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk::DescriptorType::eUniformBuffer,
.pBufferInfo = &bufferInfo
};
143
Updating Uniform Buffers
In our main loop, we’ll update the uniform buffer with the latest camera data:
UniformBufferObject ubo{};
// Copy the data to the uniform buffer for the current frame-in-flight
memcpy(uniformBuffers[currentFrame].mapped, &ubo, sizeof(ubo));
}
void processInput() {
// Calculate delta time
static float lastFrame = 0.0f;
float currentFrame = glfwGetTime();
float deltaTime = currentFrame - lastFrame;
lastFrame = currentFrame;
144
if (glfwGetKey(window, GLFW_KEY_D) == GLFW_PRESS)
[Link](CameraMovement::RIGHT, deltaTime);
if (glfwGetKey(window, GLFW_KEY_SPACE) == GLFW_PRESS)
[Link](CameraMovement::UP, deltaTime);
if (glfwGetKey(window, GLFW_KEY_LEFT_CONTROL) == GLFW_PRESS)
[Link](CameraMovement::DOWN, deltaTime);
}
lastX = xpos;
lastY = ypos;
[Link](xoffset, yoffset);
}
void initWindow() {
// ... existing GLFW initialization code ...
145
// Capture the cursor for camera control
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
}
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
processInput();
// Draw frame
drawFrame();
}
}
With these components in place, we now have a fully functional camera system integrated with our
Vulkan application. Users can navigate the 3D scene using keyboard and mouse controls, and the
view will update accordingly.
In the next section, we’ll wrap up with a conclusion and discuss potential improvements to our
camera system.
146
movement modes and handles user input for navigation.
• Transformation Matrices: We examined the model, view, and projection matrices that form
the MVP pipeline, and how they transform vertices through different coordinate spaces.
• Vulkan Integration: We integrated our camera system with Vulkan by setting up uniform
buffers, descriptor sets, and input handling.
With these components in place, we now have a solid foundation for creating interactive 3D
applications with Vulkan. Our camera system allows users to navigate and explore 3D scenes from
any perspective.
Potential Improvements
While our camera system is functional, there are several ways it could be enhanced:
• Camera Modes: Implement different camera modes (first-person, third-person, orbit) that can
be switched at runtime.
• Smooth Transitions: Add interpolation between camera positions and orientations for
smoother transitions.
• Collision Detection: Implement collision detection to prevent the camera from passing through
objects or walls.
• Camera Paths: Create a system for defining and following predefined camera paths for
cinematic sequences.
• Camera Effects: Add support for camera effects like depth of field, motion blur, or screen-space
reflections.
• Performance Optimization: Optimize the camera system for performance, especially for
mobile or VR applications.
Next Steps
As you continue building your Vulkan engine, consider how the camera system integrates with
other components:
• Scene Graph: How does the camera fit into your scene graph hierarchy?
• Rendering Pipeline: How can you optimize rendering based on the camera’s position and
orientation?
• User Interface: How will users interact with the camera in your application?
By addressing these questions, you can create a more cohesive and user-friendly 3D application.
Final Thoughts
A well-designed camera system is essential for any 3D application. It serves as the user’s window
into your virtual world and significantly impacts the user experience. By understanding the
mathematical foundations and implementing a flexible camera system, you’ve taken a major step
147
toward creating immersive 3D applications with Vulkan.
Remember that the code provided in this chapter is a starting point. Feel free to modify and extend
it to suit your specific needs and application requirements.
Contents
• Introduction
• Mathematical Foundations
• Transformation Matrices
• Camera Implementation
• Vulkan Integration
In this chapter, we’ll explore the fundamentals of lighting and materials in 3D rendering, with a
focus on Physically Based Rendering (PBR). Lighting is a crucial aspect of creating realistic and
visually appealing 3D scenes. Without proper lighting, even the most detailed models can appear
flat and lifeless.
About PBR References: Throughout this tutorial, you may encounter references to
PBR (Physically Based Rendering) before reaching this chapter. PBR is a modern
NOTE rendering approach that simulates how light interacts with surfaces based on
physical principles. We’ll cover PBR in detail in the sections that follow, so don’t
worry if you’re not familiar with these concepts yet.
This chapter serves as the foundation for understanding how light interacts with different
materials in a physically accurate way. The concepts you’ll learn here will be applied in later
chapters, including the Loading_Models chapter where we’ll use this knowledge to render glTF
models with PBR materials.
Throughout our engine implementation, we’ll be using vk::raii dynamic rendering and C++20
modules. The vk::raii namespace provides Resource Acquisition Is Initialization (RAII) wrappers for
Vulkan objects, which helps with resource management and makes the code cleaner. Dynamic
rendering simplifies the rendering process by eliminating the need for explicit render passes and
framebuffers. C++20 modules improve code organization, compilation times, and encapsulation
compared to traditional header files.
148
Why Lighting Matters
Lighting in computer graphics serves several important purposes:
1. Visual Realism: Proper lighting creates shadows, highlights, and gradients that make 3D objects
appear more realistic.
2. Spatial Understanding: Lighting helps viewers understand the spatial relationships between
objects in a scene.
3. Mood and Atmosphere: Different lighting setups can dramatically change the mood and
atmosphere of a scene.
4. Focus and Attention: Lighting can be used to draw attention to important elements in a scene.
1. Fixed-Function Pipeline (1990s): Early 3D hardware used fixed lighting models like Gouraud
or Phong shading with limited material properties.
3. Physically Based Rendering (2010s): By basing rendering on physical principles, PBR provides
more realistic results that remain consistent across different environments.
• Intuitiveness: Material parameters have physical meaning, making them easier to understand
149
Energy Conservation
In the real world, a surface cannot reflect more light than it receives. This principle of energy
conservation is fundamental to PBR:
• The sum of diffuse and specular reflection must not exceed 1.0
• As surfaces become rougher, specular highlights become larger but less intense
Microfacet Theory
PBR uses microfacet theory to model surface roughness. This theory assumes that surfaces are
composed of tiny, perfectly reflective microfacets with varying orientations:
• Smooth surfaces have microfacets that are mostly aligned, creating sharp reflections
• Rough surfaces have randomly oriented microfacets, scattering light and creating blurry
reflections
Fresnel Effect
The Fresnel effect describes how reflectivity changes with viewing angle:
• All surfaces become more reflective at grazing angles (angles where the viewing direction is
nearly parallel to the surface)
• The base reflectivity at normal incidence (F0, when light hits the surface perpendicularly), is
determined by the material’s index of refraction
Metallic-Roughness Workflow
The PBR implementation in glTF and many modern engines uses the metallic-roughness workflow,
which defines materials using these primary parameters:
• Roughness: How smooth or rough the surface is (0.0 = mirror-like, 1.0 = rough)
This workflow is intuitive for artists and efficient for real-time rendering.
150
light is reflected from a surface, taking into account:
• Diffuse BRDF: Handles light that penetrates the surface, scatters, and exits
• Specular BRDF: Handles light that reflects directly from the surface
Diffuse BRDF
f_diffuse = albedo / π
Where:
• π is a normalization factor
More advanced models like Disney’s diffuse or Oren-Nayar can be used for increased realism,
especially for rough surfaces.
Specular BRDF
Where:
151
• F: Schlick’s approximation
Material Properties
In computer graphics, materials are defined by various properties:
• Metalness: How metallic the surface is (affects specular reflection and diffuse absorption)
• Roughness/Smoothness: How rough or smooth the surface is (affects specular highlight size
and sharpness)
• Ambient Occlusion: Approximates how much ambient light a surface point receives
• Refraction: Controls how light bends when passing through the material
• Dielectrics (Non-metals): Lower specular reflection, white specular, strong diffuse reflection
• Translucent Materials: Allow light to pass through and scatter within (e.g., skin, wax, marble)
• Transparent Materials: Allow light to pass through with minimal scattering (e.g., glass, water)
• Anisotropic Materials: Reflect light differently based on direction (e.g., brushed metal, hair)
Push constants are a way to send a small amount of data to shaders without having to create and
manage descriptor sets. They’re perfect for frequently changing data like material properties.
152
What You’ll Learn
By the end of this chapter, you’ll understand:
Let’s get started by exploring the principles of Physically Based Rendering in more detail.
In this section, we’ll explore various lighting models used in computer graphics, with a focus on
understanding the concepts rather than implementation details. We’ll discuss how different
lighting models simulate the interaction of light with surfaces, their advantages and limitations,
and when to use each approach.
In this chapter, we’ll introduce Physically Based Rendering (PBR) and other lighting models. The
concepts we cover here will be applied in later chapters, such as the Loading_Models chapter
where we’ll use glTF, which uses PBR with the metallic-roughness workflow for its material system.
By understanding the theory behind different lighting models, including PBR, we can better
leverage the material properties provided by glTF models and extend our rendering capabilities.
The way these interactions occur depends on the material properties and the characteristics of the
light.
Types of Reflection
There are two main types of reflection:
153
The Evolution of Lighting Models
Lighting models in computer graphics have evolved significantly over time, each with their own
approach to simulating light-surface interactions:
The simplest lighting model, where each polygon is assigned a single color based on its normal and
the light direction. This creates a faceted appearance with visible polygon edges.
Gouraud Shading
An improvement over flat shading, where lighting is calculated once per vertex and then
interpolated across the polygon. This per-vertex approach is significantly faster than per-pixel
calculations, but means specular highlights that should appear in the middle of a polygon can be
missed entirely since they’re not present at any vertex.
• When to use: For low-power devices where Phong shading is too expensive
One of the most widely used traditional lighting models, developed by Bui Tuong Phong in 1975.
When used with per-pixel shading (Phong Shading), normals are interpolated across the polygon
and lighting is calculated for every pixel, providing much more accurate specular highlights than
Gouraud’s per-vertex approach. The model calculates lighting using three components:
• Specular: Shiny highlights (using a power function of the reflection vector and view vector)
Characteristics:
• Advantages: Reasonably realistic for many materials, intuitive parameters, accurate specular
highlights with per-pixel shading
• Disadvantages: Not physically accurate, can look artificial under certain lighting conditions
• When to use: For simple real-time applications where PBR is too expensive
154
For more information on the Phong lighting model, see the Wikipedia article.
Blinn-Phong Model
A modification of the Phong model by Jim Blinn that uses the halfway vector between the light and
view directions instead of the reflection vector, making it more efficient to compute.
Learn more about Blinn-Phong in this Wikipedia article or this GPU Gems chapter.
A more physically-based model developed by Robert Cook and Kenneth Torrance in 1982. It uses
microfacet theory to model surface roughness and includes a more accurate specular term.
• When to use: When you need more realistic materials but full PBR is too expensive
Oren-Nayar Model
An extension of the Lambertian diffuse model that accounts for microfacet roughness in diffuse
reflection, making it more suitable for rough surfaces like cloth, concrete, or sand.
PBR represents one of the most significant advancements in real-time graphics over the past
decade. Unlike earlier ad-hoc shading models, PBR aims to simulate how light interacts with
surfaces based on the principles of physics.
• Microfacet Theory: Surfaces are modeled as collections of tiny mirrors with varying
155
orientations
• Metallic-Roughness Workflow: Materials are defined by their base color, metalness, and
roughness
• Advantages: Realistic results that remain consistent across different lighting conditions,
intuitive parameters for artists
• When to use: For modern games and applications where realism is important
For comprehensive information on PBR, see the Physically Based Rendering book.
• Roughness: How smooth or rough the surface is (0.0 = mirror-like, 1.0 = rough)
This workflow is intuitive for artists and efficient for real-time rendering. The glTF specification
provides a standardized way to define PBR materials that can be used across different rendering
engines.
For more information on the glTF PBR implementation, see the glTF 2.0 specification.
Light Types
Different lighting models can work with various types of light sources:
2. Directional Lights: Light rays are parallel, as if coming from a very distant source (like the
sun).
5. Image-Based Lighting (IBL): Light is derived from an environment map, simulating global
illumination.
Each type of light requires specific calculations for the light direction, attenuation, and other
156
properties.
Global Illumination
Global Illumination (GI) simulates how light bounces between surfaces, creating indirect lighting
effects. Techniques include:
Subsurface Scattering
Subsurface Scattering (SSS) simulates how light penetrates and scatters within translucent
materials like skin, wax, or marble.
For more information, see this GPU Gems chapter on subsurface scattering.
Ambient Occlusion
Ambient Occlusion (AO) approximates how much ambient light a surface point would receive,
darkening corners and crevices.
For more information, see this GPU Gems chapter on ambient occlusion.
3. Artist Workflow: Some models are more intuitive for artists to work with
4. Consistency: PBR provides more consistent results across different lighting conditions
For our engine, we’ll leverage the PBR implementation from the glTF format, as it provides a good
balance of realism, performance, and artist-friendly parameters.
157
Further Reading
To deepen your understanding of lighting models, here are some valuable resources:
• Physically Based Rendering: From Theory to Implementation - The definitive book on PBR
In the next section, we’ll explore how to use push constants to efficiently pass material properties
to our shaders.
In this section, we’ll explore push constants, a powerful feature in Vulkan that allows us to
efficiently pass small amounts of data to shaders without the overhead of descriptor sets.
Some key characteristics of push constants: they are tiny (typically up to 128 bytes, device
dependent), fast to update per draw, and require no descriptor sets or allocations because they live
on the command buffer. They can be read by any shader stage you enable in the pipeline.
158
int normalTextureSet;
int occlusionTextureSet;
int emissiveTextureSet;
float alphaMask;
float alphaMaskCutoff;
} material;
In Slang, which we’re using for our engine, the syntax is slightly different:
struct PushConstants {
float4 baseColorFactor;
float metallicFactor;
float roughnessFactor;
int baseColorTextureSet;
int physicalDescriptorTextureSet;
int normalTextureSet;
int occlusionTextureSet;
int emissiveTextureSet;
float alphaMask;
float alphaMaskCutoff;
};
159
// Create pipeline layout with vk::raii
vk::raii::PipelineLayout pipelineLayout =
[Link](pipelineLayoutInfo);
Here’s a comparison:
Update Frequency Ideal for frequent updates Better for infrequent updates
For our PBR implementation, we’ll use push constants for material properties and uniform buffers
160
for light information and transformation matrices.
In the next section, we’ll implement a basic lighting shader that uses push constants for material
properties.
In this section, we’ll implement a Physically Based Rendering (PBR) shader based on the concepts
we’ve explored in the previous sections. This shader will use the metallic-roughness workflow
that’s compatible with glTF models and push constants for material properties. We’ll examine the
shader implementation and then discuss how to integrate it with our engine.
We’ll break this shader into three distinct sections to better understand its architecture:
// Input from vertex buffer - Data sent per vertex from CPU
struct VSInput {
float3 Position : POSITION; // 3D position in model space
float3 Normal : NORMAL; // Surface normal for lighting calculations
float2 UV : TEXCOORD0; // Texture coordinates for material sampling
float4 Tangent : TANGENT; // Tangent vector for normal mapping (w component
= handedness)
};
161
// Uniform buffer - Global data shared across all vertices/fragments
struct UniformBufferObject {
float4x4 model; // Model-to-world transformation matrix
float4x4 view; // World-to-camera transformation matrix
float4x4 proj; // Camera-to-clip space projection matrix
float4 lightPositions[4]; // Light positions in world space
float4 lightColors[4]; // Light intensities and colors
float4 camPos; // Camera position for view-dependent effects
float exposure; // HDR exposure control
float gamma; // Gamma correction value (typically 2.2)
float prefilteredCubeMipLevels; // IBL prefiltered environment map mip levels
float scaleIBLAmbient; // IBL ambient contribution scale
};
// Mathematical constants
static const float PI = 3.14159265359;
The vertex input layout defines how vertex data is structured in GPU memory, with semantic
162
annotations like POSITION and NORMAL telling the GPU how to interpret each data component.
This structured approach allows the graphics pipeline to efficiently process vertex attributes and
pass them through the rendering stages.
When it comes to data management, we use two primary mechanisms: uniform buffers and push
constants. Uniform buffers are larger, read-only memory blocks that efficiently store data shared
across many draw calls, making them perfect for transformation matrices and lighting information
that remain constant across multiple objects. Push constants, on the other hand, are smaller
(typically limited to 128 bytes or less) but much faster for frequently changing per-object data like
material properties, making them ideal for our material system.
The resource binding syntax using [[vk::binding(x, y)]] creates the essential link between CPU
resources and GPU shader registers. The first number represents the binding index, while the
second specifies the descriptor set, allowing us to organize and efficiently access textures, samplers,
and other resources from within our shaders.
Finally, the interpolation system works seamlessly in the background, where data in our VSOutput
structure gets automatically interpolated across triangle surfaces by the GPU’s rasterization
hardware, ensuring smooth transitions of attributes like normals and texture coordinates across
the rendered surface.
163
// Geometry obstruction from light direction (shadowing)
float ggx2 = NdotL / (NdotL * (1.0 - k) + k);
The foundation of our PBR implementation rests on microfacet theory, which recognizes that real
surfaces consist of countless microscopic facets with varying orientations. Rather than trying to
model each individual facet, the BRDF statistically represents their collective behavior, allowing us
to achieve realistic lighting without the computational complexity of simulating every surface
detail. This approach was thoroughly explored in Walter et al.'s seminal 2007 paper "Microfacet
Models for Refraction through Rough Surfaces," which you can find at their comprehensive BSDF
documentation.
Our choice of the GGX distribution function, also known as Trowbridge-Reitz, stems from its ability
to produce realistic highlight shapes with longer tails compared to older models like Blinn-Phong.
This distribution function has become the standard in modern real-time rendering because it
closely matches measured material data and provides the natural falloff that we observe in real-
world materials. Eric Heitz’s 2014 work "Understanding the Masking-Shadowing Function in
Microfacet-Based BRDFs" provides deep insights into why this distribution works so well in
practice.
The Smith geometry function plays a crucial role by accounting for the statistical correlation
between masking (when the viewer can’t see a microfacet) and shadowing (when light can’t reach a
microfacet). This might seem like a technical detail, but it prevents energy gain at grazing angles
where naive models become unrealistically bright, ensuring our materials look believable under all
viewing conditions.
The Fresnel effect captures a phenomenon we see every day: materials become more reflective at
grazing angles, like water appearing mirror-like when viewed from the side. Schlick’s
approximation gives us this essential behavior while trading some accuracy for the performance
we need in real-time applications. The F0 parameter represents reflectance at normal incidence (0°
viewing angle), allowing us to control how reflective different materials appear when viewed head-
on.
Finally, energy conservation ensures that the sum of reflected and transmitted light never exceeds
the incident light, maintaining physical plausibility. This principle guides how we balance diffuse
and specular components, ensuring our materials look consistent and believable under varying
lighting conditions.
Further Reading:
164
For deeper exploration of these concepts, "Real-Time Rendering, 4th Edition" Chapter 9 on
Physically Based Shading provides comprehensive coverage of the theory and practice. The online
"PBR Book" by Pharr, Jakob, and Humphreys at [Link] offers an exhaustive
mathematical treatment of physically based rendering. For practical implementation insights, Epic
Games' "Real Shading in Unreal Engine 4" presentation from the 2013 Shading Course demonstrates
how these concepts translate into production-ready code.
return output;
}
165
// Sample base color texture and apply material color factor
float4 baseColor = [Link](baseColorSampler, [Link]) *
[Link];
166
// Initialize outgoing radiance accumulator
float3 Lo = float3(0.0, 0.0, 0.0);
167
// Add simple ambient lighting (should be replaced with IBL in production)
float3 ambient = float3(0.03, 0.03, 0.03) * [Link] * ao;
The vertex shader serves as the first stage of our rendering pipeline, with its primary responsibility
being geometric transformation. It converts vertex positions through the standard MVP (Model-
View-Projection) matrix pipeline, systematically transforming coordinates from model space to
world space, then to camera space, and finally to clip space in preparation for rasterization. This
transformation chain ensures that our 3D geometry appears correctly positioned and projected for
the viewer.
Beyond basic transformation, the vertex shader handles crucial attribute processing by
transforming normals from model space to world space and passing through texture coordinates
and tangent vectors that the fragment shader will need. This attribute processing ensures that
lighting calculations in the fragment shader receive properly transformed surface information,
while texture coordinates and tangent vectors maintain their relationships for accurate material
sampling and normal mapping.
The vertex shader also performs essential data preparation by setting up interpolated values that
the fragment shader requires for lighting calculations. These interpolated values, such as world
positions and transformed normals, get automatically interpolated across triangle surfaces by the
GPU’s rasterization hardware, providing smooth transitions that enable realistic per-pixel lighting
in the subsequent fragment stage.
The fragment shader represents the heart of our PBR implementation, beginning with
comprehensive material sampling that extracts surface properties like color, roughness, and
metallic values from texture maps. This sampling process reads multiple texture channels
according to the glTF standard, combining texture data with material parameters passed through
push constants to determine the final surface characteristics for each pixel.
Normal mapping reconstruction forms another critical objective, where the fragment shader takes
encoded normal information from normal maps and reconstructs detailed surface normals that
simulate fine geometric detail without requiring additional geometry. This process involves
168
sampling the normal map, transforming the values from texture space to world space using the
tangent-bitangent-normal matrix, and applying the resulting detailed normals to lighting
calculations.
The core PBR lighting implementation brings together all these elements using the Cook-Torrance
microfacet model with proper energy conservation. This involves evaluating the distribution,
geometry, and Fresnel terms of the BRDF, carefully balancing diffuse and specular contributions to
ensure physically plausible results across all viewing angles and material types.
Finally, post-processing operations convert the HDR linear lighting results into display-appropriate
sRGB values through tone mapping and gamma correction. This final stage compresses the high
dynamic range values generated by realistic lighting calculations into the limited range that
displays can show, while maintaining visual fidelity and preventing the harsh clipping that would
otherwise occur with bright highlights.
Our implementation carefully follows established conventions and best practices to ensure
compatibility and visual quality. We adhere to the glTF texture channel convention where metallic
information uses the blue channel and roughness uses the green channel, enabling seamless
integration with standard 3D authoring tools and asset pipelines. This convention ensures that
materials created in external tools will render correctly without requiring texture channel
remapping or custom import procedures.
Energy conservation remains paramount throughout our implementation, with careful attention
paid to ensuring that diffuse plus specular contributions never exceed unity through the kS/kD
relationship. This physical constraint prevents materials from appearing to emit more light than
they receive, maintaining believable appearance across different lighting conditions and viewing
angles while avoiding the artificial brightness that can plague non-physically-based approaches.
Numerical stability considerations appear throughout the implementation, with small epsilon
values added to prevent division by zero in BRDF calculations and careful handling of edge cases
where mathematical operations might produce undefined results. These seemingly minor details
prove crucial for robust rendering that handles extreme material parameters and unusual viewing
angles without producing artifacts or rendering failures.
The HDR pipeline architecture ensures that all lighting calculations occur in linear space,
preserving the full dynamic range of realistic lighting throughout the computation stages and only
applying gamma correction at the final output stage. This approach maintains maximum precision
and accuracy in the lighting calculations while ensuring that the final image appears correct on
standard sRGB displays.
This shader implements the PBR lighting model with the metallic-roughness workflow, but the goal
here is not just to show "what" the code does — it’s to explain "why" each piece exists.
169
Why these BRDF terms (D, G, F)
The Normal Distribution Function (D) serves as the statistical heart of our microfacet model,
determining how many surface microfacets are oriented to reflect light directly toward the viewer.
This function explains why rough surfaces produce broader, dimmer highlights while smooth
surfaces create tight, bright reflections. We chose the GGX distribution because it matches
measured material data remarkably well and produces the natural long tails in highlights that we
observe in real-world materials, avoiding the artificial cutoff that plagued older distribution
functions like Blinn-Phong.
The Geometry function (G) addresses a crucial physical reality: microfacets cast shadows on each
other and can be hidden from view depending on the surface roughness and viewing angle.
Without proper geometric consideration, highlights become unrealistically bright as roughness
increases because we’d be ignoring the natural self-shadowing and masking that occurs on rough
surfaces. Smith’s approach with our roughness-derived k parameter provides an efficient yet
physically plausible solution that maintains energy conservation across all viewing conditions.
Fresnel reflectance (F) captures one of the most fundamental optical phenomena we encounter
daily: surfaces become more reflective at grazing angles, just as you can see your reflection clearly
in water when looking across its surface but hardly at all when looking straight down. Schlick’s
approximation gives us this essential angle-dependent behavior with minimal computational cost,
while the F0 parameter allows us to control how reflective materials appear when viewed head-on,
distinguishing between different material types.
Energy conservation ties these components together by ensuring that the sum of reflected light
never exceeds the incident light, maintaining physical plausibility. When more light reflects
specularly (kS), correspondingly less can reflect diffusely (kD = 1 - kS), creating the natural balance
that keeps materials looking believable across different lighting conditions and viewing angles
while preventing the artificial brightness that can make rendered scenes look unrealistic.
From an artistic perspective, this workflow proves remarkably intuitive because it presents artists
with just two conceptual dials to control: metalness (distinguishing between non-metals and
metals) and roughness (controlling the surface finish from perfectly smooth to completely rough),
plus the base color. This simplification allows artists to focus on the visual intent rather than
getting lost in complex parameter interactions, while still providing the full range of material
appearances found in the real world.
The workflow also handles F0 behavior correctly by encoding the fundamental difference between
metallic and non-metallic materials. Non-metals typically have low F0 values around 0.02 to 0.08
170
(we use 0.04 as a reasonable default), while metals derive their colored specular reflectance directly
from the base color. Our lerp(F0, baseColor, metallic) operation elegantly encodes this physical
distinction, automatically transitioning from the achromatic reflectance of dielectrics to the colored
reflectance of conductors as the metallic parameter increases.
• Working in HDR (linear float) preserves detail through the lighting pipeline. Only at the end do
we compress dynamic range using a tone mapper to fit the display.
• In this chapter we use simple Reinhard: color / (color + 1). It’s robust and artifact-free, good as a
baseline. Alternatives you might adopt later:
◦ ACES (RRT/ODT): Filmic with good color preservation across extremes; widely used.
◦ Reinhard with exposure: Multiply color by an exposure before compressing to shift middle
gray.
• Exposure parameter ([Link]): Conceptually shifts scene brightness so midtones sit well
under your chosen tone mapper. Even if the snippet shows a fixed operator, you can pre-scale
color by exposure to support dynamic auto-exposure.
• Gamma correction ([Link]): Displays are non-linear (approx 2.2). Lighting must happen in
linear space, then we apply pow(color, 1/gamma) right before writing to the sRGB framebuffer.
Skipping this causes washed-out or too-dark images.
• Pipeline note: Prefer sRGB formats for color attachments when presenting. If writing to an sRGB
swapchain image, do gamma in shader OR use sRGB formats so hardware handles it — not both.
Do exactly one.
171
Practical tuning checklist
• If highlights look “plasticky” everywhere, roughness may be too low or kD not reduced by
metallic; verify kD *= (1 - metallic).
• If everything clips to white, add/adjust exposure and switch to ACES or Filmic tone mapping.
• If colors shift in highlights, check that tone mapping happens in linear space and gamma is
applied only once.
• If normal maps look inverted or seams appear, verify tangent handedness (TBN), normal map
channel order, and normal map space.
• If ambient looks flat, confirm AO is applied to ambient/IBL but not to direct specular.
Let’s start by adding a new function to create the PBR pipeline. This process involves several
distinct steps, each serving a specific purpose in configuring the Vulkan graphics pipeline for
physically based rendering.
bool Renderer::createPBRPipeline() {
try {
// Load our compiled PBR shader from disk
// The .spv file contains both vertex and fragment shader code compiled by
slangc
auto shaderCode = readFile("shaders/[Link]");
172
.setModule(*shaderModule)
.setPName("VSMain"); // Must match the vertex shader
function name
The entry point names ("VSMain" and "PSMain") must exactly match the function names in our
shader code. This explicit binding system gives us fine-grained control over which functions serve
which pipeline stages, and it’s particularly useful when working with shader libraries that contain
multiple variations of vertex or fragment shaders.
// Configure how vertex data is structured and fed to the vertex shader
vk::PipelineVertexInputStateCreateInfo vertexInputInfo;
// Define the vertex buffer binding - describes the overall vertex structure
// This tells Vulkan the total size of each vertex and how vertices are
arranged
vk::VertexInputBindingDescription bindingDescription;
[Link](0) // Binding point 0
.setStride(sizeof(float) * 14) // Total vertex size:
pos(3) + normal(3) + uv(2) + tangent(4) + bitangent(2)
.setInputRate(vk::VertexInputRate::eVertex); // Data advances
per vertex (not per instance)
173
data
// Connect the binding and attribute descriptions to the vertex input state
[Link](1)
.setPVertexBindingDescriptions(&bindingDescription)
.setVertexAttributeDescriptionCount(static_cast<uint32_t>([Link]()
))
.setPVertexAttributeDescriptions([Link]());
The vertex input configuration serves as a contract between our vertex buffer data and the vertex
shader inputs. Each attribute description maps a specific piece of vertex data to a shader input
location, with precise format and offset specifications. This explicit mapping system ensures that
the GPU correctly interprets our vertex data regardless of how it’s packed in memory.
The stride calculation (14 floats) reflects our comprehensive vertex format that supports full PBR
174
rendering: position for geometry, normals for basic lighting, UV coordinates for texture sampling,
and tangent vectors for normal mapping. The tangent vector includes a fourth component (W) that
stores handedness information, which is crucial for correctly reconstructing the bitangent vector in
cases where the tangent space might be flipped.
The offset calculations ensure that each attribute starts at the correct byte position within each
vertex. This precise alignment is for performance, as misaligned vertex data can cause significant
performance penalties on some GPU architectures.
Triangle lists represent the most straightforward and commonly used primitive topology for
complex 3D models. In this mode, every group of three consecutive vertices defines a complete
triangle, providing maximum flexibility for representing arbitrary geometry. While other
topologies like triangle strips or fans can be more memory-efficient for certain geometric patterns,
triangle lists avoid the complexity of degenerate triangles and vertex ordering constraints that can
arise with more compact representations.
Primitive restart functionality allows special index values to signal the end of one primitive and the
beginning of another, but this feature adds complexity that’s unnecessary for most PBR rendering
scenarios. By disabling it, we ensure predictable behavior and avoid potential performance
penalties associated with index buffer scanning.
175
changes
std::vector<vk::DynamicState> dynamicStates = {
vk::DynamicState::eViewport, // Viewport can change (window resize,
camera changes)
vk::DynamicState::eScissor // Scissor rectangle can change (UI
clipping, effects)
};
vk::PipelineDynamicStateCreateInfo dynamicState;
[Link](static_cast<uint32_t>([Link]()))
.setPDynamicStates([Link]());
The single viewport approach covers the vast majority of rendering scenarios. Multi-viewport
rendering is primarily used for specialized applications like VR stereo rendering or certain shadow
mapping techniques, but single-viewport rendering provides optimal performance for standard
PBR applications.
Rasterization Configuration
The rasterization stage converts geometric primitives into fragments (potential pixels) and applies
various geometric processing options that affect how triangles are converted to pixels.
The rasterization configuration directly impacts both rendering performance and visual quality.
Back-face culling provides a significant performance boost by eliminating triangles that face away
from the camera, effectively halving the fragment processing workload for typical closed meshes.
176
The counter-clockwise winding order follows the standard convention used by most 3D modeling
tools and asset pipelines.
Fill mode produces solid triangles appropriate for PBR rendering, though wireframe mode can be
useful for debugging geometry or creating special visual effects. The line width setting only affects
wireframe rendering, but some graphics drivers require it to be specified even when using fill
mode.
Depth bias (also known as polygon offset) is commonly used in shadow mapping to prevent self-
shadowing artifacts, but it’s unnecessary for standard forward rendering and can introduce its own
artifacts if used inappropriately.
This configuration disables multisampling anti-aliasing (MSAA) for simplicity and performance.
While MSAA can significantly improve visual quality by reducing aliasing artifacts on geometric
edges, it also substantially increases memory bandwidth requirements and fragment processing
costs. For learning purposes and initial implementations, single-sample rendering provides a good
balance between performance and complexity.
In production applications, you might enable MSAA by increasing the sample count to 4x or 8x,
depending on performance requirements and target hardware capabilities. Per-sample shading,
when enabled, runs the fragment shader once per sample rather than once per pixel, providing the
highest quality anti-aliasing at the cost of proportionally increased fragment processing time.
177
passes if its depth is less (closer)
.setDepthBoundsTestEnable(false) // Don't use
depth bounds testing
.setStencilTestEnable(false); // Don't use
stencil testing
Depth testing forms the foundation of proper 3D rendering by ensuring that closer objects occlude
more distant ones. The "less than" comparison function works with the standard depth buffer
convention where smaller depth values represent closer fragments. This configuration writes depth
values for each rendered fragment, building up the depth buffer that subsequent draw calls can use
for occlusion testing.
Depth bounds testing and stencil testing are advanced features used for specific rendering
techniques like light volume optimization or complex compositing operations. For standard PBR
rendering, they add unnecessary complexity without providing benefits, so we disable them to
maintain optimal performance.
// Configure color blending - how new pixels combine with existing ones
vk::PipelineColorBlendAttachmentState colorBlendAttachment;
[Link](
vk::ColorComponentFlagBits::eR | vk::ColorComponentFlagBits::eG | //
Write all color channels
vk::ColorComponentFlagBits::eB | vk::ColorComponentFlagBits::eA)
.setBlendEnable(true) //
Enable alpha blending
.setSrcColorBlendFactor(vk::BlendFactor::eSrcAlpha) //
New fragment's alpha
.setDstColorBlendFactor(vk::BlendFactor::eOneMinusSrcAlpha)
// One minus new fragment's alpha
.setColorBlendOp(vk::BlendOp::eAdd) //
Add source and destination
.setSrcAlphaBlendFactor(vk::BlendFactor::eOne) //
Preserve new alpha
.setDstAlphaBlendFactor(vk::BlendFactor::eZero) //
Ignore old alpha
.setAlphaBlendOp(vk::BlendOp::eAdd); //
Add alpha values
vk::PipelineColorBlendStateCreateInfo colorBlending;
[Link](false) //
Don't use logical operations
.setAttachmentCount(1) //
Single color attachment
178
.setPAttachments(&colorBlendAttachment);
This blend configuration implements standard alpha transparency using the classic "over"
compositing operation. The formula (srcAlpha * newColor) + ((1 - srcAlpha) * oldColor)
produces natural-looking transparency effects where fully opaque fragments (alpha = 1) completely
replace the background, while partially transparent fragments blend proportionally.
The separate alpha blending configuration preserves the alpha channel properly for potential
multi-pass rendering or post-processing effects. By setting source alpha factor to one and
destination alpha factor to zero, we ensure that the final alpha value comes entirely from the new
fragment, which is typically the desired behavior for transparency effects.
The pipeline layout serves as a contract between the application and shaders regarding resource
organization. Push constants provide the fastest path for updating small amounts of data (like
material properties) between draw calls, as they bypass the memory hierarchy and are directly
accessible to shader cores. The 128-byte limit on push constants in most implementations makes
them perfect for per-material data but unsuitable for larger datasets.
The descriptor set layout reference connects our pipeline to the texture and uniform buffer
bindings we established earlier. This separation of concerns allows the same descriptor set layout
to be used across multiple pipelines while maintaining clean resource organization.
179
Phase 10: Final Pipeline Creation and Dynamic
Rendering Setup
The final phase assembles all configuration states into a complete graphics pipeline and sets up
dynamic rendering compatibility for modern Vulkan applications.
180
return true;
} catch (const std::exception& e) {
std::cerr << "Error creating PBR pipeline: " << [Link]() << std::endl;
return false;
}
}
The pipeline creation represents the culmination of all our configuration work, where Vulkan
validates the entire pipeline specification and compiles it into an optimized form suitable for GPU
execution. The dynamic rendering configuration replaces the traditional render pass system with a
more flexible approach that allows render targets to be specified at command recording time
rather than pipeline creation time.
This flexibility proves particularly valuable for applications that need to render to different targets
(like shadow maps, reflection textures, or post-processing buffers) using the same pipeline. The
format specifications ensure that the pipeline generates output compatible with our target render
surfaces.
The exception handling provides essential feedback during development, as pipeline creation
failures can result from subtle configuration mismatches or resource compatibility issues that are
difficult to debug without proper error reporting.
This function creates a new pipeline for our PBR shader, including support for push constants. We’ll
also need to update our uniform buffer to include light information:
181
0.0f), glm::vec3(0.0f, 0.0f, 1.0f));
[Link] = glm::perspective(glm::radians(45.0f), [Link] /
(float)[Link], 0.1f, 100.0f);
[Link][1][1] *= -1; // Flip Y coordinate for Vulkan
}
// Set up lights
// Light 1: White light from above
[Link][0] = glm::vec4(0.0f, 5.0f, 5.0f, 1.0f);
[Link][0] = glm::vec4(300.0f, 300.0f, 300.0f, 1.0f);
// Copy the uniform buffer object to the device memory using vk::raii
// With vk::raii, we can use the mapped memory directly
memcpy(uniformBuffers[currentFrame].mapped, &ubo, sizeof(ubo));
}
Finally, we need to add support for pushing material properties to the shader:
182
[Link] = [Link];
[Link] = [Link];
[Link] =
[Link];
[Link] = [Link];
[Link] = [Link];
[Link] = [Link];
[Link] = [Link] == AlphaMode::MASK ? 1.0f : 0.0f;
[Link] = [Link];
In the next section, we’ll integrate our lighting implementation with the rest of the Vulkan
rendering pipeline.
In this section, we’ll integrate our PBR implementation with the rest of the Vulkan rendering
pipeline. We’ll update our renderer class to support advanced lighting techniques that can be used
with glTF models and their PBR materials. The techniques we develop here will be applied in the
Loading_Models chapter when we load and render glTF models.
To keep the flow concrete and avoid repeating earlier theory, use this quick roadmap:
1) Extend the renderer with PBR pipeline objects and a material push-constant block 2) Create the
PBR pipeline (layout, shaders, blending, formats) alongside the main pipeline 3) Record draws: bind
PBR pipeline, bind geometry, and push per-material constants per mesh 4) Clean up via RAII (no
special teardown required)
The PBR pass slots into the graphics pipeline as shown below:
183
class Renderer {
public:
// ... existing members ...
// PBR pipeline
vk::raii::PipelineLayout pbrPipelineLayout;
vk::raii::Pipeline pbrPipeline;
// New methods
bool createPBRPipeline();
void pushMaterialProperties(vk::CommandBuffer commandBuffer, const Model* model,
uint32_t materialIndex);
};
We’ve added members for the PBR pipeline and a struct for PBR material properties. We’ve also
added methods for creating the PBR pipeline and pushing material properties to the shader.
184
return false;
}
initialized = true;
return true;
}
void Renderer::Cleanup() {
// ... existing cleanup code ...
// With vk::raii, pipeline and pipeline layout objects are automatically destroyed
// when they go out of scope, so we don't need explicit destruction calls
185
// Bind descriptor sets
[Link](
vk::PipelineBindPoint::eGraphics,
*pbrPipelineLayout,
0,
1,
&descriptorSets[imageIndex],
0,
nullptr
);
// Draw
[Link]([Link], 1, [Link], 0, 0);
}
}
void Renderer::renderTestScene() {
186
// Set up camera
glm::vec3 cameraPos = glm::vec3(0.0f, 0.0f, 3.0f);
glm::vec3 cameraTarget = glm::vec3(0.0f, 0.0f, 0.0f);
glm::vec3 cameraUp = glm::vec3(0.0f, 1.0f, 0.0f);
// Set up lights
// Light 1: White light from above
glm::vec4 lightPos1 = glm::vec4(0.0f, 5.0f, 5.0f, 1.0f);
glm::vec4 lightColor1 = glm::vec4(300.0f, 300.0f, 300.0f, 1.0f);
// The models already have PBR materials defined in the glTF file
// We can render them directly with our PBR pipeline
Conclusion
In this section, we’ve integrated our PBR implementation with the rest of the Vulkan rendering
pipeline. We’ve updated our renderer class to support advanced lighting techniques that can be
used with glTF models and their PBR materials. We’ve created a PBR shader based on the concepts
187
we’ve learned and shown how to test the implementation with glTF models.
This approach provides a solid foundation for rendering physically accurate materials, which we’ll
apply in the Loading_Models chapter when we load and render glTF models. It also gives us the
flexibility to modify and extend the material properties as needed for our specific rendering
requirements.
In the next section, we’ll explore how to add high-quality shadows using Vulkan Ray Query.
In this chapter, we’ve explored the fundamentals of lighting and materials in 3D rendering and
introduced Physically Based Rendering (PBR) using the metallic-roughness workflow. We’ve
covered the theory behind PBR, implemented a shader that can be used with glTF models, and
added high-quality shadows using Vulkan Ray Query. We’ve also learned how to use push constants
to efficiently pass material properties to our shaders.
• BRDF roles: D shapes the highlight (roughness controls lobe width), G enforces masking/self-
shadowing on microfacets, F boosts reflectance at grazing angles and ties reflectivity to material
type via F0. Energy conservation links specular (kS) and diffuse (kD) so total doesn’t exceed
what came in.
◦ Roughness: widens/narrows the specular lobe and also reduces peak intensity via G.
◦ Metallic: cross-fades between dielectric behavior (colored diffuse + neutral specular) and
conductor behavior (colored specular, no diffuse).
◦ Base color: is diffuse albedo for dielectrics and colored specular for metals.
188
• Normal/AO/emissive context: normal maps perturb local orientation to add detail, AO damps
indirect/ambient to avoid flat crevices, emissive adds light-independent glow.
• Output staging: after summing ambient/indirect and direct lighting, compress HDR with a tone
mapper (e.g., Reinhard/ACES) and only then apply gamma to match the display. Do gamma
exactly once (either shader pow or sRGB framebuffer).
1. Confirm spaces and order: linear lighting ➜ tone map ➜ gamma. Check you’re not doing double-
gamma.
2. Probe the BRDF: plastic look on everything? Roughness too low or kD not reduced by metallic.
Dim, muddy highlights? Roughness too high or exposure too low.
3. Validate normals/TBN: inverted green channel or wrong tangent handedness causes odd
shading and seams.
4. Calibrate exposure/tone map: if whites clip harshly, add exposure control and/or switch to
ACES/Hable for smoother roll-off.
5. Use AO and emissive judiciously: AO should affect ambient/IBL, not direct specular; emissive is
additive and independent of lights.
This mental model helps you predict how a change to any input will echo through the pipeline and
appear on screen, which is the core of “understanding,” not just following a list.
Potential Improvements
Our PBR pass is a solid baseline. The most impactful upgrades are image‑based lighting
(environment maps for ambient/indirect) and a few material extensions (e.g., clear coat or
anisotropy). On the performance side, consider clustered forward or a deferred path when light
counts grow. If you build an HDR chain, bloom and a more filmic tone mapper (ACES/Hable) round
out the presentation.
Next Steps
Pick one thread and go deep. For lighting, explore GI/AO/volumetrics as time allows. For materials,
design a data‑driven system that maps glTF (and custom) parameters cleanly to your shaders. For
visuals, prototype post effects (fog, bloom, DoF). For performance, profile first, then optimize the
hot spots—especially on mobile.
Remember that lighting is a complex topic with many approaches and techniques. The
implementation we’ve covered in this chapter is just the beginning. As you continue to develop
your engine, you’ll likely want to refine and expand your lighting system to meet the specific needs
of your projects.
In the next chapter, we’ll explore GUI implementation, which will allow us to create interactive
user interfaces for our applications.
189
Shadows are more than just dark patches on the ground; they are fundamental to how we perceive
3D space. They provide critical visual cues about the position, shape, and scale of objects, as well as
the nature of the light sources illuminating them. In this section, we’ll move from simple "flat"
lighting to a more realistic model by implementing hardware-accelerated shadows using Vulkan
Ray Query.
Understanding Shadows
In the physical world, shadows occur when an opaque object obstructs the path of light from a
source to a surface. To simulate this in computer graphics, we must solve the visibility problem:
for any given point on a surface, is there an unobstructed line of sight to the light source?
• Umbra: The darkest part of the shadow where the light source is completely occluded.
• Penumbra: The "soft" edge of the shadow where the light source is only partially occluded.
While our initial implementation focuses on "hard" shadows (where a point is either 100% in
shadow or 100% lit), the techniques we use here support advanced soft shadowing by sampling the
light as an area rather than a point.
• Resolution & Aliasing: Shadows can look "blocky" if the shadow map resolution is too low.
• Biasing Issues: Finding the right "bias" to prevent shadow acne (self-shadowing) and peter-
panning (shadows detaching from objects) is a constant struggle.
• Memory Overhead: Each light source requires its own depth texture.
Ray Tracing (Ray Query) solves these issues by performing precise geometric intersections.
Instead of checking a low-resolution texture, we ask the GPU: "Does this ray hit any triangle
between point A and point B?" This results in pixel-perfect accuracy and simplifies the handling of
multiple light types (point, spot, directional) without managing dozens of depth maps.
1. Bottom-Level Acceleration Structure (BLAS): This stores the raw geometry (vertices and
190
indices) for a single mesh. Think of it as a spatial index for a single object.
2. Top-Level Acceleration Structure (TLAS): This contains instances of BLASs. Each instance has
its own transformation matrix, allowing us to place the same mesh multiple times in the world
with minimal memory overhead.
• VK_KHR_acceleration_structure
• VK_KHR_ray_query
vk::AccelerationStructureInstanceKHR instance{};
[Link] = toVkTransform(transform->getMatrix());
[Link] = getBufferDeviceAddress(mesh-
>[Link]);
[Link] = 0xFF; // Allows filtering objects during ray tests
instances.push_back(instance);
}
buildTlas(instances);
return true;
}
191
Implementing Ray Query in Shaders
With the TLAS built and bound to a descriptor set, we can perform visibility tests directly in our
PBR fragment shader ([Link]).
RayQuery<RAY_FLAG_NONE> q;
[Link](
tlas,
RAY_FLAG_ACCEPT_FIRST_HIT_AND_END_SEARCH, // Optimization: any hit is enough
to shadow
0xFF,
ray
);
while ([Link]()) {
// [Link]() steps through potential hits.
// For simple opaque shadows, we don't need logic here.
}
192
// Important: Move the origin slightly along the normal to prevent the ray
// from immediately hitting the surface it started from.
float3 shadowOrigin = [Link] + N * RASTER_SHADOW_EPS;
if (!occluded) {
// Add diffuse and specular contributions if not in shadow
directLighting += calculatePBR(L, V, N, ...);
}
193
directLighting += calculatePBR(...) * finalVisibility;
2. Alpha Masking & Transmissivity: For foliage or glass, a simple binary hit test isn’t enough. Our
engine handles this by:
◦ Manual Alpha Testing: In the while([Link]()) loop, we fetch the material’s texture and
discard hits that are transparent.
◦ Transmissive Bypass: We can flag certain materials (like glass) as non-occluding for
shadow rays so they don’t cast pitch-black shadows.
3. Performance: Ray tracing is expensive. While Ray Query is faster than a full ray tracing
pipeline for simple visibility, it still adds cost. For high-performance scenarios, consider:
◦ Denoising: If you use multiple rays for soft shadows, you’ll need a denoiser to clean up the
grain.
◦ Culling: Don’t trace rays for lights that are too far away or behind the surface.
• Ambient Occlusion (RTAO): Use ray tracing to calculate how much ambient light reaches a
point by tracing rays in a hemisphere around the normal.
• Vulkan Ray Tracing Tutorial: The NVIDIA Vulkan Ray Tracing Tutorial is an excellent resource
194
for deep-diving into these extensions.
In the next chapter, we’ll look at how to add a Graphical User Interface (GUI) to control these
lighting and shadow parameters in real-time.
Contents
• Introduction
• Lighting Models
• Push Constants
• Lighting Implementation
• Vulkan Integration
• Shadows
• Conclusion :pp: ++
GUI: Introduction
Introduction
Welcome to the "GUI" chapter of our "Building a Simple Engine" series! After implementing a
camera system in the previous chapter, we’ll now focus on adding a graphical user interface (GUI)
to our Vulkan application. A well-designed GUI is essential for creating interactive applications that
allow users to control settings, display information, and interact with the 3D scene.
In this chapter, we’ll integrate a popular immediate-mode GUI library called Dear ImGui with our
Vulkan engine. Dear ImGui is widely used in the game and graphics industry due to its simplicity,
performance, and flexibility. It allows developers to quickly create debug interfaces, tools, and in-
game menus without the complexity of traditional retained-mode GUI systems.
This chapter will guide you through integrating a professional GUI system into your Vulkan engine.
We’ll start by setting up Dear ImGui with Vulkan, establishing the foundation for all GUI
functionality. The integration requires careful management of Vulkan resources—we’ll create
195
dedicated buffers, textures, and pipelines that work alongside your existing rendering systems
without interference.
User input handling becomes more complex when you need to support both 3D scene navigation
and GUI interaction. We’ll implement a system that can distinguish between input intended for the
3D world and input meant for interface elements, ensuring smooth interaction with both.
Rather than overwhelming you with exhaustive widget examples, we’ll focus on the key integration
concepts that enable GUI functionality. Understanding these principles will let you implement any
interface elements your project needs.
The rendering integration presents interesting challenges—your GUI needs to render on top of your
3D scene without disrupting the existing pipeline. We’ll solve this by carefully managing render
passes and ensuring proper depth testing and blending.
Finally, we’ll implement object picking, which bridges the gap between your GUI and 3D scene. This
feature allows users to click on 3D objects and see their properties in the interface, creating a
cohesive development environment.
By the end of this chapter, you’ll have a functional GUI system that you can use to control your
camera, adjust rendering settings, and interact with your 3D scene. This will serve as a foundation
for more advanced features in later chapters, such as material editors, scene hierarchies, and
debugging tools.
Prerequisites
This chapter builds directly on the Camera & Transformations chapter, as we’ll extend the camera
system we developed there to work seamlessly with GUI interaction. The camera controls need to
be aware of when the user is interacting with interface elements versus navigating the 3D scene.
You’ll also need a solid understanding of several core Vulkan concepts. The rendering pipeline and
command buffer knowledge is crucial because GUI rendering requires careful coordination with
your existing 3D rendering—we’ll be recording GUI draw calls into the same command buffers
while managing different pipeline states.
Buffer and image creation skills are essential since Dear ImGui requires dedicated vertex and index
buffers for its geometry, plus texture resources for fonts and any custom UI textures.
Understanding descriptor sets and layouts becomes important as we’ll need to create descriptors
specifically for GUI rendering that don’t interfere with your 3D scene descriptors.
Pipeline creation knowledge ties everything together, as we’ll build a specialized graphics pipeline
for GUI rendering with different vertex input, shaders, and render state than your 3D pipeline.
A basic understanding of input handling concepts will help you follow along as we implement the
dual-mode input system that can distinguish between 3D navigation and GUI interaction.
You should also be familiar with the following chapters from the main tutorial:
196
◦ Command buffers
◦ Graphics pipelines
• Uniform buffers
• Texture mapping
Let’s begin by exploring how to implement a professional GUI system with Dear ImGui and Vulkan.
Previous: Lighting & Materials Conclusion | Next: Setting Up Dear ImGui :pp: ++
3. Manual Integration: Download and include the ImGui source files directly
For this tutorial, we’ll use the manual integration approach for simplicity:
# ImGui files
197
set(IMGUI_SOURCES
src/[Link]
src/imgui_draw.cpp
src/imgui_widgets.cpp
src/imgui_tables.cpp
src/imgui_demo.cpp
)
add_executable(VulkanApp
src/[Link]
${IMGUI_SOURCES}
${IMGUI_VULKAN_SOURCES}
)
The ImGuiVulkanUtil class serves as the bridge between ImGui’s immediate-mode GUI system and
Vulkan’s explicit graphics API. This integration requires careful management of GPU resources,
synchronization, and rendering state to efficiently display user interface elements alongside our 3D
graphics. Let’s break down the class architecture into logical components to understand how each
part contributes to the overall integration.
// ImGuiVulkanUtil.h
#pragma once
#include <vulkan/vulkan_raii.hpp>
#include <imgui.h>
class ImGuiVulkanUtil {
private:
// Core GPU rendering resources for UI display
// These objects form the foundation of our ImGui-to-Vulkan rendering pipeline
vk::raii::Sampler sampler{nullptr}; // Texture sampling
198
configuration for font rendering
Buffer vertexBuffer; // Dynamic vertex buffer
for UI geometry
Buffer indexBuffer; // Dynamic index buffer
for UI triangle connectivity
uint32_t vertexCount = 0; // Current vertex count for
draw commands
uint32_t indexCount = 0; // Current index count for
draw commands
Image fontImage; // GPU texture containing
ImGui font atlas
ImageView fontImageView; // Shader-accessible view
of font texture
The GPU resource foundation reflects ImGui’s dynamic rendering model, where UI geometry is
generated fresh each frame based on the current interface layout. The vertex and index buffers use
host-visible memory to enable efficient CPU updates, while the font texture remains static once
loaded. This hybrid approach balances the need for dynamic UI updates with the performance
benefits of GPU-resident font data.
The buffer sizing strategy must accommodate ImGui’s variable geometry output, which can change
dramatically based on UI complexity. Unlike static 3D models, ImGui generates different amounts of
geometry each frame, requiring our buffers to resize dynamically or be pre-allocated with
sufficient capacity for worst-case scenarios.
The pipeline infrastructure creates a specialized graphics pipeline optimized for UI rendering,
199
which differs significantly from typical 3D rendering pipelines. UI rendering typically requires
alpha blending for transparency effects, operates in 2D screen space rather than 3D world space,
and uses simpler shading models focused on texture sampling rather than complex lighting
calculations.
Frames-in-flight safety: If your renderer uses more than one frame in flight and you
do not stall the GPU between frames, you must duplicate the dynamic ImGui buffers
(vertex/index) per frame-in-flight. Using a single shared vertex/index buffer risks
NOTE the CPU overwriting data still in use by the GPU from a previous frame. The simple
single-buffer members shown above are for conceptual clarity; in production, store
vectors of buffers/memories sized to the max frames in flight and update/bind the
buffers for the current frame index.
The descriptor system manages the connection between our CPU-side resources and the GPU
shaders. For UI rendering, this primarily involves binding the font atlas texture to the fragment
shader, though more complex UI systems might include additional textures for icons, backgrounds,
or other visual elements.
The device context integration demonstrates the explicit nature of Vulkan’s resource management,
where every operation requires specific device and queue references. Unlike higher-level graphics
APIs that maintain global state, Vulkan requires explicit specification of which GPU device and
command queue should handle each operation.
The queue family index enables validation and optimization by ensuring that UI rendering
operations use compatible queue types. While UI rendering typically uses the same graphics queue
as 3D rendering, some applications might benefit from dedicated queues for different rendering
responsibilities.
200
ImGuiVulkanUtil Architecture: UI State and Rendering
Configuration
After that, we manage UI-specific state including styling, rendering parameters, and dynamic
update tracking.
The styling and configuration management reflects ImGui’s flexibility in visual presentation while
maintaining compatibility with Vulkan’s explicit rendering model. The push constants provide an
efficient mechanism for updating per-frame parameters like screen resolution changes or UI
scaling factors without requiring descriptor set updates.
The dynamic state tracking optimizes performance by avoiding unnecessary GPU resource updates
when the UI layout remains stable between frames. This optimization becomes particularly
important in applications with complex UIs where buffer updates could otherwise impact frame
rates.
201
public:
// Lifecycle management for proper resource initialization and cleanup
ImGuiVulkanUtil(vk::raii::Device& device, vk::raii::PhysicalDevice&
physicalDevice,
vk::raii::Queue& graphicsQueue, uint32_t graphicsQueueFamily);
~ImGuiVulkanUtil();
The public interface design balances ease of integration with performance considerations,
separating one-time setup operations from per-frame rendering tasks. The initialization methods
handle the expensive resource creation that should happen once during application startup, while
the frame-by-frame methods focus on efficient updates and rendering.
The input handling interface enables proper integration with existing input systems, allowing
ImGui to capture relevant events while passing through others to the main application. This
cooperative approach ensures that UI elements can respond to user interaction without interfering
with 3D scene controls or other input handling.
202
Constructor and Destructor
ImGuiVulkanUtil::~ImGuiVulkanUtil() {
// Wait for device to finish operations before destroying resources
// NOTE: waitIdle() is acceptable in destructors/cleanup code but should NEVER be
used
// in the main rendering loop as it causes severe performance issues. For frame
// synchronization, use fences and semaphores instead.
if (device) {
device->waitIdle();
}
Initialization
203
ImGui::CreateContext();
// Configure ImGui
ImGuiIO& io = ImGui::GetIO();
[Link] |= ImGuiConfigFlags_NavEnableKeyboard; // Enable keyboard controls
[Link] |= ImGuiConfigFlags_DockingEnable; // Enable docking
// Set up style
vulkanStyle = ImGui::GetStyle();
[Link][ImGuiCol_TitleBg] = ImVec4(1.0f, 0.0f, 0.0f, 0.6f);
[Link][ImGuiCol_TitleBgActive] = ImVec4(1.0f, 0.0f, 0.0f, 0.8f);
[Link][ImGuiCol_MenuBarBg] = ImVec4(1.0f, 0.0f, 0.0f, 0.4f);
[Link][ImGuiCol_Header] = ImVec4(1.0f, 0.0f, 0.0f, 0.4f);
[Link][ImGuiCol_CheckMark] = ImVec4(0.0f, 1.0f, 0.0f, 1.0f);
switch (index) {
case 0:
// Custom Vulkan style
style = vulkanStyle;
break;
case 1:
// Classic style
ImGui::StyleColorsClassic();
break;
case 2:
// Dark style
ImGui::StyleColorsDark();
break;
case 3:
// Light style
ImGui::StyleColorsLight();
break;
}
}
Resource Initialization
Now let’s implement the method to initialize all Vulkan resources needed for ImGui rendering. This
complex process involves several distinct steps that work together to create the GPU resources
204
required for text and UI rendering.
void ImGuiVulkanUtil::initResources() {
// Extract font atlas data from ImGui's internal font system
// ImGui generates a texture atlas containing all glyphs needed for text rendering
ImGuiIO& io = ImGui::GetIO();
unsigned char* fontData; // Raw pixel data from font atlas
int texWidth, texHeight; // Dimensions of the generated font
atlas
[Link]->GetTexDataAsRGBA32(&fontData, &texWidth, &texHeight);
The font data extraction represents the bridge between ImGui’s CPU-based text rendering system
and Vulkan’s GPU-based texture pipeline. ImGui automatically generates a font atlas that combines
all required character glyphs into a single texture, optimizing GPU memory usage and reducing
draw calls during text rendering. The RGBA32 format provides full color and alpha support for anti-
aliased text rendering.
205
// Create image view for shader access
// The image view defines how shaders interpret the raw image data
fontImageView = ImageView(*device, [Link](),
vk::Format::eR8G8B8A8Unorm,
vk::ImageAspectFlagBits::eColor);
The GPU image creation step establishes the foundation for efficient text rendering by allocating
device-local memory that provides optimal access speeds for the GPU. The dual usage flags
(eSampled | eTransferDst) enable both data upload operations and shader sampling, while the
RGBA8_UNORM format ensures consistent color representation across different GPU architectures.
The staging buffer approach represents the most efficient method for transferring large amounts of
data from CPU to GPU memory in Vulkan. Host-visible memory enables direct CPU access while
host-coherent ensures that CPU writes are immediately visible to the GPU without requiring explicit
cache flushes. This intermediate step is necessary because device-local memory (where the final
image resides) is typically not directly accessible by the CPU.
206
// Transition image to optimal layout for data reception
// Vulkan requires explicit layout transitions for optimal performance and
correctness
transitionImageLayout([Link](), vk::Format::eR8G8B8A8Unorm,
vk::ImageLayout::eUndefined,
vk::ImageLayout::eTransferDstOptimal);
The layout transition sequence ensures that the GPU memory subsystem can optimize its internal
data arrangements for each operation type. The eTransferDstOptimal layout provides the best
performance for receiving data uploads, while eShaderReadOnlyOptimal enables efficient texture
sampling during rendering. These transitions include automatic memory barriers that synchronize
access between different GPU pipeline stages.
207
for clamped areas
vk::DescriptorPoolCreateInfo poolInfo{};
[Link] = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet; //
Allow individual descriptor set freeing
[Link] = 2; //
Maximum number of descriptor sets
[Link] = 1; //
Number of pool size specifications
[Link] = &poolSize; // Pool
size configuration
descriptorPool = device->createDescriptorPool(poolInfo); //
Create descriptor pool
vk::DescriptorSetLayoutCreateInfo layoutInfo{};
[Link] = 1; //
Number of bindings in layout
[Link] = &binding; //
Binding configuration array
descriptorSetLayout = device->createDescriptorSetLayout(layoutInfo); //
Create layout object
208
Layout template array
[Link] = layouts; //
Layout configuration
descriptorSet = std::move(device->allocateDescriptorSets(allocInfo).front()); //
Allocate and store set
// Update descriptor set with actual font texture and sampler resources
// This final step connects the physical GPU resources to the shader binding
points
vk::DescriptorImageInfo imageInfo{};
[Link] = vk::ImageLayout::eShaderReadOnlyOptimal; //
Expected image layout
[Link] = [Link](); // Font
texture view
[Link] = *sampler; //
Texture sampler
vk::WriteDescriptorSet writeSet{};
[Link] = *descriptorSet; //
Target descriptor set
[Link] = 1; //
Number of resources to bind
[Link] = vk::DescriptorType::eCombinedImageSampler; //
Resource type
[Link] = &imageInfo; //
Image resource information
[Link] = 0; //
Binding point in shader
vk::PipelineLayoutCreateInfo pipelineLayoutInfo{};
[Link] = 1;
vk::DescriptorSetLayout setLayouts[] = {*descriptorSetLayout};
[Link] = setLayouts;
[Link] = 1;
[Link] = &pushConstantRange;
pipelineLayout = device->createPipelineLayout(pipelineLayoutInfo);
209
// Create the graphics pipeline with dynamic rendering
// ... (shader loading, pipeline state setup, etc.)
// For brevity, we're omitting the full pipeline creation code here
// In a real implementation, you would:
// 1. Load the vertex and fragment shaders
// 2. Set up all the pipeline state (vertex input, input assembly, rasterization,
etc.)
// 3. Include the renderingInfo in the pipeline creation to enable dynamic
rendering
}
Finally, let’s implement the methods for frame management and rendering:
bool ImGuiVulkanUtil::newFrame() {
// Start a new ImGui frame
ImGui::NewFrame();
return false;
}
void ImGuiVulkanUtil::updateBuffers() {
210
ImDrawData* drawData = ImGui::GetDrawData();
if (!drawData || drawData->CmdListsCount == 0) {
return;
}
[Link]();
[Link]();
}
Before issuing any UI draw commands, we open a dynamic rendering scope that targets the current
framebuffer. This replaces vkCmdBeginRenderPass/EndRenderPass and keeps the UI pass
211
lightweight.
vk::RenderingInfo renderingInfo{};
[Link] = vk::Rect2D{{0, 0}, {static_cast<uint32_t>(drawData-
>DisplaySize.x),
static_cast<uint32_t>(drawData-
>DisplaySize.y)}};
[Link] = 1;
[Link] = 1;
[Link] = &colorAttachment;
[Link](renderingInfo);
At this point, commands affect the UI overlay only. Next we bind state that doesn’t change per draw.
The pipeline has blending and raster states tailored for UI. The viewport maps ImGui’s coordinate
system to the framebuffer.
212
[Link] = glm::vec2(-1.0f);
[Link](*pipelineLayout, vk::ShaderStageFlagBits::eVertex,
0, sizeof(PushConstBlock), &pushConstBlock);
This keeps the shader simple and avoids per-vertex work for coordinate transforms.
int vertexOffset = 0;
int indexOffset = 0;
vertexOffset += cmdList->[Link];
}
213
Each ImDrawCmd provides a scissor rect that clips widgets efficiently without extra passes.
Input Handling
Let’s implement the input handling methods:
// This example uses GLFW key codes and actions, but you can adapt this
// to work with any windowing library's input system
if (action == KEY_PRESSED)
[Link][key] = true;
if (action == KEY_RELEASED)
[Link][key] = false;
bool ImGuiVulkanUtil::getWantKeyCapture() {
return ImGui::GetIO().WantCaptureKeyboard;
214
}
// During initialization
void initImGui() {
// Initialize ImGui directly
imGui = ImGuiVulkanUtil(
device,
physicalDevice,
graphicsQueue,
graphicsQueueFamily
);
[Link]([Link], [Link]);
[Link](); // No renderPass needed with dynamic rendering
}
// Update ImGui
if ([Link]()) {
[Link]();
}
// Render ImGui (in multi-frame renderers, pass the current frame index to bind
per-frame buffers)
[Link](commandBuffer);
215
// ... submit command buffer ...
}
// Input handling
// This example shows how to handle input with GLFW, but you can adapt this
// to work with any windowing library's input system
// If ImGui doesn't want to capture the keyboard, process for your application
if (![Link]()) {
// Process key for your application
}
}
// With other windowing libraries, you would implement similar callback functions
// using their equivalent APIs and event systems
// Cleanup
void cleanup() {
// ... existing cleanup code ...
ImGui::EndFrame();
ImGui::Render();
216
// Check if buffers need updating
// ...
}
With this implementation, you have a Vulkan implementation for ImGui that allows you to
customize the rendering process to fit your specific needs.
In the next section, we’ll explore how to handle input for both the GUI and the 3D scene.
In this section, we’ll explore how to handle input for both the GUI and the 3D scene, ensuring a
smooth user experience regardless of the windowing library you choose to use.
// InputSystem.h
#pragma once
#include <functional>
#include <unordered_map>
#include <vector>
#include <glm/[Link]>
217
enum class InputAction {
MOVE_FORWARD,
MOVE_BACKWARD,
MOVE_LEFT,
MOVE_RIGHT,
MOVE_UP,
MOVE_DOWN,
LOOK_UP,
LOOK_DOWN,
LOOK_LEFT,
LOOK_RIGHT,
ZOOM_IN,
ZOOM_OUT,
TOGGLE_UI_MODE,
// Add more actions as needed
};
class InputSystem {
public:
static void Initialize();
static void Shutdown();
218
static void RegisterActionCallback(InputAction action, std::function<void(float)>
callback);
private:
static InputState inputState;
static std::unordered_map<InputAction, std::function<void(float)>>
actionCallbacks;
};
Input Prioritization
The general approach for input handling in applications with both 3D navigation and GUI is:
1. First, check if the GUI is capturing input (e.g., mouse is over a UI element)
2. If the GUI is not capturing input, then process the input for 3D navigation
// Process keyboard input for camera movement if ImGui is not capturing keyboard
if (!imguiCapturingKeyboard) {
// Forward these to the camera system
// This could be done through the action callback system
if (InputSystem::IsActionActive(InputAction::MOVE_FORWARD))
[Link](CameraMovement::FORWARD, deltaTime);
if (InputSystem::IsActionActive(InputAction::MOVE_BACKWARD))
[Link](CameraMovement::BACKWARD, deltaTime);
if (InputSystem::IsActionActive(InputAction::MOVE_LEFT))
[Link](CameraMovement::LEFT, deltaTime);
if (InputSystem::IsActionActive(InputAction::MOVE_RIGHT))
219
[Link](CameraMovement::RIGHT, deltaTime);
if (InputSystem::IsActionActive(InputAction::MOVE_UP))
[Link](CameraMovement::UP, deltaTime);
if (InputSystem::IsActionActive(InputAction::MOVE_DOWN))
[Link](CameraMovement::DOWN, deltaTime);
}
// Process mouse/touch input for camera rotation if ImGui is not capturing mouse
if (!imguiCapturingMouse) {
if ([Link].x != 0.0f || [Link].y != 0.0f) {
[Link]([Link].x,
-[Link].y);
}
if ([Link] != 0.0f) {
[Link]([Link]);
}
}
}
// InputSystem_GLFW.cpp
#include "InputSystem.h"
#include <GLFW/glfw3.h>
#include <imgui.h>
220
// Calculate delta from last position
glm::vec2 newPos(static_cast<float>(xpos), static_cast<float>(ypos));
[Link] = newPos - [Link];
[Link] = newPos;
}
static void glfwKeyCallback(GLFWwindow* window, int key, int scancode, int action, int
mods) {
// Map GLFW keys to our input actions
if (action == GLFW_PRESS || action == GLFW_RELEASE) {
bool pressed = (action == GLFW_PRESS);
if (mouseCaptureMode) {
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_DISABLED);
} else {
glfwSetInputMode(window, GLFW_CURSOR, GLFW_CURSOR_NORMAL);
}
}
221
// Update key states for continuous actions (like movement)
if (glfwGetKey(gWindow, GLFW_KEY_W) == GLFW_PRESS) {
if (auto it = [Link](InputAction::MOVE_FORWARD); it !=
[Link]()) {
it->second(deltaTime);
}
}
bool InputSystem::IsImGuiCapturingKeyboard() {
return ImGui::GetIO().WantCaptureKeyboard;
}
bool InputSystem::IsImGuiCapturingMouse() {
return ImGui::GetIO().WantCaptureMouse;
}
Input Modes
For applications that need different input modes (e.g., camera control vs. UI interaction), we can
implement a mode system:
222
glfwSetInputMode(gWindow, GLFW_CURSOR, GLFW_CURSOR_NORMAL);
}
// With other windowing libraries, you would use their equivalent APIs
}
void drawGUI() {
// Start a new ImGui frame
ImGui::NewFrame();
223
ImGui::End();
// Render ImGui
ImGui::Render();
}
void mainLoop() {
// Main application loop
while (isRunning) {
// Calculate delta time
float deltaTime = calculateDeltaTime();
// Draw GUI
drawGUI();
// Draw frame
drawFrame();
}
}
224
while (!glfwWindowShouldClose(window)) {
float deltaTime = calculateDeltaTime();
Gesture Recognition
Gesture recognition can enhance the user experience regardless of which windowing library you
use:
// GestureRecognizer.h
#pragma once
#include <glm/[Link]>
#include <vector>
#include <functional>
struct GestureEvent {
GestureType type;
glm::vec2 position;
glm::vec2 delta;
float scale; // For pinch
float rotation; // For rotate
int pointerCount;
225
};
class GestureRecognizer {
public:
static void Initialize();
static void Update(const InputState& inputState, float deltaTime);
private:
static void detectTap(const InputState& inputState);
static void detectSwipe(const InputState& inputState);
static void detectPinch(const InputState& inputState);
static void detectRotate(const InputState& inputState);
static void detectPan(const InputState& inputState);
For more complex applications with different input requirements in different states:
// InputContext.h
#pragma once
#include <string>
#include <unordered_map>
#include <functional>
#include <stack>
class InputContext {
public:
// Create a new input context
static void CreateContext(const std::string& name);
226
action, std::function<void(float)> handler);
private:
static std::unordered_map<std::string, std::unordered_map<InputAction,
std::function<void(float)>>> contextHandlers;
static std::stack<std::string> contextStack;
};
With these advanced input handling techniques, your application can provide a consistent and
intuitive user experience. In the next section, we’ll explore how to create various UI elements to
control your application.
1. Separation of Concerns: Keep your GUI code separate from your rendering code to maintain
clean architecture.
2. Performance Impact: GUIs can impact performance, especially with complex layouts or
frequent updates.
3. Input Management: Properly handle input to ensure it’s routed to either the GUI or the 3D
scene.
4. Rendering Order: The GUI is typically rendered after the 3D scene, as an overlay.
5. State Management: Use the GUI to modify application state in a controlled manner.
227
void drawGUI() {
// Start a new ImGui frame
ImGui::NewFrame();
// Create a window
ImGui::Begin("Settings");
ImGui::End();
// Render ImGui
ImGui::Render();
}
For a comprehensive guide to all available ImGui widgets and their options, please refer to the
official ImGui documentation and demo: [Link]
imgui_demo.cpp
Memory Management
ImGui generates vertex and index buffers that need to be uploaded to the GPU. Ensure these
resources are properly managed:
1. Buffer Sizing: Allocate buffers with sufficient size or implement resizing logic
228
// Record commands for scene rendering
// ...
Descriptor Resources
ImGui requires descriptors for its font texture. Ensure your descriptor pool has sufficient capacity:
vk::DescriptorPoolCreateInfo poolInfo{};
[Link] = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet;
[Link] = 50;
[Link] = static_cast<uint32_t>(std::size(poolSizes));
[Link] = poolSizes;
descriptorPool = [Link](poolInfo);
Performance Considerations
When integrating ImGui with Vulkan, consider these performance aspects:
1. Command Buffer Recording: Record ImGui commands efficiently, ideally once per frame
4. Pipeline State: Use a dedicated pipeline for ImGui to minimize state changes
5. Render Pass Integration: Consider whether to use a separate render pass or subpass for the
GUI
If your renderer uses multiple frames in flight (e.g., double/triple buffering) without a device wait-
idle between frames, ImGui’s dynamic vertex and index buffers must not be shared across frames.
Otherwise, the CPU can overwrite data that the GPU from a previous frame is still reading.
• Allocate one vertex buffer and one index buffer per frame-in-flight.
229
• Update/bind the buffers for the current frame index only.
Example sketch:
class ImGuiSystem {
// ...
std::vector<vk::raii::Buffer> vertexBuffers;
std::vector<vk::raii::DeviceMemory> vertexMemories;
std::vector<vk::raii::Buffer> indexBuffers;
std::vector<vk::raii::DeviceMemory> indexMemories;
std::vector<uint32_t> vertexCounts;
std::vector<uint32_t> indexCounts;
When integrating with your main renderer, pass the current frame index to the ImGui render call:
230
// inside your frame loop after scene rendering
imguiSystem->Render(commandBuffers[currentFrame], currentFrame);
2. State Management: Use a centralized state store that the GUI can modify
4. Lazy Updates: Only update Vulkan resources when GUI settings actually change
// Components
void drawRenderSettingsPanel();
void drawPerformancePanel();
void drawSceneControlsPanel();
public:
void draw() {
// Start a new ImGui frame
ImGui::NewFrame();
231
}
// Render ImGui
ImGui::Render();
}
};
3. Update the descriptor set with your texture’s image view and sampler
This layout declares a single combined image sampler the shader can sample from when ImGui
draws the quad.
vk::DescriptorSetLayoutCreateInfo layoutInfo{};
[Link] = 1;
[Link] = &binding;
vk::raii::DescriptorSetLayout textureSetLayout =
[Link](layoutInfo);
232
// Allocate a descriptor set for each texture
vk::DescriptorSetAllocateInfo allocInfo{};
[Link] = *descriptorPool;
[Link] = 1;
vk::DescriptorSetLayout layouts[] = {*textureSetLayout};
[Link] = layouts;
vk::raii::DescriptorSet textureDescriptorSet =
std::move([Link](allocInfo).front());
Point the descriptor at your image view and sampler in shader‑read layout.
vk::WriteDescriptorSet writeSet{};
[Link] = *textureDescriptorSet;
[Link] = 1;
[Link] = vk::DescriptorType::eCombinedImageSampler;
[Link] = &imageInfo;
[Link] = 0;
Use it in ImGui
Once you have set up the descriptor set, you can use it with ImGui’s image functions:
// Or as an image button
if (ImGui::ImageButton(textureId, ImVec2(width, height))) {
// Handle button click
}
233
ImGui::Image(textureId, ImVec2(width, height),
ImVec2(0, 0), ImVec2(1, 1), // UV coordinates (0,0) to (1,1) for the
full texture
ImVec4(1, 1, 1, 1), // Tint color (white = no tint)
ImVec4(1, 1, 1, 0.5)); // Border color
ImGui::End();
Here’s a more complete example of a texture manager class that handles multiple textures for
ImGui:
class ImGuiTextureManager {
private:
vk::raii::Device* device = nullptr;
vk::raii::DescriptorPool* descriptorPool = nullptr;
vk::raii::DescriptorSetLayout descriptorSetLayout{nullptr};
struct TextureInfo {
vk::raii::DescriptorSet descriptorSet{nullptr};
uint32_t width;
uint32_t height;
};
public:
ImGuiTextureManager(vk::raii::Device& device, vk::raii::DescriptorPool&
descriptorPool)
: device(&device), descriptorPool(&descriptorPool) {
vk::DescriptorSetLayoutCreateInfo layoutInfo{};
[Link] = 1;
[Link] = &binding;
descriptorSetLayout = [Link](layoutInfo);
}
234
// Allocate descriptor set
vk::DescriptorSetAllocateInfo allocInfo{};
[Link] = **descriptorPool;
[Link] = 1;
vk::DescriptorSetLayout layouts[] = {*descriptorSetLayout};
[Link] = layouts;
vk::WriteDescriptorSet writeSet{};
[Link] = *descriptorSet;
[Link] = 1;
[Link] = vk::DescriptorType::eCombinedImageSampler;
[Link] = &imageInfo;
[Link] = 0;
return (ImTextureID)(VkDescriptorSet)*textures[name].descriptorSet;
}
return ImVec2(static_cast<float>(textures[name].width),
static_cast<float>(textures[name].height));
}
235
};
Usage Example
Here’s how you might use the texture manager in your application:
// During initialization
ImGuiTextureManager textureManager(device, descriptorPool);
// Display textures
ImGui::Text("Albedo Texture:");
ImGui::Image([Link]("albedo"),
ImVec2(200, 200));
ImGui::Text("Normal Map:");
ImGui::Image([Link]("normalMap"),
ImVec2(200, 200));
// Material properties
static float roughness = 0.5f;
if (ImGui::SliderFloat("Roughness", &roughness, 0.0f, 1.0f)) {
updateMaterialProperty("roughness", roughness);
}
236
ImGui::End();
}
Performance Considerations
When working with textures in ImGui, keep these performance considerations in mind:
1. Descriptor Management: Create descriptor sets for textures only when needed and reuse them
2. Texture Size: Consider using smaller preview versions of textures for the UI
3. Mipmap Selection: For large textures, ensure proper mipmap selection to avoid aliasing
4. Texture Updates: If a texture changes frequently, use a staging buffer for updates
5. Texture Atlas: For many small textures (like icons), consider using a texture atlas
By properly managing textures in your ImGui integration, you can create rich interfaces that
display rendered content, material previews, and other visual elements directly in your GUI.
237
glm::vec3 rayOrigin = [Link]();
For object picking to work, you need to implement ray-object intersection tests. Here’s a simple
example for sphere intersection:
bool rayIntersectsSphere(
const glm::vec3& rayOrigin,
const glm::vec3& rayDirection,
const glm::vec3& sphereCenter,
float sphereRadius,
float& outDistance
) {
glm::vec3 oc = rayOrigin - sphereCenter;
float a = glm::dot(rayDirection, rayDirection);
float b = 2.0f * glm::dot(oc, rayDirection);
float c = glm::dot(oc, oc) - sphereRadius * sphereRadius;
float discriminant = b * b - 4 * a * c;
if (discriminant < 0) {
return false; // No intersection
}
238
t = (-b + sqrt(discriminant)) / (2.0f * a);
if (t < 0) {
return false; // Both intersection points are behind the ray
}
}
outDistance = t;
return true;
}
You can also display information about the selected object in the GUI:
void drawObjectPropertiesPanel() {
if (selectedObjectId < 0) {
return; // No object selected
}
ImGui::Begin("Object Properties");
239
// Edit object properties
glm::vec3 position = [Link];
if (ImGui::DragFloat3("Position", &position[0], 0.1f)) {
[Link] = position;
updateObjectTransform(selectedObjectId);
}
ImGui::End();
}
Object picking creates a powerful interaction model where users can select and manipulate 3D
objects directly, while using the GUI to fine-tune properties. This combination of direct
manipulation and precise control provides an intuitive user experience.
◦ Application-wide controls
◦ Camera navigation
3. Hybrid Approaches:
240
◦ Property panels that update based on selection
By thoughtfully integrating ImGui with your Vulkan application and implementing object picking,
you can create a powerful and intuitive user interface that combines the strengths of both 2D GUI
controls and direct 3D interaction.
In the next section, we’ll explore more details about integrating the GUI rendering with the Vulkan
rendering pipeline.
2. Generate Draw Data: ImGui generates vertex and index buffers for the UI
3. Record Commands: Record Vulkan commands to render the ImGui draw data
This flow needs to be integrated with your existing Vulkan rendering pipeline, which typically
involves:
241
// When initializing ImGui, we set up our custom Vulkan renderer with dynamic
rendering
ImGuiVulkanRenderer renderer;
// ... configure the renderer ...
[Link](*device, *physicalDevice);
Dynamic rendering simplifies the integration by removing the dependency on render passes and
framebuffers, making the code more flexible and easier to maintain.
1. Single Command Buffer: Record both scene and ImGui rendering commands in the same
command buffer
2. Multiple Command Buffers: Use separate command buffers for scene and ImGui rendering
This is the simplest approach and works well for most applications. With dynamic rendering, the
code becomes even cleaner:
void drawFrame() {
// ... existing frame preparation code ...
Command buffer recording represents the heart of Vulkan’s explicit GPU control model. Unlike
242
older APIs where rendering commands are immediately submitted to the GPU, Vulkan allows us to
build up a complete sequence of operations before submission. This approach enables powerful
optimizations like command reordering, parallel command buffer construction, and efficient GPU
scheduling.
The 'begin' operation transitions the command buffer from an initial state into a recording state,
where subsequent API calls will be captured as GPU instructions rather than executed immediately.
This explicit state management gives us precise control over when and how GPU work is submitted,
enabling the fine-grained performance control that makes Vulkan so powerful for demanding
applications.
The attachment configuration system provides explicit control over how the GPU handles our
render targets throughout the rendering process. By specifying load and store operations, we can
optimize memory bandwidth by only preserving data that needs to carry forward to subsequent
passes. The clear operations ensure we start with a known state, preventing visual artifacts from
243
previous frame data.
Image layout transitions happen automatically based on our specifications, with the GPU driver
handling the necessary memory barriers and cache flushes to ensure data coherency. The optimal
layouts we specify here tell the driver to arrange the image data in whatever format provides the
best performance for the intended usage, rather than forcing a specific memory organization.
Dynamic rendering represents a significant evolution from traditional Vulkan render passes,
providing greater flexibility while maintaining the performance benefits of explicit GPU control.
Instead of pre-defining render pass objects at initialization time, we can specify render targets and
their properties at command recording time, enabling more dynamic and flexible rendering
architectures.
The render area specification allows for partial-screen rendering, which can provide significant
performance benefits when only portions of the screen need updating. For full-screen rendering
like our case, we specify the entire swapchain extent to ensure complete coverage.
3D Scene Rendering
The main scene rendering phase handles all 3D geometry, lighting, and material rendering within
the established rendering context.
244
// ... your existing scene rendering code ...
The scene rendering phase operates within the rendering context we established, with the GPU
automatically handling depth testing, color blending, and other rasterization operations according
to our pipeline configurations. All draw commands issued between beginRendering and
endRendering will target our configured attachments with the specified clear and store behaviors.
The explicit endRendering call ensures that all scene rendering operations are properly completed
and that render targets are transitioned to appropriate states for subsequent operations. This
explicit control allows the GPU driver to perform optimal scheduling and memory management for
the rendering workload.
UI Overlay Integration
The final rendering phase integrates ImGui UI elements as an overlay on top of the 3D scene,
requiring careful coordination between the two rendering systems.
This approach gives you more flexibility and can be useful for more complex rendering pipelines.
With dynamic rendering, it becomes even more straightforward:
void drawFrame() {
// ... existing frame preparation code ...
245
// Initialize scene-specific command buffer recording
// This dedicated buffer will contain only 3D geometry and lighting operations
vk::CommandBufferBeginInfo beginInfo{};
[Link](beginInfo);
Separating scene rendering into its own command buffer provides several architectural
advantages. First, it enables parallel command buffer recording where different threads can
simultaneously build scene and UI command sequences, improving CPU utilization on multi-core
systems. Second, it allows for independent optimization of each rendering phase, where scene
rendering can use different GPU queues or submission timing than UI rendering.
This separation also facilitates advanced rendering techniques like multi-frame latency
optimization, where scene rendering can be decoupled from UI updates to maintain consistent
frame timing even when one system experiences performance variations.
246
The attachment configuration for scene rendering emphasizes the separation of concerns between
3D and UI rendering. The store operation for the color attachment ensures that scene rendering
results are preserved for the subsequent UI overlay, while the depth attachment uses "don’t care"
storage since UI elements typically render without depth testing.
This explicit configuration makes the rendering dependencies clear and helps optimize memory
bandwidth by only preserving the data that subsequent passes actually need.
The scene rendering execution benefits from having its own isolated command buffer context,
where all GPU state changes and draw calls are contained within a clearly defined scope. This
isolation makes debugging easier, as scene-specific rendering issues can be analyzed independently
of UI rendering complexity.
Command buffer finalization with end() transitions the buffer to an executable state, ready for GPU
submission, while maintaining clear boundaries between different rendering responsibilities.
247
// Initialize UI-specific command buffer recording
// This dedicated buffer handles only UI overlay operations
[Link](beginInfo);
The UI command buffer setup demonstrates the power of the multi-buffer approach through its
different attachment configuration. By changing the load operation to eLoad, we preserve the scene
rendering results as the foundation for UI overlay rendering. This approach is more explicit and
controllable than relying on automatic render pass dependencies.
Removing the depth attachment for UI rendering eliminates unnecessary depth testing overhead,
since UI elements typically render in screen space without complex occlusion relationships. This
optimization can provide measurable performance improvements, especially on mobile GPUs
where bandwidth is at a premium.
248
Multi-Buffer: UI Rendering and Submission
Coordination
The final phase handles UI rendering execution and coordinates the submission of both command
buffers in the correct order.
This will render any detached ImGui windows. Note that this feature requires additional platform-
specific code and may not be necessary for all applications.
249
Handling Window Resize
When the window is resized, you need to recreate the swap chain and update ImGui:
void recreateSwapChain() {
// ... existing swap chain recreation code ...
Performance Considerations
Here are some tips to optimize ImGui rendering performance in Vulkan:
1. Minimize State Changes: Try to render all ImGui elements in a single pass to minimize state
changes.
2. Use Appropriate Descriptor Pool Sizes: Allocate enough descriptors for ImGui to avoid
running out of descriptors.
3. Consider Secondary Command Buffers: For complex UIs, consider using secondary command
buffers to record ImGui commands in parallel.
4. Optimize UI Updates: Only update UI elements that change, and consider using ImGui’s Begin()
function with the ImGuiWindowFlags_NoDecoration flag for static UI elements.
5. Use ImGui’s Memory Allocators: ImGui allows you to provide custom memory allocators,
which can be useful for controlling memory usage.
class VulkanApplication {
private:
// ... existing Vulkan members ...
// ImGui-specific members
vk::raii::DescriptorPool imguiPool = nullptr;
bool showDemoWindow = true;
bool showMetricsWindow = false;
public:
void initVulkan() {
// ... existing Vulkan initialization ...
250
// Initialize ImGui
createImGuiDescriptorPool();
initImGui();
}
void createImGuiDescriptorPool() {
// ImGui typically needs a handful of descriptors (font texture + user UI
textures).
// Adjust these values to your app's needs (e.g., expected number of UI
textures, buffers).
// As a starting point:
vk::DescriptorPoolSize poolSizes[] =
{
{ vk::DescriptorType::eSampler, 8 },
{ vk::DescriptorType::eCombinedImageSampler, 128 }, // font + user-
provided textures
{ vk::DescriptorType::eSampledImage, 128 },
{ vk::DescriptorType::eStorageImage, 8 },
{ vk::DescriptorType::eUniformTexelBuffer, 8 },
{ vk::DescriptorType::eStorageTexelBuffer, 8 },
{ vk::DescriptorType::eUniformBuffer, 32 },
{ vk::DescriptorType::eStorageBuffer, 32 },
{ vk::DescriptorType::eUniformBufferDynamic, 16 },
{ vk::DescriptorType::eStorageBufferDynamic, 16 },
{ vk::DescriptorType::eInputAttachment, 8 }
};
vk::DescriptorPoolCreateInfo poolInfo{
.flags = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet,
.maxSets = maxSets,
.poolSizeCount = static_cast<uint32_t>(std::size(poolSizes)),
.pPoolSizes = poolSizes
};
void initImGui() {
// Initialize ImGui context
IMGUI_CHECKVERSION();
ImGui::CreateContext();
ImGuiIO& io = ImGui::GetIO();
[Link] |= ImGuiConfigFlags_NavEnableKeyboard;
[Link] |= ImGuiConfigFlags_DockingEnable;
251
ImGui::StyleColorsDark();
void drawFrame() {
// ... existing frame preparation code ...
// Create ImGui UI
createImGuiUI();
// Render ImGui
ImGui::Render();
252
[Link] = vk::AttachmentLoadOp::eClear;
[Link] = vk::AttachmentStoreOp::eStore;
[Link] = std::array<float, 4>{0.0f, 0.0f, 0.0f,
1.0f};
vk::RenderingAttachmentInfo depthAttachment{};
[Link] = *depthImageView;
[Link] = vk::ImageLayout::eDepthStencilAttachmentOptimal;
[Link] = vk::AttachmentLoadOp::eClear;
[Link] = vk::AttachmentStoreOp::eDontCare;
[Link] = vk::ClearDepthStencilValue{1.0f, 0};
vk::RenderingInfo renderingInfo{};
[Link] = vk::Rect2D{{0, 0}, swapChainExtent};
[Link] = 1;
[Link] = 1;
[Link] = &colorAttachment;
[Link] = &depthAttachment;
[Link](renderingInfo);
// Render 3D scene
// ... your existing scene rendering code ...
[Link]();
void createImGuiUI() {
// Menu bar
if (ImGui::BeginMainMenuBar()) {
if (ImGui::BeginMenu("File")) {
if (ImGui::MenuItem("Exit", "Alt+F4")) {
// Generic way to request application exit
requestApplicationExit();
}
ImGui::EndMenu();
}
if (ImGui::BeginMenu("View")) {
ImGui::MenuItem("Demo Window", nullptr, &showDemoWindow);
ImGui::MenuItem("Metrics", nullptr, &showMetricsWindow);
ImGui::EndMenu();
}
ImGui::EndMainMenuBar();
253
}
// Demo window
if (showDemoWindow) {
ImGui::ShowDemoWindow(&showDemoWindow);
}
// Metrics window
if (showMetricsWindow) {
ImGui::ShowMetricsWindow(&showMetricsWindow);
}
// Custom windows
ImGui::Begin("Settings");
ImGui::End();
}
void cleanup() {
// ... existing cleanup code ...
// Cleanup ImGui
[Link]();
ImGuiPlatform::Shutdown(); // Our custom platform backend
ImGui::DestroyContext();
}
};
Advanced Topics
Custom Shaders for ImGui
ImGui uses its own shaders for rendering, but you can customize them if needed:
254
createShaderModule("custom_imgui_vert.spv");
vk::raii::ShaderModule customFragShaderModule =
createShaderModule("custom_imgui_frag.spv");
// Initialize our custom renderer with custom shaders and dynamic rendering
ImGuiVulkanRenderer renderer;
[Link](
*instance,
*physicalDevice,
*device,
queueFamily,
*queue,
*descriptorPool,
minImageCount,
imageCount,
vk::SampleCountFlagBits::e1
);
You can render ImGui to a texture instead of directly to the screen, which can be useful for creating
in-game UI elements:
255
);
vk::RenderingInfo renderingInfo{};
[Link] = vk::Rect2D{{0, 0}, {width, height}};
[Link] = 1;
[Link] = 1;
[Link] = &colorAttachment;
[Link](renderingInfo);
[Link](ImGui::GetDrawData(), commandBuffer);
[Link]();
For high DPI displays, you need to handle scaling correctly across different platforms:
// Platform-specific implementations
// Here's an example using GLFW, but you can implement similar functions
// for any windowing library you choose to use
256
// Update ImGui with the correct display size and scale
updateDisplayScale(width, height, xscale, yscale);
}
// With other windowing libraries, you would use their equivalent APIs
// to get the framebuffer size and DPI scaling factor
// ImGuiUtil.h
#pragma once
import vulkan_hpp;
#include <imgui.h>
#include <functional>
#include <memory>
class ImGuiUtil {
public:
// Initialize ImGui with Vulkan using dynamic rendering
static void Init(
vk::raii::Instance& instance,
vk::raii::PhysicalDevice& physicalDevice,
vk::raii::Device& device,
uint32_t queueFamily,
vk::raii::Queue& queue,
uint32_t minImageCount,
uint32_t imageCount,
vk::Format swapChainImageFormat,
vk::SampleCountFlagBits msaaSamples = vk::SampleCountFlagBits::e1
);
// Shutdown ImGui
static void Shutdown();
257
// Process platform-specific input event
static bool ProcessInputEvent(void* event);
private:
// Create descriptor pool for ImGui
static void createDescriptorPool();
// Upload fonts
static void uploadFonts();
// Input callback
inline static std::function<void(ImGuiIO&)> inputCallback = nullptr;
// Initialization state
inline static bool initialized = false;
};
// [Link]
#include "ImGuiUtil.h"
void ImGuiUtil::Init(
vk::raii::Instance& instance,
vk::raii::PhysicalDevice& physicalDevice,
vk::raii::Device& device,
uint32_t queueFamily,
vk::raii::Queue& queue,
uint32_t minImageCount,
uint32_t imageCount,
vk::Format swapChainImageFormat,
vk::SampleCountFlagBits msaaSamples
) {
ImGuiUtil::instance = &instance;
258
ImGuiUtil::physicalDevice = &physicalDevice;
ImGuiUtil::device = &device;
ImGuiUtil::queueFamily = queueFamily;
ImGuiUtil::queue = &queue;
// Upload fonts
uploadFonts();
initialized = true;
259
}
void ImGuiUtil::Shutdown() {
if (!initialized) return;
// Cleanup ImGui
[Link]();
ImGui::DestroyContext();
// Reset pointers
instance = nullptr;
physicalDevice = nullptr;
device = nullptr;
queue = nullptr;
initialized = false;
}
void ImGuiUtil::NewFrame() {
if (!initialized) return;
ImGui::NewFrame();
}
ImGui::Render();
[Link](ImGui::GetDrawData(), commandBuffer);
}
ImGuiIO& io = ImGui::GetIO();
[Link] = ImVec2(static_cast<float>(width), static_cast<float>(height));
260
[Link] = ImVec2(scaleX, scaleY);
}
void ImGuiUtil::createDescriptorPool() {
// Tune these to match your expected number of UI textures and buffers.
vk::DescriptorPoolSize poolSizes[] =
{
{ vk::DescriptorType::eSampler, 8 },
{ vk::DescriptorType::eCombinedImageSampler, 128 },
{ vk::DescriptorType::eSampledImage, 128 },
{ vk::DescriptorType::eStorageImage, 8 },
{ vk::DescriptorType::eUniformTexelBuffer, 8 },
{ vk::DescriptorType::eStorageTexelBuffer, 8 },
{ vk::DescriptorType::eUniformBuffer, 32 },
{ vk::DescriptorType::eStorageBuffer, 32 },
{ vk::DescriptorType::eUniformBufferDynamic, 16 },
{ vk::DescriptorType::eStorageBufferDynamic, 16 },
{ vk::DescriptorType::eInputAttachment, 8 }
};
uint32_t maxSets = 0;
for (const auto& ps : poolSizes) maxSets += [Link];
vk::DescriptorPoolCreateInfo poolInfo{
.flags = vk::DescriptorPoolCreateFlagBits::eFreeDescriptorSet,
.maxSets = maxSets,
.poolSizeCount = static_cast<uint32_t>(std::size(poolSizes)),
.pPoolSizes = poolSizes
};
void ImGuiUtil::uploadFonts() {
vk::raii::CommandBuffer commandBuffer = beginSingleTimeCommands();
[Link](commandBuffer);
endSingleTimeCommands(commandBuffer);
}
vk::raii::CommandBuffer ImGuiUtil::beginSingleTimeCommands() {
vk::CommandBufferAllocateInfo allocInfo{
261
.commandPool = *commandPool,
.level = vk::CommandBufferLevel::ePrimary,
.commandBufferCount = 1
};
vk::CommandBufferBeginInfo beginInfo{
.flags = vk::CommandBufferUsageFlagBits::eOneTimeSubmit
};
[Link](beginInfo);
return commandBuffer;
}
vk::SubmitInfo submitInfo{
.commandBufferCount = 1,
.pCommandBuffers = &*commandBuffer
};
queue->submit(submitInfo);
queue->waitIdle();
}
Conclusion
In this section, we’ve explored how to integrate ImGui with Vulkan, including command buffer
integration, render pass configuration, and performance considerations. By creating a flexible
implementation, we’ve ensured that our GUI system works well with any windowing system you
choose.
2. Implementing a flexible input system that works with various windowing libraries
With this knowledge, you can create a robust GUI system for your Vulkan application that provides
a smooth user experience regardless of which windowing system you use.
In the next section, we’ll wrap up with a conclusion and discuss potential improvements to our GUI
system.
262
Previous: UI Elements | Next: Conclusion :pp: ++
GUI: Conclusion
Conclusion
In this chapter, we’ve built a comprehensive GUI system for our Vulkan application using Dear
ImGui. Let’s summarize what we’ve learned and discuss potential improvements.
• Versatile Input Handling: We implemented a robust input handling system that correctly
routes input events to either the GUI or the 3D scene, ensuring a smooth user experience on any
device.
• UI Elements: We learned how to create various UI elements, from basic components like
buttons and sliders to more complex elements like tables and plots, and how to organize them
into a cohesive interface that works well on both desktop and mobile platforms.
• Vulkan Integration: We dove deep into the technical details of integrating ImGui with the
Vulkan rendering pipeline, including command buffer integration, render pass configuration,
and performance considerations.
With these components in place, we now have a solid foundation for creating interactive
applications with Vulkan that can run on multiple platforms. Our GUI system allows users to
control settings, display information, and interact with the 3D scene through an intuitive interface,
whether they’re using a desktop computer, a mobile phone, or a tablet.
Potential Improvements
While our GUI system is functional, there are several ways it could be enhanced:
• Touch-Friendly UI: Enhance the UI elements to be more touch-friendly for mobile platforms,
with larger hit areas and gesture support.
• Adaptive Layouts: Create layouts that automatically adapt to different screen sizes and
orientations, from desktop monitors to mobile phones.
• Custom Styling: Create a custom theme that matches your application’s visual style, rather than
using the default ImGui style.
• Localization: Add support for multiple languages by implementing a localization system for UI
text.
263
• Accessibility: Improve accessibility by adding features like keyboard navigation, screen reader
support, and high-contrast modes.
• Persistent Settings: Implement a system to save and load UI settings between application
sessions.
• Advanced Layout: Use ImGui’s docking features to create more complex UI layouts, such as
dockable panels.
• Custom Widgets: Develop custom widgets for specific needs in your application, such as a color
wheel, a curve editor, or a node graph editor.
• Performance Optimization: Profile and optimize the GUI rendering to minimize its impact on
overall application performance, especially on mobile devices with limited resources.
• Battery Efficiency: For mobile platforms, optimize the GUI rendering to minimize battery
usage.
• Scene Graph: How can the GUI be used to visualize and edit the scene graph hierarchy across
different platforms?
• Material System: Can you create a material editor using the GUI to adjust material properties
in real-time, with interfaces that work well on both desktop and mobile?
• Animation System: How might the GUI be used to control and visualize animations, with
controls that are appropriate for each platform?
• Physics System: Could the GUI provide tools for setting up and debugging physics simulations,
with different interaction models for desktop and mobile?
• Device-Specific Features: How can you leverage specific features (like haptic feedback on
mobile) while maintaining a consistent core experience?
By addressing these questions, you can create a more cohesive and powerful engine that leverages
the GUI for both development and runtime functionality across multiple platforms.
Cross-Platform Considerations
When developing a GUI system that works across platforms, keep these considerations in mind:
• Input Methods: Different platforms have different primary input methods (mouse/keyboard vs.
touch).
• Screen Sizes: Interfaces need to work on screens ranging from small phones to large monitors.
• Performance Constraints: Mobile devices typically have less processing power and memory
than desktops.
• Battery Life: On mobile devices, efficient rendering is crucial for battery life.
264
• Testing: Cross-platform applications require testing on all target platforms.
• Nuklear: A minimalist immediate-mode GUI library with a small footprint. It’s designed to be
embedded directly into applications and supports Vulkan among other rendering backends.
Nuklear is used in smaller indie games and tools due to its simplicity and low overhead.
• Qt: A comprehensive UI framework that added Vulkan support in Qt 5.10. Qt provides a more
traditional retained-mode GUI approach with a rich set of widgets and tools. It’s used in
applications like the Autodesk Maya viewport and various CAD software.
• CEGUI: The Crazy Eddie’s GUI system is a free library providing windowing and widgets for
games and simulation applications. It has Vulkan renderer support and is used in some indie
game engines.
• Ultralight: A lightweight, high-performance HTML renderer designed for game and application
UIs. It can be integrated with Vulkan and is used by developers who want to leverage web
technologies for their interfaces.
• Noesis GUI: A commercial UI middleware that supports XAML and can render through Vulkan.
It’s used in games like Dauntless and provides a designer-friendly workflow.
When choosing a GUI library for your Vulkan application, consider factors like:
• Performance requirements
• Designer-friendliness
• Learning curve
• Platform support
• Integration complexity
Dear ImGui, which we’ve used in this chapter, strikes a good balance for many developers due to its
simplicity, performance, and ease of integration with Vulkan.
Final Thoughts
A well-designed GUI is essential for creating user-friendly applications that can reach a wide
audience. It serves as the primary way users interact with your application and can significantly
impact the user experience. By understanding how to integrate Dear ImGui with Vulkan and
implementing a robust input handling system that works with basic inputs for mouse and
keyboard, you’ve taken a major step toward creating professional-quality applications.
265
Remember that the code provided in this chapter is a starting point. Feel free to modify and extend
it to suit your specific needs and application requirements. The flexibility of our approach allows
for a wide range of customization and extension while maintaining compatibility with multiple
platforms.
In the next chapter, we’ll explore how to load and render 3D models, which will allow us to create
more complex and visually interesting scenes.
In this chapter, we’ll set up a robust model loading system that can handle modern 3D assets.
Building upon the engine architecture we’ve established and the camera system we’ve
implemented, we’ll now add the ability to load and render complex 3D models. In the chapter on
glTF and KTX2 from the main tutorial, we learned about migrating from OBJ to glTF format and the
basics of loading glTF models. Now, we’ll integrate that knowledge into our engine structure to
create a more complete implementation.
This chapter will transform your understanding of 3D asset handling from simple model loading to
sophisticated engine-level systems. We’ll begin by building a scene graph, which provides the
hierarchical organization that complex 3D scenes require. This foundation enables you to group
objects logically, apply transformations at different levels, and efficiently manage scene complexity.
Animation support forms a crucial part of modern 3D applications. We’ll implement a system that
266
can handle glTF’s skeletal animations, giving life to your 3D models through smooth character
movement, object animations, and complex multi-part systems.
The PBR material system we’ll create bridges the gap between the lighting concepts from previous
chapters and real-world asset integration. You’ll see how to map glTF material properties to your
shaders seamlessly, creating a workflow that artists can understand and use effectively.
Rendering multiple objects with different transformations presents both technical and
organizational challenges. We’ll solve these through careful engine architecture that can batch
similar objects efficiently while maintaining the flexibility to handle unique materials and
transformations per object.
Throughout this implementation, we’ll structure our code with engine-level thinking rather than
tutorial-style solutions. This approach will serve you well as your projects grow in complexity and
scope, providing a solid foundation for creating complex scenes with animated models.
Prerequisites
This chapter builds on the foundation established in the main Vulkan tutorial, particularly Chapter
16 (Multiple Objects), as we’ll extend those concepts to handle more complex scene organization
and asset management. The multiple objects chapter introduced the basic concepts of rendering
different geometry, which we’ll now scale up to handle complete 3D models with materials and
animations.
You’ll need solid familiarity with core Vulkan concepts that form the backbone of our model
loading system. Command buffers become more complex when handling multiple models with
different materials, as we’ll need to manage descriptor sets and push constants efficiently.
Understanding graphics pipelines is crucial since different materials might require different
pipeline configurations.
Experience with vertex and index buffers translates directly to model loading, where glTF files
contain vertex data in specific formats that we’ll need to parse and upload to GPU buffers. Uniform
buffers knowledge becomes essential as we’ll use them for transformation matrices, lighting
information, and material properties.
Texture mapping skills are particularly important since glTF models often include multiple textures
per material (diffuse, normal, metallic-roughness, etc.), and we’ll need to load and bind these
textures efficiently.
Finally, basic 3D math understanding (matrices, vectors, quaternions) is crucial for handling model
transformations, animations, and scene hierarchies. If you need a refresher, see the Camera
Transformations chapter for detailed coverage of these mathematical concepts.
267
Loading Models: Asset Pipeline
Concepts
1. Understanding Asset Pipelines
After exploring engine architecture and camera systems, it’s important to understand how 3D
assets are managed in rendering engines. A well-designed asset pipeline is crucial for efficiently
handling models, textures, and other resources in any production environment.
Here’s an example of how assets might be organized in a final product, demonstrating all four
principles:
assets/
├── models/ // 3D model files (Categorization)
│ ├── characters/ // Character models (Hierarchy)
│ │ ├── player/ // Player character models (Hierarchy)
│ │ └── npc/ // Non-player character models (Hierarchy)
│ ├── environments/ // Environment models
│ │ ├── indoor/ // Indoor environment models
│ │ └── outdoor/ // Outdoor environment models
│ └── props/ // Prop models
├── textures/ // Texture files (Categorization)
│ ├── common/ // Shared textures (Discoverability)
│ └── high_resolution/ // High-res textures for close-up views
(Scalability)
├── shaders/ // Shader files
│ ├── core/ // Essential shaders (Discoverability)
│ ├── effects/ // Special effect shaders
│ └── mobile/ // Mobile-optimized shaders (Scalability)
└── config/ // Configuration files
└── quality_presets/ // Different quality settings (Scalability)
268
• Hierarchy: Assets are organized in a nested structure (e.g., models > characters > player)
• Discoverability: Common assets are placed in dedicated folders (e.g., common textures, core
shaders) making them easy to find
• Scalability: The structure accommodates different quality levels and platform-specific assets
(e.g., high-resolution textures, mobile shaders, quality presets)
The specific organization should be tailored to your project’s needs, but the underlying principles
remain consistent across different engines.
2. Export - Models are exported to interchange formats suitable for game engines
3. Validation - Models are checked for issues (e.g., incorrect scale, missing textures)
269
◦ Pros: Smaller file sizes, directly usable by GPU
1. Separate Files
2. Bundled Assets
1. Coordinate System - Different applications use different coordinate systems (e.g., Y-up vs. Z-up)
3. Origin Placement - Define where the origin point should be for different asset types
4. Level of Detail - Specify polygon count ranges for different asset types and usage scenarios
2. Material Standards - Define how materials should be structured (e.g., PBR parameters)
3. Export Settings - Document the correct export settings for your chosen interchange formats
270
3. Provides feedback to artists on technical requirements
1. Development Assets
2. Production Assets
1. Pre-Submission Validation
2. Pre-Conversion Validation
3. Post-Conversion Validation
271
1.4.3. Automation Considerations
1. Batch Processing
2. Continuous Integration
3. Versioning
1. Asynchronous Loading
272
◦ Consider loading models in background threads to avoid blocking the main thread
2. Memory Management
1.5.3. Extensibility
1. Material System
◦ Create a flexible material system that can represent various shading models
2. Animation System
◦ Consider how animations will interact with physics and gameplay systems
3. Custom Data
Understanding these concepts provides a solid foundation for designing and implementing model
loading systems in any rendering engine. By carefully considering abstraction, performance, and
extensibility from the beginning, you can create a robust system that will scale with your project’s
needs and adapt to changing requirements.
1. Model Format: We’ll use glTF 2.0 binary format (.glb) with embedded KTX2 textures. This
format offers several advantages:
273
◦ Ability to embed textures, reducing file operations
2. Texture Format: We’ll use KTX2 with Basis Universal compression for textures, which
provides:
3. Directory Structure:
assets/
├── models/ // 3D model files
│ ├── characters/ // Character models
│ │ └── [Link] // Example character model
│ ├── environments/ // Environment models
│ │ └── [Link] // Example environment model
│ └── props/ // Prop models
│ └── [Link] // Example prop model
└── shaders/ // Shader files
└── [Link] // PBR shader
1. Model Loading: We’ll use the tinygltf library to parse glTF files. This library provides:
2. Texture Loading: We’ll use the KTX-Software library to load KTX2 textures, which offers:
3. Asset Conversion: For converting development assets to production assets, we’ll use:
274
2.3. Integration with Engine Architecture
Our model loading system will integrate with the engine architecture from previous chapters:
1. Resource Management: We’ll leverage the resource management system from the Engine
Architecture chapter to:
◦ These components will work with the TransformComponent from the Camera
Transformations chapter
3. Rendering Pipeline: Our model loading system will integrate with the rendering pipeline by:
1. Development Phase:
2. Technical Requirements:
3. Conversion Process:
4. Integration:
275
◦ Place converted assets in the appropriate directories
1. Load Models:
2. Process Materials:
3. Handle Animations:
4. Render Models:
By implementing these specific approaches, our engine will have a robust and efficient asset
pipeline that aligns with the general concepts discussed earlier in this chapter.
276
1.1. Building on glTF Knowledge
As we learned in the glTF and KTX2 Migration chapter, glTF is a modern 3D format that supports a
wide range of features including PBR materials, animations, and scene hierarchies. In this chapter,
we’ll leverage these capabilities to build a more robust engine.
While the previous chapter covered the basics of loading glTF models, here we’ll focus on
organizing the loaded data into a proper scene graph and implementing animation support. This
approach will allow us to create more complex and dynamic scenes.
In this chapter, we’ll not only implement the technical aspects of model loading but also discuss the
architectural decisions behind our design and how developers can effectively use this system in
their applications. Understanding these concepts is crucial for building a maintainable and
extensible engine.
However, instead of just loading the model data directly into vertex and index buffers, we’ll create
a more structured approach with proper data classes to represent our scene.
277
offsetof(Vertex, normal) ),
vk::VertexInputAttributeDescription( 2, 0, vk::Format::eR32G32B32Sfloat,
offsetof(Vertex, color) ),
vk::VertexInputAttributeDescription( 3, 0, vk::Format::eR32G32Sfloat,
offsetof(Vertex, texCoord) )
};
}
• Spatial Organization: Scene graphs organize objects based on their spatial relationships,
278
making it easier to perform operations like culling, collision detection, and level-of-detail
management.
• Animation Support: Hierarchical structures are crucial for skeletal animations, where
movements propagate through a chain of bones.
• Scene Management: Scene graphs facilitate operations like saving/loading scenes, instancing
(reusing the same model in different locations), and dynamic scene modifications.
For example, with a flat collection of objects, if you wanted to move a character and all its
equipment, you’d need to update each piece individually. With a scene graph, you simply move the
character node, and all attached equipment moves automatically.
It’s important to distinguish between scene graphs and spatial partitioning systems (often referred
to as "game maps" in engine development):
• Spatial Partitioning Systems focus on efficiently organizing objects in space for collision
detection, visibility determination, and physics calculations.
While scene graphs organize objects based on logical relationships (like a character and its
equipment), spatial partitioning systems organize objects based on their physical location in the
game world.
• Octrees: Divide 3D space into eight equal octants recursively. Used for large open worlds where
objects are distributed unevenly. Octrees adapt to object density, with more subdivisions in
crowded areas.
• Binary Space Partitioning (BSP): Recursively divides space using planes. Particularly efficient
for indoor environments and was popularized by early first-person shooters like Doom and
Quake.
• Quadtrees: The 2D equivalent of octrees, dividing space into four quadrants recursively.
Commonly used for 2D games or for terrain in 3D games.
• Axis-Aligned Bounding Boxes (AABB) Trees: Organize objects based on their bounding boxes,
279
creating a hierarchy that allows for efficient collision checks.
• Portal Systems: Divide the world into "rooms" connected by "portals." This approach is
particularly effective for indoor environments with distinct areas.
• Spatial Hashing: Maps 3D positions to a hash table, allowing for constant-time lookups of
nearby objects. Useful for particle systems and other scenarios with many similar-sized objects.
• Bounding Volume Hierarchies (BVH): Create a tree of nested bounding volumes, allowing for
efficient ray casting and collision detection.
Different game engines use different spatial partitioning systems, often combining multiple
approaches:
• Unreal Engine: Uses a combination of octrees for the overall world and BSP for detailed indoor
environments. Also uses a custom system called "Unreal Visibility Determination" that combines
portals and potentially visible sets.
• Unity: Implements a quadtree/octree hybrid system for its physics and rendering. For
navigation, it uses a navigation mesh system.
• CryEngine/CRYENGINE: Uses octrees for outdoor environments and portal systems for indoor
areas.
• Godot: Employs BVH trees for its physics engine and octrees for rendering.
• Source Engine (Valve): Famous for its Binary Space Partitioning (BSP) combined with a portal
system called "Potentially Visible Set" (PVS).
• id Tech (id Software): Early versions (Doom, Quake) pioneered BSP usage. Later versions use
combinations of BSP, octrees, and portal systems.
• Frostbite (EA): Uses a hierarchical grid system combined with octrees for its large-scale
destructible environments.
In practice, many modern engines use hybrid approaches, selecting the appropriate partitioning
system based on the specific needs of different parts of the game world.
• Node-Based Structure: We use a node-based approach where each node can have a mesh,
transformation, and children. This provides flexibility for complex scene hierarchies.
• Memory Management: We use a centralized ownership model where the Model class owns all
nodes, simplifying cleanup and preventing memory leaks.
280
• Efficient Traversal: We maintain both a hierarchical structure (nodes) and a flat list
(linearNodes) to support different traversal patterns efficiently.
// Play an animation
float deltaTime = 0.016f; // 16ms or ~60 FPS NB: Keep this relative to frame
instead of a constant in actual code as some systems are faster resulting in
faster animation on a constant that isn't tied to the frame time.
characterModel->updateAnimation(0, deltaTime); // Play the first animation
281
// Bind the appropriate material
if (node->[Link] >= 0) {
bindMaterial(model->materials[node->[Link]]);
}
// For animation
glm::vec3 translation = glm::vec3(0.0f);
glm::quat rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f);
glm::vec3 scale = glm::vec3(1.0f);
glm::mat4 getLocalMatrix() {
return glm::translate(glm::mat4(1.0f), translation) *
glm::toMat4(rotation) *
glm::scale(glm::mat4(1.0f), scale) *
matrix;
}
glm::mat4 getGlobalMatrix() {
glm::mat4 m = getLocalMatrix();
Node* p = parent;
while (p) {
m = p->getLocalMatrix() * m;
282
p = p->parent;
}
return m;
}
};
// Structure for a model with nodes, meshes, materials, textures, and animations
struct Model {
std::vector<Node*> nodes;
std::vector<Node*> linearNodes;
std::vector<Material> materials;
283
std::vector<Animation> animations;
~Model() {
for (auto node : linearNodes) {
delete node;
}
}
switch ([Link]) {
case AnimationChannel::TRANSLATION: {
glm::vec3 start = sampler.outputsVec3[i];
glm::vec3 end = sampler.outputsVec3[i + 1];
[Link]->translation = glm::mix(start, end, t);
break;
}
case AnimationChannel::ROTATION: {
glm::quat start = glm::quat(sampler.outputsVec4[i].w,
sampler.outputsVec4[i].x, sampler.outputsVec4[i].y, sampler.outputsVec4[i].z);
glm::quat end = glm::quat(sampler.outputsVec4[i + 1].w,
sampler.outputsVec4[i + 1].x, sampler.outputsVec4[i + 1].y, sampler.outputsVec4[i +
1].z);
284
[Link]->rotation = glm::slerp(start, end, t);
break;
}
case AnimationChannel::SCALE: {
glm::vec3 start = sampler.outputsVec3[i];
glm::vec3 end = sampler.outputsVec3[i + 1];
[Link]->scale = glm::mix(start, end, t);
break;
}
}
break;
}
}
}
}
};
The main purpose of glTF is to bridge the gap between 3D content creation tools (like Blender,
Maya, 3ds Max) and real-time rendering applications like games and visualization tools. Before
glTF, developers often had to create custom exporters or use intermediate formats that weren’t
optimized for real-time rendering.
285
• Efficiency: Optimized for loading speed and rendering performance with minimal processing
• Standardization: Widely adopted across the industry, reducing the need for custom exporters
• Scenes and Nodes: The hierarchical structure that organizes objects in a scene graph
• Meshes: The 3D geometry data (vertices, indices, attributes like normals and UVs)
• Textures and Images: Visual data for materials, with support for various texture types
• Skins: Data for skeletal animations (joint hierarchies and vertex weights)
3. Accessors: Descriptions of how to interpret data in a bufferView (type, component type, count,
etc.)
This system allows different attributes (positions, normals, UVs) to share the same underlying
buffer, reducing memory usage and file size. For example:
286
• It handles both .gltf and .glb formats transparently
Using tinygltf allows us to focus on the higher-level task of converting the parsed data into our
engine’s structures rather than dealing with the low-level details of parsing JSON and binary data.
if (extension == "glb") {
ret = [Link](&gltfModel, &err, &warn, modelPath);
} else if (extension == "gltf") {
ret = [Link](&gltfModel, &err, &warn, modelPath);
} else {
err = "Unsupported file extension: " + extension + ". Expected .gltf or .glb";
}
287
}
Supporting both .gltf and .glb formats gives artists flexibility in their workflow.
Materials define how surfaces look when rendered. Modern games and engines use Physically
Based Rendering (PBR), which simulates how light interacts with real-world materials based on
physical principles.
1. Basic Materials (1990s): Simple diffuse colors with optional specular highlights
2. Multi-Texture Materials (2000s): Multiple texture maps combined for different effects
3. Shader-Based Materials (Late 2000s): Custom shader programs for advanced effects
PBR represents the current state of the art in real-time graphics. It provides more realistic results
288
across different lighting conditions and ensures consistent appearance regardless of the
environment.
The PBR model in glTF is based on the "metallic-roughness" workflow, which uses these key
properties:
• Base Color: The albedo or diffuse color of the surface (RGB or texture)
• Roughness: How smooth or rough the surface is (0.0 = mirror-like, 1.0 = rough)
These properties can be specified as constant values or as texture maps for spatial variation across
the surface. We’ll go into details about PBR in the next few chapters.
In our engine, we use KTX2 with Basis Universal compression for textures. This approach offers
several advantages:
• Reduced File Size: Basis Universal compression significantly reduces texture sizes while
maintaining visual quality
• Cross-Platform Compatibility: Basis Universal textures work across different platforms and
graphics APIs
• Mipmap Support: KTX2 includes support for mipmaps, improving rendering quality and
289
performance
2. Embedded Data: Images are embedded directly in the .glb file as binary data
For our engine, we use the .glb format with embedded KTX2 textures. This approach:
The glTF specification supports embedded textures through the bufferView property of image
objects. When using KTX2 textures, the mimeType is set to "image/ktx2" to indicate the format.
The texture loading process involves several complex steps that bridge the gap between glTF’s
abstract texture references and Vulkan’s low-level GPU resources.
Texture tex;
[Link] = [Link]() ? "texture_" + std::to_string(i) : [Link];
The glTF texture system uses an indirection approach where textures reference images, and images
contain the actual pixel data or references to it. This separation allows multiple textures to share
the same image data but with different sampling parameters (like different filtering or wrapping
modes). Our iteration process builds a comprehensive inventory of all texture resources that
materials will eventually reference.
The naming strategy provides essential debugging and asset management capabilities. When artists
create textures in their 3D applications, meaningful names help developers identify which textures
serve which purposes during development. The fallback naming scheme ensures every texture has
a unique identifier even when artists haven’t provided descriptive names.
290
1.7. Texture Loading: Format Detection and Buffer
Access
Next, we need to figure out whether textures are embedded in the glTF file and identify their
format, setting up the foundation for appropriate loading strategies.
The MIME type detection ensures we’re working with KTX2 format specifically, which provides
several advantages over traditional image formats like PNG or JPEG. KTX2 is designed specifically
for GPU textures and supports advanced features like basis universal compression, multiple
mipmap levels, and direct GPU format compatibility. The bufferView check confirms that the image
data is embedded within the glTF file rather than referenced externally.
The buffer access pattern demonstrates glTF’s sophisticated data organization system. Rather than
copying data unnecessarily, we obtain direct pointers to the KTX2 data within the loaded glTF
buffer. This approach minimizes memory usage and avoids expensive copy operations, which is
particularly important when dealing with large texture datasets that can easily consume hundreds
of megabytes.
if (result != KTX_SUCCESS) {
std::cerr << "Failed to load KTX2 texture: " << ktxErrorString(result) <<
std::endl;
continue;
}
291
The KTX-Software library provides robust parsing of the complex KTX2 format, handling details
like multiple mipmap levels, various pixel formats, and metadata that would be extremely complex
to implement correctly from scratch. The KTX_TEXTURE_CREATE_LOAD_IMAGE_DATA_BIT flag instructs the
library to immediately load the actual pixel data into memory, preparing it for subsequent
processing steps.
Error handling at this stage is crucial because texture files can become corrupted during asset
pipeline processing or file transfer. By continuing with the next texture when one fails to load, we
ensure that a single problematic texture doesn’t prevent the entire model from loading. This
graceful degradation approach is essential for robust production systems where content issues
shouldn’t crash the application.
The format selection logic (shown in commented form) demonstrates how production systems
292
handle GPU capability differences. Desktop GPUs typically support BC7 compression which
provides excellent quality, while mobile GPUs often use ASTC or ETC2 formats. The transcoding
process happens at runtime based on the actual capabilities of the target GPU, ensuring optimal
performance and quality on every platform.
The transcoding operation itself is computationally intensive but happens only once during asset
loading. The resulting GPU-native format provides significantly better performance during
rendering compared to uncompressed textures, making the upfront transcoding cost worthwhile.
Failed transcoding attempts trigger cleanup of partially processed resources, preventing memory
leaks in error conditions.
293
&ktxVulkanImageMemory);
textures.push_back(tex);
}
// Base color
if ([Link]() == 4) {
[Link].r = [Link][0];
[Link].g = [Link][1];
[Link].b = [Link][2];
[Link].a = [Link][3];
}
if ([Link] >= 0) {
const auto& texture =
[Link][[Link]];
[Link] = &textures[[Link]];
}
if ([Link] >= 0) {
294
const auto& texture = [Link][[Link]];
[Link] = &textures[[Link]];
}
if ([Link] >= 0) {
const auto& texture = [Link][[Link]];
[Link] = &textures[[Link]];
}
if ([Link] >= 0) {
const auto& texture = [Link][[Link]];
[Link] = &textures[[Link]];
}
[Link].push_back(mat);
}
Scene graphs offer several critical advantages over flat collections of objects:
• Spatial Organization: Objects are organized based on their logical relationships, making scene
management easier
• Animation Support: Hierarchical structures are crucial for skeletal animations and complex
movement patterns
• Instancing Support: The same object can appear multiple times with different transformations
1. Character with Equipment: When a character moves, all attached equipment (weapons,
armor) should move with it. With a scene graph, you move the character node, and all child
nodes automatically inherit the transformation.
2. Vehicle with Moving Parts: A vehicle might have wheels that rotate independently while the
whole vehicle moves. A scene graph makes this hierarchy of movements natural to express.
295
3. Articulated Animations: Characters with skeletons need joints that move relative to their
parent joints. A scene graph directly models this parent-child relationship.
One of the most powerful aspects of scene graphs is how they handle transformations:
• The global transformation is calculated by combining the node’s local transformation with its
parent’s global transformation
We use a two-pass approach to ensure all nodes exist before we try to link them together.
296
1.12. Understanding 3D Geometry and Mesh Data
3D models are represented as meshes - collections of vertices, edges, and faces that define the shape
of an object. Understanding how this data is structured is crucial for efficient rendering.
• Indices: References to vertices that define how they connect to form triangles
◦ Texture Coordinates (UVs): 2D coordinates for mapping textures onto the surface
• Triangles are the simplest polygon that can represent any surface
glTF organizes mesh data in a way that’s efficient for both storage and rendering:
// Load meshes
297
for (size_t i = 0; i < [Link](); i++) {
const auto& node = [Link][i];
if ([Link] >= 0) {
const auto& mesh = [Link][[Link]];
// Set material
if ([Link] >= 0) {
[Link] = [Link];
}
• Keyframe Animation: Defining specific poses at specific times, with interpolation between
them
Modern games typically use a combination of these techniques, with skeletal animation forming the
backbone of character movement.
298
1.13.2. Core Animation Concepts
• Keyframes: Specific points in time where animation values are explicitly defined
glTF uses a flexible animation system that can represent various animation techniques:
• Channels: Links between samplers and node properties (translation, rotation, scale)
• Targets: The properties being animated (translation, rotation, scale, or weights for morph
targets)
• CUBICSPLINE: Smooth curves with control points for acceleration and deceleration
This system allows for complex animations that can target specific parts of a model independently,
enabling actions like walking, facial expressions, and complex interactions.
// Load animations
for (const auto& anim : [Link]) {
Animation animation;
[Link] = [Link];
[Link].push_back(animSampler);
299
}
[Link].push_back(animChannel);
}
[Link].push_back(animation);
}
The journey from a glTF file to pixels on the screen involves several stages:
1. Asset Loading: The glTF loader populates our Model, Node, Mesh, and Material structures
2. Scene Management: The engine maintains a collection of loaded models in the scene
3. Update Loop: Each frame, animations are updated based on elapsed time
5. Rendering: The scene graph is traversed, and each visible mesh is rendered with its material
This pipeline allows for efficient rendering of complex scenes with animated models.
• Instancing: Render multiple instances of the same mesh with different transforms
When loading models, especially large ones, memory management becomes crucial:
300
• Vertex Data: Store in GPU buffers for efficient rendering
• Textures: Use KTX2 with Basis Universal compression to significantly reduce memory usage
• Instancing: Reuse the same model data for multiple instances with different transforms
[Link]. Efficient Texture Memory Management with KTX2 and Basis Universal
Textures often consume the majority of GPU memory in 3D applications. KTX2 with Basis Universal
compression provides several memory optimization benefits:
• GPU-Native Formats: Textures are transcoded to formats that GPUs can directly sample from,
avoiding runtime decompression
• Mipmaps: KTX2 supports mipmaps, which not only improve visual quality but also reduce
memory usage for distant objects
• Format Selection: The transcoder can choose the optimal format based on the target GPU’s
capabilities:
1. Descriptor Sets: Create descriptor sets that bind texture image views and samplers to shader
binding points
2. Pipeline Layout: Define a pipeline layout that includes these descriptor sets
3. Shader Access: In shaders, access textures using the appropriate binding points
301
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eFragment
},
// Normal map
vk::DescriptorSetLayoutBinding{
.binding = 2,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eFragment
},
// Occlusion map
vk::DescriptorSetLayoutBinding{
.binding = 3,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eFragment
},
// Emissive map
vk::DescriptorSetLayoutBinding{
.binding = 4,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eFragment
}
};
vk::DescriptorSetLayoutCreateInfo layoutInfo{
.bindingCount = static_cast<uint32_t>([Link]()),
.pBindings = [Link]()
};
// For each material, create a descriptor set and update it with the material's
textures
for (const auto& material : [Link]) {
// Allocate descriptor set from the descriptor pool
vk::DescriptorSetAllocateInfo allocInfo{
.descriptorPool = descriptorPool,
.descriptorSetCount = 1,
.pSetLayouts = &*descriptorSetLayout
};
if ([Link]) {
302
vk::DescriptorImageInfo imageInfo{
.sampler = [Link]->sampler,
.imageView = [Link]->imageView,
.imageLayout = vk::ImageLayout::eShaderReadOnlyOptimal
};
vk::WriteDescriptorSet write{
.dstSet = *descriptorSet,
.dstBinding = 0,
.dstArrayElement = 0,
.descriptorCount = 1,
.descriptorType = vk::DescriptorType::eCombinedImageSampler,
.pImageInfo = &imageInfo
};
descriptorWrites.push_back(write);
}
[Link](descriptorWrites, {});
// Store the descriptor set with the material for later use during rendering
[Link] = *descriptorSet;
}
1. Texture Atlasing: Combine multiple small textures into a single larger texture to reduce state
changes
2. Mipmap Management: Generate and use mipmaps for all textures to improve performance
and quality
3. Texture Streaming: For very large scenes, implement texture streaming to load higher
resolution textures only when needed
4. Memory Budgeting: Implement a texture budget system that can reduce texture quality when
memory is constrained
5. Format Selection: Choose the appropriate format based on the texture content:
303
1.15. Summary and Next Steps
In this chapter, we’ve explored the process of loading 3D models from glTF files and organizing
them into a scene graph. We’ve covered:
In the next chapter, we’ll explore how to render these models using physically-based rendering
techniques, bringing our loaded assets to life with realistic lighting and materials.
Previous: Implementing the Model Loading System | Next: Implementing PBR Rendering :pp: ++
304
knowledge to implement a PBR pipeline for the glTF models we’ve loaded.
As we learned in the glTF and KTX2 Migration chapter, glTF uses PBR with the metallic-roughness
workflow for its material system. This aligns perfectly with the PBR concepts we’ve already
covered, making it straightforward to render our glTF models with physically accurate lighting.
By using these properties directly, we can ensure our rendering matches the artist’s intent and
produces physically accurate results.
// PBR parameters
alignas(16) glm::vec4 lightPositions[4]; // Position and radius
alignas(16) glm::vec4 lightColors[4]; // RGB color and intensity
alignas(16) glm::vec4 camPos; // Camera position for view-dependent
effects
alignas(4) float exposure = 4.5f; // Exposure for HDR rendering
alignas(4) float gamma = 2.2f; // Gamma correction value
alignas(4) float prefilteredCubeMipLevels = 1.0f; // For image-based lighting
alignas(4) float scaleIBLAmbient = 1.0f; // Scale factor for ambient lighting
};
305
This uniform buffer includes:
1. Standard Transformation Matrices: Model, view, and projection matrices for vertex
transformation
5. Image-Based Lighting Parameters: For environment reflections (we’ll cover this in a later
chapter)
We introduced push constants earlier in push constants; here we focus on how the
same mechanism carries glTF metallic‑roughness material knobs efficiently per
draw.
To implement PBR, we need to set up descriptor sets for our textures and uniform buffer:
306
vk::DescriptorSetLayoutBinding uboBinding{
.binding = 0,
.descriptorType = vk::DescriptorType::eUniformBuffer,
.descriptorCount = 1,
.stageFlags = vk::ShaderStageFlagBits::eVertex |
vk::ShaderStageFlagBits::eFragment
};
// Metallic-roughness texture
textureBindings[1].binding = 2;
textureBindings[1].descriptorType = vk::DescriptorType::eCombinedImageSampler;
textureBindings[1].descriptorCount = 1;
textureBindings[1].stageFlags = vk::ShaderStageFlagBits::eFragment;
// Normal map
textureBindings[2].binding = 3;
textureBindings[2].descriptorType = vk::DescriptorType::eCombinedImageSampler;
textureBindings[2].descriptorCount = 1;
textureBindings[2].stageFlags = vk::ShaderStageFlagBits::eFragment;
// Occlusion map
textureBindings[3].binding = 4;
textureBindings[3].descriptorType = vk::DescriptorType::eCombinedImageSampler;
textureBindings[3].descriptorCount = 1;
textureBindings[3].stageFlags = vk::ShaderStageFlagBits::eFragment;
// Emissive map
textureBindings[4].binding = 5;
textureBindings[4].descriptorType = vk::DescriptorType::eCombinedImageSampler;
textureBindings[4].descriptorCount = 1;
textureBindings[4].stageFlags = vk::ShaderStageFlagBits::eFragment;
307
// Create the descriptor set layout
vk::DescriptorSetLayoutCreateInfo layoutInfo{
.bindingCount = static_cast<uint32_t>([Link]()),
.pBindings = [Link]()
};
Our PBR pipeline needs to be configured for the specific requirements of physically-based
rendering:
void createPipeline() {
// ... (standard pipeline setup code)
308
// ... (rest of pipeline creation)
}
// Uniform buffer
struct UniformBufferObject {
float4x4 model;
float4x4 view;
float4x4 proj;
float4 lightPositions[4];
float4 lightColors[4];
float4 camPos;
float exposure;
float gamma;
float prefilteredCubeMipLevels;
float scaleIBLAmbient;
};
// Constants
static const float PI = 3.14159265359;
// Bindings
309
ConstantBuffer<UniformBufferObject> ubo;
Texture2D baseColorMap;
SamplerState baseColorSampler;
Texture2D metallicRoughnessMap;
SamplerState metallicRoughnessSampler;
Texture2D normalMap;
SamplerState normalSampler;
Texture2D occlusionMap;
SamplerState occlusionSampler;
Texture2D emissiveMap;
SamplerState emissiveSampler;
// PBR functions
float DistributionGGX(float NdotH, float roughness) {
float a = roughness * roughness;
float a2 = a * a;
float NdotH2 = NdotH * NdotH;
310
float ao = [Link](occlusionSampler, [Link]).r; //
link:[Link] occlusion]
float3 emissive = [Link](emissiveSampler, [Link]).rgb; //
link:[Link] lighting] (self-illumination)
// Initialize lighting
float3 Lo = float3(0.0, 0.0, 0.0);
// Calculate half vector (the normalized vector halfway between view and light
direction)
// Used in
link:[Link]
and PBR models
float3 H = normalize(V + L);
311
float NdotH = max(dot(N, H), 0.0);
float HdotV = max(dot(H, V), 0.0);
// Specular BRDF
float D = DistributionGGX(NdotH, roughness);
float G = GeometrySmith(NdotV, NdotL, roughness);
float3 F = FresnelSchlick(HdotV, F0);
float3 numerator = D * G * F;
float denominator = 4.0 * NdotV * NdotL + 0.0001;
float3 specular = numerator / denominator;
// link:[Link] conservation]
float3 kS = F;
float3 kD = float3(1.0, 1.0, 1.0) - kS;
kD *= 1.0 - metallic;
// link:[Link]
link:[Link] and
link:[Link] correction]
color = color / (color + float3(1.0, 1.0, 1.0));
color = pow(color, float3(1.0 / [Link], 1.0 / [Link], 1.0 / [Link]));
PBR requires careful setup of light sources to achieve realistic results. Here’s how we can set up
lights in our application:
312
void setupLights() {
// Set up four lights with different positions and colors
std::array<glm::vec4, 4> lightPositions = {
glm::vec4(-10.0f, 10.0f, 10.0f, 1.0f),
glm::vec4(10.0f, 10.0f, 10.0f, 1.0f),
glm::vec4(-10.0f, -10.0f, 10.0f, 1.0f),
glm::vec4(10.0f, -10.0f, 10.0f, 1.0f)
};
PBR relies on view-dependent effects like the Fresnel effect, so we need to integrate our camera
system:
313
[Link] = [Link]();
[Link] = [Link]([Link] /
(float)[Link]);
PushConstantBlock pushConstants{};
[Link] = [Link];
[Link] = [Link];
[Link] = [Link];
314
[Link] = [Link];
[Link] =
[Link];
[Link] = [Link];
[Link] = [Link];
[Link] = [Link];
[Link](
pipelineLayout,
vk::ShaderStageFlagBits::eFragment,
0,
sizeof(PushConstantBlock),
&pushConstants
);
}
IBL uses environment maps to simulate global illumination: * Diffuse IBL: Uses irradiance maps
for ambient lighting * Specular IBL: Uses pre-filtered environment maps and BRDF integration
maps for reflections
For materials like skin, wax, or marble where light penetrates the surface:
315
• Simulates how light scatters within translucent materials
1.6.4. Anisotropy
• How to leverage the material properties from glTF for PBR rendering
• How to set up uniform buffers and push constants for PBR parameters
• How to integrate our camera system with PBR for view-dependent effects
This implementation allows us to render the glTF models we loaded in the previous chapter with
physically accurate materials, resulting in more realistic and consistent rendering across different
lighting conditions.
In the next chapter, we’ll explore how to render multiple objects with different transformations,
which will allow us to create more complex scenes with our PBR-enabled engine.
If you want to dive deeper into lighting and materials, refer back to the Lighting & Materials
chapter, where we explored the theory behind PBR in detail.
316
1. Managing Multiple Objects in a 3D Scene
1.1. 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.
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
For our engine, we’ll implement the instancing approach, which is more memory-efficient and
suitable for many common scenarios.
◦ Advantages: Efficient culling and queries, better performance for large scenes
317
◦ Examples: Octrees, BSP trees, grid systems
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.
Each object typically requires at least one draw call, which can become a bottleneck:
• Instanced Rendering: Using hardware instancing to draw multiple copies of the same mesh
• Distance Culling: Skip rendering objects too far from the camera
• Asset Streaming: Load and unload assets based on proximity to the camera
• Instance Data: Store only transformation and material variations per instance
318
glm::vec3 scale; // Scale factors for each axis
};
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.
void setupObjectInstances() {
// Create multiple instances of the model with different positions
const int MAX_OBJECTS = 10; // Define how many objects we want
[Link](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);
319
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);
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.
void drawFrame() {
// ... (standard Vulkan frame setup)
vk::RenderingAttachmentInfo colorAttachmentInfo = {
.imageView = swapChainImageViews[imageIndex],
320
.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
};
// Bind pipeline
[Link](vk::PipelineBindPoint::eGraphics, graphicsPipeline);
321
// Copy to uniform buffer (per frame-in-flight)
memcpy(uniformBuffers[currentFrame].mapped, &ubo, sizeof(ubo));
322
if (!node->[Link]() && !node->[Link]() &&
node->vertexBufferIndex >= 0 && node->indexBufferIndex >= 0) {
323
renderNode(commandBuffer, node->children, nodeMatrix);
}
}
}
This rendering approach leverages our model system to efficiently render multiple instances of a
model:
1. It uses the scene graph structure to handle complex models with multiple parts
While this approach is more sophisticated than a simple flat list of objects, it does have some
limitations:
1. It still requires a separate draw call for each mesh in each instance, which can be inefficient for
large numbers of objects
// 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 : [Link]) {
if (node->[Link]() || node->[Link]()) {
continue;
}
324
vk::raii::Buffer instanceBuffer = nullptr;
vk::raii::DeviceMemory instanceBufferMemory = nullptr;
createBuffer(
bufferSize,
vk::BufferUsageFlagBits::eVertexBuffer,
vk::MemoryPropertyFlagBits::eHostVisible |
vk::MemoryPropertyFlagBits::eHostCoherent,
instanceBuffer,
instanceBufferMemory
);
void updateInstanceBuffers() {
// For each node with an instance buffer
for (auto node : [Link]) {
if (node->instanceBufferIndex < 0) {
continue;
}
325
}
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);
}
326
[Link] = [Link]();
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:
• Bind per‑mesh vertex + index buffers and a per‑mesh instance buffer, then draw instanced
void drawFrame() {
// ... (standard Vulkan frame setup)
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,
327
.clearValue = clearDepth
};
vk::RenderingInfo renderingInfo = {
.renderArea = { .offset = { 0, 0 }, .extent = swapChainExtent },
.layerCount = 1,
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachmentInfo,
.pDepthAttachment = &depthAttachmentInfo
};
// Bind pipeline
[Link](vk::PipelineBindPoint::eGraphics, graphicsPipeline);
328
transition_image_layout(
imageIndex,
vk::ImageLayout::eColorAttachmentOptimal,
vk::ImageLayout::ePresentSrcKHR,
vk::AccessFlagBits2::eColorAttachmentWrite,
{},
vk::PipelineStageFlagBits2::eColorAttachmentOutput,
vk::PipelineStageFlagBits2::eBottomOfPipe
);
// 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->[Link]() && !node->[Link]() &&
node->vertexBufferIndex >= 0 && node->indexBufferIndex >= 0 &&
node->instanceBufferIndex >= 0) {
329
[Link] = -1;
[Link] = -1;
[Link] = -1;
}
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.
#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;
330
layout(location = 5) in vec4 instanceModelRow1;
layout(location = 6) in vec4 instanceModelRow2;
layout(location = 7) in vec4 instanceModelRow3;
// 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;
void main() {
// Reconstruct model matrix from instance attributes
mat4 instanceModel = mat4(
instanceModelRow0,
instanceModelRow1,
instanceModelRow2,
instanceModelRow3
);
331
1.8. 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:
332
float r, g, b;
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;
}
This approach allows for much more visual variety in your scene, even when using the same base
model for all instances.
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.
Previous: Understanding Physically Based Rendering | Next: Rendering the Scene :pp: ++
333
Loading Models: Rendering the
Scene
1. Rendering the Scene
1.1. Introduction to Scene Rendering
Scene rendering is the process of transforming a 3D scene description into a 2D image that can be
displayed on screen. In our engine, this involves traversing the scene graph, applying
transformations, setting material properties, and issuing draw commands to the GPU.
The scene rendering process is a critical part of the rendering pipeline, as it’s where all the
components we’ve built so far come together:
• The model system provides the scene graph structure and mesh data
In this chapter, we’ll explore how these components work together to render a complete scene.
To render a scene graph, we need to traverse it in a depth-first manner, calculating the global
transformation matrix for each node and rendering any meshes we encounter:
The renderNode function is the heart of our scene rendering system. It recursively traverses the
scene graph, calculating the global transformation matrix for each node and rendering any meshes
it contains:
334
1.3. Node traversal and transform calculation
The rendering process begins with systematic traversal of the scene graph, where each node’s
transformation is calculated by combining its local transformation with its parent’s accumulated
transformation matrix.
The transformation calculation represents the core of hierarchical scene graph rendering. Each
node’s getLocalMatrix() returns its transformation relative to its parent, which we then combine
with the accumulated parent transformation using matrix multiplication. This mathematical
operation effectively "chains" transformations down the hierarchy, ensuring that moving a parent
node automatically moves all its children in world space.
The order of multiplication is critical here: parentMatrix * nodeLocalMatrix ensures that the node’s
local transformation occurs first (in the node’s local coordinate space), followed by the parent’s
transformation that places it in world space. This ordering preserves the hierarchical relationship
where children are positioned relative to their parents.
This validation step prevents rendering errors that could occur from incomplete scene graph nodes.
Not every node in a scene graph necessarily contains renderable geometry - some nodes exist
purely for organization or as transformation anchors for child objects. By checking for non-empty
vertex and index arrays plus valid buffer indices, we ensure that we only attempt to render nodes
that have been properly prepared with GPU resources.
The buffer index checks (>= 0) confirm that the mesh data has been successfully uploaded to GPU
buffers and assigned valid indices (0 or greater) in our buffer management system. Note that 0 is a
valid buffer index - negative values (typically -1) indicate uninitialized or failed buffer allocations.
335
1.5. Material property configuration
This material setup step translates high-level material descriptions into GPU-ready push constants
that control the appearance and lighting properties of the rendered geometry.
336
}
The material configuration system bridges the gap between artist-authored materials and GPU
shader parameters. Push constants provide the fastest path for updating per-object material data,
as they bypass the GPU’s memory hierarchy and are directly accessible to shader cores. This makes
them ideal for material properties that change frequently between draw calls.
The texture index mapping system (-1 for unused, positive integers for active bindings) allows
shaders to conditionally sample textures based on availability. This approach provides flexibility
where some materials might have normal maps while others don’t, without requiring different
shader variants or complex branching logic.
The default material properties are chosen conservatively to prevent rendering artifacts when
materials are missing or improperly configured. Metallic and roughness values of 1.0 tend to
produce visually acceptable results across different lighting conditions, though they may not
represent the intended material appearance.
The resource binding sequence follows Vulkan’s explicit binding model where each resource type
must be bound before use. Vertex buffers provide the per-vertex attribute data (positions, normals,
texture coordinates), while index buffers define how vertices connect to form triangles. This
337
indexed rendering approach reduces memory usage by allowing vertex reuse across multiple
triangles.
The drawIndexed command triggers GPU execution of the entire graphics pipeline for this mesh. The
GPU processes each index to fetch vertex data, runs the vertex shader to transform geometry,
rasterizes triangles to generate fragments, and executes the fragment shader to determine final
pixel colors. All the material properties we configured via push constants become available to the
fragment shader during this process.
The first step in rendering a node is calculating its global transformation matrix:
This combines the node’s local transformation (position, rotation, scale) with its parent’s global
transformation. The result is a matrix that transforms from the node’s local space to world space.
The getLocalMatrix method (defined in the Node class) combines the node’s translation, rotation, and
scale properties:
338
glm::mat4 getLocalMatrix() {
return glm::translate(glm::mat4(1.0f), translation) *
glm::toMat4(rotation) *
glm::scale(glm::mat4(1.0f), scale) *
matrix;
}
We covered PBR material theory and shader details earlier in PBR Rendering, so
we won’t restate that here. This section focuses on the wiring: how material
properties are packed into push constants and consumed by the draw call in this
chapter’s context.
If the node has a mesh, we need to set up its material properties before rendering:
339
This code:
• Metallic factor
• Roughness factor
• Texture set indices for various material maps (base color, metallic-roughness, normal,
occlusion, emissive)
Once the transformation and material are set up, we can render the mesh:
This code:
This ensures that all nodes in the scene graph are visited and rendered with the correct
transformations.
340
1.9. Integrating Scene Rendering in the Main Loop
To use our scene rendering system in the main rendering loop, we need to set up the necessary
Vulkan state and call the renderScene function. To keep this digestible, think of the frame as five
steps:
1) Begin and describe attachments (dynamic rendering inputs) 2) Begin rendering, bind pipeline,
set viewport/scissor 3) Update camera UBO (view/projection) 4) Traverse scene graph and issue per-
mesh draws 5) End rendering and present
void drawFrame() {
// ... (standard Vulkan frame setup)
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,
341
.colorAttachmentCount = 1,
.pColorAttachments = &colorAttachmentInfo,
.pDepthAttachment = &depthAttachmentInfo
};
// Bind pipeline
[Link](vk::PipelineBindPoint::eGraphics, graphicsPipeline);
342
vk::PipelineStageFlagBits2::eColorAttachmentOutput,
vk::PipelineStageFlagBits2::eBottomOfPipe
);
This code:
1. Sets up the Vulkan rendering state (command buffer, image transitions, rendering attachments)
Frustum culling is the process of skipping the rendering of objects that are outside the camera’s
view frustum. This can significantly improve performance by reducing the number of draw calls:
// Calculate the signed distance from the sphere center to the plane
float distance = glm::dot(glm::vec4(center, 1.0f), plane);
343
}
return true;
}
Level of Detail (LOD) involves using simpler versions of models for objects that are far from the
camera:
344
// Render the node with the selected LOD level
// ...
Occlusion culling involves skipping the rendering of objects that are hidden behind other objects:
// Render the node's bounding box with depth write but no color write
renderBoundingBox(commandBuffer, node, nodeMatrix);
345
// ...
For scenes with many identical objects, instanced rendering can significantly improve
performance:
346
1.11. Advanced Scene Rendering Techniques
Beyond basic scene rendering, there are several advanced techniques that can enhance the visual
quality and performance of your renderer:
Hierarchical culling involves using the scene graph structure to accelerate culling operations:
return hasVisibleContent;
}
347
if (!node->[Link]()) {
renderNodeHierarchical(commandBuffer, node->children, nodeMatrix,
viewProjection);
}
}
}
}
The hierarchical culling algorithm presented here works correctly but is relatively
naïve. In its current form, it’s unlikely to provide significant performance
improvements over simpler culling approaches. For real performance gains in
production environments, more efficient and complex handling would be needed,
such as:
• Using bounding volume hierarchies (BVH) with efficient spatial data structures
• Using occlusion queries to skip entire subtrees hidden behind other geometry
Deferred rendering separates the geometry and lighting passes, which can improve performance
for scenes with many lights:
Clustered rendering divides the view frustum into 3D cells to efficiently handle many lights:
void setupLightClusters() {
// Divide the view frustum into a 3D grid of clusters
const int clusterCountX = 16;
const int clusterCountY = 9;
348
const int clusterCountZ = 24;
1.12. Conclusion
In this chapter, we’ve explored the process of rendering a scene using a scene graph. We’ve seen
how to traverse the scene graph, calculate transformations, apply materials, and render meshes.
We’ve also discussed various optimization techniques to improve performance.
The scene rendering system we’ve built is flexible and extensible, allowing for the rendering of
complex scenes with multiple objects, materials, and lighting conditions. In the next chapter, we’ll
build on this foundation to implement animations, bringing our scenes to life.
349
Loading Models: Updating
Animations
1. Understanding and Implementing
Animations
1.1. Introduction to 3D Animations
Animation is a crucial aspect of modern 3D applications, bringing static models to life with
movement and interactivity. In our engine, we’ve implemented a robust animation system that
supports skeletal animations from glTF files.
• Keyframes: Specific points in time where the state of an object is explicitly defined
• Bones/Joints: A hierarchical structure that defines how parts of a model move together
glTF provides a standardized way to store and transfer animations, which our engine can load and
play back.
350
// Structure for animation
struct Animation {
std::string name;
std::vector<AnimationSampler> samplers;
std::vector<AnimationChannel> channels;
float start = std::numeric_limits<float>::max();
float end = std::numeric_limits<float>::min();
float currentTime = 0.0f;
};
These structures work together to define how animations are stored and processed:
Animation validation is critical for robust systems because not all models contain animations, and
external code might request non-existent animation indices. By performing this check early, we
avoid crashes and undefined behavior when working with static models or invalid animation
351
requests. This defensive programming approach is essential in production game engines where
content from various sources might have inconsistent animation data.
Time management forms the foundation of animation playback, where the deltaTime parameter
represents the elapsed time since the last update. This frame-rate independent approach ensures
animations play at consistent speeds regardless of rendering performance. The automatic looping
mechanism seamlessly restarts animations when they reach their end time, creating continuous
motion that’s essential for idle animations, walking cycles, and other repetitive movements.
The channel iteration represents the heart of our animation-to-scene-graph mapping system. Each
channel defines a specific transformation type (position, rotation, or scale) for a particular node in
the scene hierarchy. This one-to-many relationship allows complex animations where multiple
properties of multiple nodes can be animated simultaneously, enabling sophisticated character
animations with dozens of moving parts.
The sampler access pattern demonstrates the separation of concerns in our animation architecture.
Samplers contain the actual keyframe data and interpolation logic, while channels define what gets
animated. This design allows multiple channels to share the same sampler data, reducing memory
usage when the same animation curve applies to different nodes or when different transformation
components follow identical patterns.
// Find the current keyframe pair that brackets the animation time using
binary search
auto nextKeyFrameIt = std::lower_bound([Link](),
[Link](), [Link]);
if (nextKeyFrameIt != [Link]() && nextKeyFrameIt !=
[Link]()) {
size_t i = std::distance([Link](), nextKeyFrameIt) - 1;
// Calculate normalized interpolation factor between keyframes
352
float t = ([Link] - [Link][i]) / ([Link][i
+ 1] - [Link][i]);
The keyframe search algorithm uses std::lower_bound to perform a binary search, finding the pair
of keyframes that bracket the current animation time with O(log n) complexity. This efficient
approach is ideal for animation data with many keyframes, providing optimal performance
compared to linear scanning. The binary search returns an iterator to the first keyframe whose
time is greater than or equal to the current animation time, allowing us to determine the bracketing
pair by looking at the previous keyframe.
The interpolation factor calculation creates a normalized value between 0.0 and 1.0 that represents
exactly where the current time falls between two keyframes. When t=0.0, we’re at the first
keyframe; when t=1.0, we’re at the second keyframe; values in between create smooth transitions.
This mathematical foundation enables all the interpolation techniques that follow, whether for
linear position changes or complex quaternion rotations.
353
}
}
break;
}
}
}
}
This method:
2. Calculates the interpolation factor between the current and next keyframe
3. Interpolates between keyframe values based on the channel type (translation, rotation, or
scale)
void mainLoop() {
while (!glfwWindowShouldClose(window)) {
glfwPollEvents();
drawFrame();
}
[Link]();
}
354
This code:
Animation blending is a technique that combines multiple animations to create smooth transitions
or entirely new animations. This is essential for creating realistic character movement and
responsive gameplay.
At its core, animation blending works by interpolating between the transformations (position,
rotation, scale) of corresponding bones or nodes in different animations. The key concepts include:
• Blend Factor: A value between 0.0 and 1.0 that determines how much of each animation
contributes to the final result
• Blend Space: A multidimensional space where animations are positioned based on parameters
(like speed, direction)
• Blend Trees: Hierarchical structures that organize multiple blends into complex animation
systems
• Linear Blending: Simple interpolation between two animations (e.g., transitioning from walk to
run)
• Additive Blending: One animation is added on top of another (e.g., adding a "wounded" limp to
any movement animation)
• Partial Blending: Blending that affects only certain parts of the skeleton (e.g., aiming a weapon
while walking)
355
void blendAnimations(uint32_t fromIndex, uint32_t toIndex, float blendFactor) {
// Store original node transformations
std::vector<glm::vec3> originalTranslations;
std::vector<glm::quat> originalRotations;
std::vector<glm::vec3> originalScales;
This implementation:
356
2. Applies the first animation and stores its transformations
5. Blends between the two animations using linear interpolation for positions and scales, and
spherical interpolation for rotations
std::vector<std::vector<glm::vec3>> allTranslations;
std::vector<std::vector<glm::quat>> allRotations;
std::vector<std::vector<glm::vec3>> allScales;
357
// Apply this animation
[Link](animIndex, 0.0f);
// Store transformations
std::vector<glm::vec3> translations;
std::vector<glm::quat> rotations;
std::vector<glm::vec3> scales;
allTranslations.push_back(std::move(translations));
allRotations.push_back(std::move(rotations));
allScales.push_back(std::move(scales));
}
358
normalizedWeight;
} else {
blendedRotation += allRotations[animIdx][nodeIdx] *
normalizedWeight;
}
}
}
This more advanced implementation allows for blending between any number of animations with
different weights, which is essential for complex animation systems like locomotion or facial
expressions.
For character movement, blend spaces are particularly useful. A blend space is a 2D or 3D space
where animations are positioned based on parameters like speed and direction:
359
std::vector<float> weights;
This blend space implementation allows for smooth transitions between different movement
360
animations based on the character’s current direction and speed.
While animation blending gives us powerful tools to combine pre-created animations, sometimes
we need to adapt animations to dynamic environments in real-time. For example, how do we make
a character’s hand precisely grab an object, or ensure feet properly plant on uneven terrain? This is
where our next technique comes in.
• Forward Kinematics (FK): Given joint angles, calculate the position of the end effector
◦ Straightforward to compute
• Inverse Kinematics (IK): Given a desired end effector position, calculate the joint angles
Just as we use animation blending to create smooth transitions between predefined animations, we
use IK to adapt those animations to dynamic environments. IK enhances our animation system in
several key ways:
• Foot Placement: Remember how our animations update node transformations? With IK, we
can adjust those transformations to ensure feet properly contact uneven terrain, preventing the
"floating feet" problem common in games
• Hand Placement: Similar to our blend space example where we interpolate between different
animations, IK lets us precisely position a character’s hands to grab objects at any position
• Aiming: We can use IK to orient a character’s upper body toward a target while the lower body
follows a different animation
• Ragdoll Physics: When transitioning from animated to physics-driven movement (like when a
character falls), IK helps create realistic physical responses
361
[Link]. IK Algorithms
Just as we have different interpolation methods for animation keyframes (LINEAR, STEP,
CUBICSPLINE in our AnimationSampler), we have different algorithms for solving IK problems:
• Analytical Methods: For simple cases like two-bone chains (arm or leg), we can use closed-form
mathematical solutions - similar to how we directly interpolate between two keyframes
• Cyclic Coordinate Descent (CCD): An iterative approach that adjusts one joint at a time,
working backward from the end effector
• Jacobian Inverse: Uses matrix operations to find optimal joint adjustments for complex chains
The simplest and most common IK scenario involves a two-bone chain (like an arm or leg). Here’s
an implementation of the analytical two-bone IK solution:
// Two-bone IK solver
bool solveTwoBoneIK(
Node* rootNode, // The root joint (e.g., shoulder or hip)
Node* midNode, // The middle joint (e.g., elbow or knee)
Node* endNode, // The end effector (e.g., hand or foot)
const glm::vec3& targetPosition, // Target world position
const glm::vec3& hingeAxis, // Axis of rotation for the middle joint
float preferredAngle = 0.0f // Preferred angle for resolving ambiguity
) {
// Get the original global positions
glm::mat4 rootGlobal = rootNode->getGlobalMatrix();
glm::mat4 midGlobal = midNode->getGlobalMatrix();
glm::mat4 endGlobal = endNode->getGlobalMatrix();
362
// Set mid node position
glm::vec3 newMidPos = rootPos + direction * bone1Length;
// Calculate the angle between the first bone and the target direction
float cosAngle1 = (b*b + a*a - c*c) / (2*b*a);
cosAngle1 = glm::clamp(cosAngle1, -1.0f, 1.0f); // Avoid numerical errors
float angle1 = acos(cosAngle1);
// Create a rotation that aligns the x-axis with the target direction
glm::vec3 xAxis(1.0f, 0.0f, 0.0f);
glm::vec3 rotAxis = glm::cross(xAxis, targetDir);
363
// Combine rotations
glm::quat finalRot = prefRot * targetRot * glm::angleAxis(angle1, hingeAxis);
This implementation:
For chains with more than two bones, CCD is a popular iterative approach:
// CCD IK solver
void solveCCDIK(
std::vector<Node*> chain, // Joint chain from root to end effector
const glm::vec3& targetPosition, // Target world position
int maxIterations = 10, // Maximum iterations
float threshold = 0.01f // Distance threshold for success
) {
if ([Link]() < 2) return;
364
// Check if we're close enough to the target
if (glm::distance(endPos, targetPosition) < threshold) {
return; // Success
}
rotAxis = glm::normalize(rotAxis);
365
// Check if we're close enough after this adjustment
endPos = glm::vec3(endEffector->getGlobalMatrix()[3]);
if (glm::distance(endPos, targetPosition) < threshold) {
return; // Success
}
}
}
}
1. Iteratively processes each joint from the end effector toward the root
2. For each joint, calculates the rotation needed to bring the end effector closer to the target
4. Repeats until the target is reached or the maximum iterations are exhausted
FABRIK is another popular IK algorithm that often converges faster than CCD:
// FABRIK IK solver
void solveFABRIK(
std::vector<Node*> chain, // Joint chain from root to end effector
const glm::vec3& targetPosition, // Target world position
bool constrainRoot = true, // Whether to keep the root fixed
int maxIterations = 10, // Maximum iterations
float threshold = 0.01f // Distance threshold for success
) {
if ([Link]() < 2) return;
if (i > 0) {
lengths.push_back(glm::distance(positions[i], positions[i-1]));
}
}
rootOriginalPos = positions[0];
366
for (float length : lengths) {
totalLength += length;
}
// BACKWARD PASS: Set the end effector to the target and work backwards
[Link]() = targetPosition;
367
break;
}
}
}
rotAxis = glm::normalize(rotAxis);
368
}
1. Works by alternating between forward and backward passes along the joint chain
2. In the backward pass, it positions joints working from the end effector toward the root
3. In the forward pass, it repositions joints from the root toward the end effector
[Link]. IK Constraints
// Apply constraints
eulerAngles.x = glm::clamp(eulerAngles.x, minAngles.x, maxAngles.x);
eulerAngles.y = glm::clamp(eulerAngles.y, minAngles.y, maxAngles.y);
eulerAngles.z = glm::clamp(eulerAngles.z, minAngles.z, maxAngles.z);
Now that we’ve implemented several IK algorithms, let’s see how they integrate with our animation
system. Remember that our animation system updates node transformations based on keyframes,
but sometimes we need to override or adjust these transformations based on runtime conditions.
Here’s how we can blend IK adjustments with our existing animation playback:
369
if (ikWeight <= 0.0f) return;
// Apply IK
solveTwoBoneIK(chain[0], chain[1], chain[2], targetPosition,
glm::vec3(0.0f, 0.0f, 1.0f));
• Stability: IK can produce jittery results without proper damping and constraints
• Environmental Adaptation: Making characters interact with varying terrain and objects
370
• Interactive Gameplay: Allowing precise control over character limbs for gameplay mechanics
So far, we’ve explored how to play individual animations, blend between animations, and adjust
animations with IK. But in a real game, characters often have dozens of animations that need to be
triggered based on player input and game state. How do we organize and manage all these
animations and their transitions? This is where animation state machines come in.
For complex characters, a state machine can manage transitions between animations:
class CharacterAnimator {
private:
Model* model;
AnimationState currentState = AnimationState::IDLE;
float blendTime = 0.3f;
float currentBlend = 0.0f;
struct StateAnimation {
uint32_t animationIndex;
float speed;
bool loop;
};
public:
CharacterAnimator(Model* model) : model(model) {
// Map states to animations
stateMap[AnimationState::IDLE] = {0, 1.0f, true};
stateMap[AnimationState::WALKING] = {1, 1.0f, true};
stateMap[AnimationState::RUNNING] = {2, 1.0f, true};
stateMap[AnimationState::JUMPING] = {3, 1.0f, false};
}
371
void update(float deltaTime) {
// Handle blending if needed
if (currentBlend < blendTime) {
currentBlend += deltaTime;
float t = currentBlend / blendTime;
// Implement blending logic here
} else {
// Just update current animation
StateAnimation& anim = stateMap[currentState];
model->updateAnimation([Link], deltaTime * [Link]);
}
}
};
if (headNode) {
// Apply a simple bobbing motion
float bobAmount = sin(time * 2.0f) * 0.05f;
headNode->translation.y += bobAmount;
• Animation Culling: Don’t update animations for objects outside the view frustum
372
• Keyframe Reduction: Reduce the number of keyframes in animations that don’t need high
precision
1.11. Conclusion
Our animation system provides a solid foundation for bringing 3D models to life. By leveraging the
glTF format and our scene graph structure, we can efficiently load, play, and blend animations to
create dynamic and engaging scenes.
In the next chapter, we’ll wrap up our exploration of the model loading system and discuss future
enhancements.
This approach demonstrates how the concepts learned throughout this tutorial series can be
structured into a more reusable and extensible engine architecture. By combining the engine
architecture principles, camera transformation systems, and now model loading capabilities, we’ve
created a foundation that you can build upon for your own projects.
As you continue to develop your engine, consider exploring these advanced topics:
3. Post-processing effects
4. Physics integration
5. Audio systems
The code for this chapter can be found in the simple_engine/20_loading_models.cpp file.
C++ code
373
Previous: Updating Animations | Next: Subsystems | Back to Building a Simple Engine :pp: ++
3. Implementing the Model Loading System - Creating the core data structures
Each section builds upon the previous ones, so it’s recommended to follow them in order. This
chapter also builds upon the concepts introduced in the Engine Architecture and Camera
Transformations chapters. By completing this chapter, you’ll have a comprehensive foundation for
a Vulkan-based 3D engine with a well-structured architecture, camera system, and model loading
capabilities.
Subsystems: Introduction
1. Introduction to Engine Subsystems
In previous chapters, we’ve built the foundation of our simple engine, implementing core
components like the rendering pipeline, camera systems, and model loading. Now, we’re ready to
expand our engine’s capabilities by adding two critical subsystems: Audio and Physics.
374
These subsystems are essential for creating immersive and interactive experiences in modern
games and simulations. While they may seem separate from the graphics pipeline we’ve been
focusing on, modern engines can leverage Vulkan’s computational power to enhance both audio
processing and physics simulations.
Our physics subsystem follows a similar path, beginning with essential collision detection and
response mechanisms that make objects interact believably. As we develop these foundations, we’ll
demonstrate how Vulkan’s parallel processing capabilities can accelerate physics calculations
dramatically, enabling simulations with large numbers of interacting objects that would overwhelm
traditional CPU-based approaches.
Throughout this chapter, we’ll continue our modern C++ approach from previous chapters.
Modern GPUs provide massive parallel processing power through thousands of cores designed for
simultaneous computation. Through Vulkan’s compute shaders, we can harness this computational
muscle for tasks far beyond graphics rendering. Audio processing benefits tremendously from
parallel operations—imagine processing dozens of simultaneous sound sources with real-time
spatial effects, or running complex physics simulations with thousands of interacting objects.
Vulkan’s unified memory model creates opportunities for efficiency that traditional separated
approaches cannot match. When graphics, audio, and physics processing share memory spaces, we
eliminate the costly data transfers that would otherwise shuttle information between CPU and GPU
repeatedly. This shared memory architecture enables sophisticated interactions—physics
simulations can directly influence particle systems, audio processing can respond to visual effects,
and all systems can work together seamlessly.
The performance benefits extend beyond raw computational power. Offloading intensive
calculations to the GPU frees CPU resources for game logic, scripting, AI processing, and other tasks
375
that require sequential processing or complex branching. This separation allows each processor
type to focus on tasks it handles most efficiently.
Additionally, the intention here is to offer a perspective of using Vulkan for more than just Graphics
in your application. Our goal with this tutorial isn’t to provide you a production quality game
engine. It’s to provide you with the tools necessary to tackle any Vulkan application development
and to think critically about how your applications can benefit from the GPU.
• Mobile power and thermals: Many phones and tablets use [Link] CPU clusters and mobile
GPUs that are power/thermal constrained. Sustained heavy GPU compute can quickly lead to
thermal throttling, causing frame rate drops and inconsistent latency.
• Scheduling and latency: GPUs excel at throughput, but certain tasks (tight control loops, small
pointer-heavy updates) can prefer CPU execution due to launch overheads and scheduling
latency.
Audio-specific guidance:
• Prefer platform audio APIs and any available dedicated audio hardware/DSP when feasible (for
mixing, resampling, effects). This path often provides lower latency, better power
characteristics, and more predictable behavior across devices.
• HRTF support in dedicated hardware is not widespread in the wild. Many consumer devices
rely on software HRTF in the OS or application. Evaluate your needs: a software HRTF pipeline
may be perfectly adequate; reserving GPU compute strictly for spatial audio is rarely necessary
unless you have many sources or complex effects.
Practical recommendations:
• Profile first: Establish CPU and GPU baselines before moving work.
• Favor hybrid designs: Offload the clearly parallel, heavy kernels (e.g., batched constraint solves,
FFT/IR convolution) while keeping control/coordination on CPU.
• Plan for mobility: Provide runtime toggles to switch between CPU/GPU paths based on device
class, thermal state, and power mode.
376
1.4. Prerequisites
This chapter builds extensively on the engine architecture and Vulkan foundations established in
previous chapters. The modular design patterns we’ve implemented become crucial when adding
subsystems that need to integrate cleanly with existing rendering, camera, and resource
management systems.
Experience with Vulkan compute shaders is essential, as we’ll leverage compute capabilities to
accelerate both audio processing and physics calculations. If you haven’t worked through the
compute shader sections in the main tutorial, review them before proceeding—the parallel
processing concepts and GPU memory management techniques translate directly to our subsystem
implementations.
A basic understanding of audio and physics concepts in game development will help you appreciate
the design decisions we make throughout the implementation. While we’ll explain the
fundamentals as we build each system, familiarity with concepts like sound attenuation, collision
detection, and rigid body dynamics will deepen your understanding of how these subsystems serve
the broader goals of interactive applications.
You should also be familiar with the following chapters from the main tutorial:
◦ Command buffers
◦ Graphics pipelines
• Uniform buffers
• Compute shaders
Let’s begin by exploring how to implement a basic audio subsystem and then enhance it with
Vulkan’s computational capabilities.
377
• Audio Engine: The core component that manages audio playback, mixing, and effects
processing.
• Sound Resources: Audio files loaded into memory and prepared for playback.
• Audio Channels: Logical paths for audio to flow through, often grouped by type (e.g., music,
sound effects, dialogue).
• Spatial Audio: System for positioning sounds in 3D space relative to the listener.
• Effects Processing: Application of effects like reverb, echo, or equalization to audio streams.
Let’s implement a simple audio system that covers these basics, using a modern C++ approach
consistent with our engine’s design.
// Audio.h
#pragma once
#include <string>
#include <unordered_map>
#include <memory>
#include <vector>
#include <glm/[Link]>
namespace Engine {
namespace Audio {
class AudioClip {
public:
AudioClip(const std::string& filename);
~AudioClip();
private:
std::vector<float> m_Data;
int m_ChannelCount;
int m_SampleRate;
};
class AudioSource {
public:
AudioSource();
~AudioSource();
378
void SetClip(std::shared_ptr<AudioClip> clip) { m_Clip = clip; }
void SetPosition(const glm::vec3& position) { m_Position = position; }
void SetVolume(float volume) { m_Volume = volume; }
void SetLooping(bool looping) { m_Looping = looping; }
void Play();
void Stop();
void Pause();
private:
std::shared_ptr<AudioClip> m_Clip;
glm::vec3 m_Position = glm::vec3(0.0f);
float m_Volume = 1.0f;
bool m_Looping = false;
bool m_IsPlaying = false;
class AudioListener {
public:
void SetPosition(const glm::vec3& position) { m_Position = position; }
void SetOrientation(const glm::vec3& forward, const glm::vec3& up) {
m_Forward = forward;
m_Up = up;
}
private:
glm::vec3 m_Position = glm::vec3(0.0f);
glm::vec3 m_Forward = glm::vec3(0.0f, 0.0f, -1.0f);
glm::vec3 m_Up = glm::vec3(0.0f, 1.0f, 0.0f);
};
class AudioSystem {
public:
AudioSystem();
~AudioSystem();
void Initialize();
void Shutdown();
379
// Update audio system (call once per frame)
void Update(float deltaTime);
// Resource management
std::shared_ptr<AudioClip> LoadClip(const std::string& name, const std::string&
filename);
std::shared_ptr<AudioClip> GetClip(const std::string& name);
// Source management
std::shared_ptr<AudioSource> CreateSource();
void DestroySource(std::shared_ptr<AudioSource> source);
private:
std::unordered_map<std::string, std::shared_ptr<AudioClip>> m_Clips;
std::vector<std::shared_ptr<AudioSource>> m_Sources;
AudioListener m_Listener;
} // namespace Audio
} // namespace Engine
This basic structure provides a foundation for loading and playing audio files with spatial
positioning. In a real implementation, you would integrate with an audio library like OpenAL,
FMOD, or Wwise to handle the low-level audio playback.
// Engine.h
#include "Audio.h"
namespace Engine {
class Engine {
public:
// ... existing engine code ...
private:
// ... existing engine members ...
380
Audio::AudioSystem m_AudioSystem;
};
} // namespace Engine
// [Link]
void Engine::Initialize() {
// ... existing initialization code ...
m_AudioSystem.Initialize();
}
void Engine::Shutdown() {
m_AudioSystem.Shutdown();
// Game code
void Game::LoadResources() {
// Load audio clips
auto explosionSound = m_Engine.GetAudioSystem().LoadClip("explosion",
"sounds/[Link]");
auto backgroundMusic = m_Engine.GetAudioSystem().LoadClip("music",
"sounds/[Link]");
381
// In a real implementation, you'd need to manage the lifetime of this source
}
1. Limited Spatial Audio: Basic distance-based attenuation doesn’t accurately model how sound
propagates in 3D space.
2. CPU-Intensive Processing: Effects and 3D audio calculations can consume significant CPU
resources.
In the next section, we’ll explore how Vulkan compute shaders can address these limitations by
offloading audio processing to the GPU, particularly for implementing more realistic spatial audio
through Head-Related Transfer Functions (HRTF).
382
direction of the sound source relative to the listener.
HRTF processing allows us to create convincing 3D audio by applying the appropriate filters to
sound sources based on their position. This creates a more immersive experience than simple
stereo panning and distance attenuation.
1. Each sound source requires a unique set of filters based on its position
3. The process involves complex convolutions (multiplying audio samples with filter coefficients)
This is where Vulkan compute shaders can help by offloading these calculations to the GPU.
1. Parallelism: Audio processing, especially HRTF convolution, can be highly parallelized, making
it well-suited for GPU computation.
2. Reduced CPU Load: Offloading audio processing to the GPU frees up CPU resources for game
logic, AI, and other tasks.
3. Scalability: GPU-based processing can more easily scale to handle hundreds or thousands of
simultaneous sound sources.
4. Unified Memory: With Vulkan, we can share memory between graphics and audio processing,
reducing data transfer overhead.
// Audio.h (additions)
#include <vulkan/vulkan_raii.hpp>
#include <array>
namespace Engine {
namespace Audio {
383
// HRTF database containing filters for different directions
class HRTFDatabase {
public:
HRTFDatabase(const std::string& filename);
private:
// In a real implementation, this would be a more sophisticated data structure
std::vector<HRTFData> m_Data;
// Mapping from direction to data index
// ...
};
private:
// ... existing members ...
// HRTF processing
bool m_HRTFEnabled = false;
std::shared_ptr<HRTFDatabase> m_HRTFDatabase;
384
// Descriptor sets
std::vector<vk::raii::DescriptorSet> descriptorSets;
VulkanResources m_VulkanResources;
} // namespace Audio
} // namespace Engine
// [Link] (implementation)
void AudioSystem::InitializeVulkanResources() {
// Get Vulkan device from the engine
auto& device = m_Engine.GetVulkanDevice();
385
};
*m_VulkanResources.computeShaderModule, "main");
vk::ComputePipelineCreateInfo computePipelineCreateInfo({}, shaderStageCreateInfo,
*m_VulkanResources.pipelineLayout);
m_VulkanResources.computePipeline = vk::raii::Pipeline(device, nullptr,
computePipelineCreateInfo);
386
// Update descriptor sets
std::array<vk::DescriptorBufferInfo, 3> bufferInfos = {
vk::DescriptorBufferInfo(*m_VulkanResources.inputBuffer, 0, VK_WHOLE_SIZE),
vk::DescriptorBufferInfo(*m_VulkanResources.outputBuffer, 0, VK_WHOLE_SIZE),
vk::DescriptorBufferInfo(*m_VulkanResources.hrtfBuffer, 0, VK_WHOLE_SIZE)
};
[Link](descriptorWrites, {});
vk::CommandBufferAllocateInfo
commandBufferAllocateInfo(*m_VulkanResources.commandPool,
vk::CommandBufferLevel::ePrimary, 1);
auto commandBuffers = vk::raii::CommandBuffers(device, commandBufferAllocateInfo);
m_VulkanResources.commandBuffer = std::move(commandBuffers[0]);
}
387
memcpy(data, inputBuffer, frameCount * sizeof(float));
vkUnmapMemory(device, *m_VulkanResources.inputBufferMemory);
m_VulkanResources.[Link](vk::PipelineBindPoint::eCompute,
*m_VulkanResources.computePipeline);
m_VulkanResources.[Link](vk::PipelineBindPoint::eCompute,
*m_VulkanResources.pipelineLayout, 0,
*m_VulkanResources.descriptorSets[0], {});
m_VulkanResources.[Link]();
388
// Calculate spatial position relative to listener
glm::vec3 relativePosition = source->GetPosition() -
m_Listener.GetPosition();
// hrtf_processing.comp
#version 450
389
layout(std430, binding = 0) buffer InputBuffer {
float samples[];
} inputBuffer;
// HRTF data
layout(std430, binding = 2) buffer HRTFBuffer {
float leftImpulseResponse[256];
float rightImpulseResponse[256];
} hrtfBuffer;
void main() {
uint gID = gl_GlobalInvocationID.x;
1. Batch Processing: Process multiple audio frames in a single dispatch to amortize the overhead
of command submission.
390
2. Memory Transfers: Minimize transfers between CPU and GPU memory by processing larger
chunks of audio at once.
3. Multiple Sources: Process multiple sound sources in a single shader invocation to maximize
GPU utilization.
4. Dynamic HRTF Selection: Only update HRTF filters when sound source positions change
significantly.
5. Workgroup Size: Tune the workgroup size based on your target hardware for optimal
performance.
void AudioSystem::Initialize() {
// Initialize audio backend
// ...
void AudioSystem::Shutdown() {
// Cleanup Vulkan resources
if (m_Engine.IsVulkanInitialized()) {
CleanupVulkanResources();
}
1. Quality: You can afford higher-order HRTF filters without significant performance impact,
improving spatial realism.
391
2. Advanced Effects: The GPU’s compute power enables more sophisticated effects (e.g., room
acoustics simulation) alongside HRTF.
1. Latency: GPU processing introduces additional latency, which may be problematic for real-time
audio.
2. Complexity: Implementing and debugging GPU-based audio processing is more complex than
CPU-based solutions.
3. Platform Support: Not all platforms support Vulkan, so you may need fallback CPU
implementations.
1. Steam Audio: Valve’s audio SDK supports GPU acceleration for its spatial audio processing.
2. Wwise: Audiokinetic’s Wwise can offload certain DSP effects to the GPU.
3. Custom Solutions: AAA game studios often implement custom GPU-accelerated audio
processing for their titles.
By implementing Vulkan-based HRTF processing in our engine, we’re following industry best
practices for high-performance audio in modern games.
In the next section, we’ll shift our focus to the physics subsystem and explore how Vulkan compute
shaders can accelerate physics simulations.
392
1.1. Physics System Architecture
A typical game physics system consists of several key components:
• Rigid Body Dynamics: Simulation of solid objects with mass, velocity, and rotational properties.
• Collision Response: Calculating how objects should react when they collide.
• Constraints: Limiting the movement of objects based on joints, hinges, or other connections.
• Continuous Collision Detection: Handling fast-moving objects that might pass through others
between frames.
• Spatial Partitioning: Optimizing collision detection by dividing the world into regions.
Let’s implement a simple physics system that covers these basics, using a modern C++ approach
consistent with our engine’s design.
// Physics.h
#pragma once
#include <vector>
#include <memory>
#include <unordered_map>
#include <string>
#include <glm/[Link]>
#include <glm/gtc/[Link]>
namespace Engine {
namespace Physics {
class Collider {
public:
virtual ~Collider() = default;
virtual ColliderType GetType() const = 0;
393
protected:
glm::vec3 m_Offset = glm::vec3(0.0f);
};
private:
glm::vec3 m_HalfExtents;
};
private:
float m_Radius;
};
class RigidBody {
public:
RigidBody();
~RigidBody();
// Kinematic state
void SetPosition(const glm::vec3& position) { m_Position = position; }
void SetRotation(const glm::quat& rotation) { m_Rotation = rotation; }
void SetLinearVelocity(const glm::vec3& velocity) { m_LinearVelocity = velocity; }
void SetAngularVelocity(const glm::vec3& velocity) { m_AngularVelocity = velocity;
}
// Physical properties
void SetMass(float mass);
float GetMass() const { return m_Mass; }
float GetInverseMass() const { return m_InverseMass; }
394
void SetRestitution(float restitution) { m_Restitution = restitution; }
float GetRestitution() const { return m_Restitution; }
// Collider management
void SetCollider(std::shared_ptr<Collider> collider) { m_Collider = collider; }
std::shared_ptr<Collider> GetCollider() const { return m_Collider; }
// Simulation flags
void SetKinematic(bool kinematic) { m_IsKinematic = kinematic; }
bool IsKinematic() const { return m_IsKinematic; }
private:
// Kinematic state
glm::vec3 m_Position = glm::vec3(0.0f);
glm::quat m_Rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f);
glm::vec3 m_LinearVelocity = glm::vec3(0.0f);
glm::vec3 m_AngularVelocity = glm::vec3(0.0f);
// Forces
glm::vec3 m_AccumulatedForce = glm::vec3(0.0f);
glm::vec3 m_AccumulatedTorque = glm::vec3(0.0f);
// Physical properties
float m_Mass = 1.0f;
float m_InverseMass = 1.0f;
glm::mat3 m_InertiaTensor = glm::mat3(1.0f);
glm::mat3 m_InverseInertiaTensor = glm::mat3(1.0f);
float m_Restitution = 0.5f;
float m_Friction = 0.5f;
// Collision
std::shared_ptr<Collider> m_Collider;
// Flags
bool m_IsKinematic = false;
bool m_UseGravity = true;
395
void UpdateInertiaTensor();
struct CollisionInfo {
std::shared_ptr<RigidBody> bodyA;
std::shared_ptr<RigidBody> bodyB;
glm::vec3 contactPoint;
glm::vec3 normal;
float penetrationDepth;
};
class PhysicsSystem {
public:
PhysicsSystem();
~PhysicsSystem();
void Initialize();
void Shutdown();
// RigidBody management
std::shared_ptr<RigidBody> CreateRigidBody();
void DestroyRigidBody(std::shared_ptr<RigidBody> body);
// World settings
void SetGravity(const glm::vec3& gravity) { m_Gravity = gravity; }
const glm::vec3& GetGravity() const { return m_Gravity; }
// Collision detection
bool Raycast(const glm::vec3& origin, const glm::vec3& direction, float
maxDistance, RaycastHit& hit);
private:
std::vector<std::shared_ptr<RigidBody>> m_RigidBodies;
glm::vec3 m_Gravity = glm::vec3(0.0f, -9.81f, 0.0f);
// Simulation steps
void IntegrateForces(RigidBody& body, float deltaTime);
void IntegrateVelocities(RigidBody& body, float deltaTime);
396
bool SphereVsSphere(const RigidBody& bodyA, const RigidBody& bodyB, CollisionInfo&
info);
bool BoxVsBox(const RigidBody& bodyA, const RigidBody& bodyB, CollisionInfo&
info);
bool SphereVsBox(const RigidBody& bodyA, const RigidBody& bodyB, CollisionInfo&
info);
};
struct RaycastHit {
std::shared_ptr<RigidBody> body;
glm::vec3 point;
glm::vec3 normal;
float distance;
};
} // namespace Physics
} // namespace Engine
This basic structure provides a foundation for simulating rigid body physics with collision detection
and response. In a real implementation, you would likely use a physics library like Bullet, PhysX, or
Havok for more advanced features and optimizations.
// Engine.h
#include "Physics.h"
namespace Engine {
class Engine {
public:
// ... existing engine code ...
private:
// ... existing engine members ...
Physics::PhysicsSystem m_PhysicsSystem;
};
} // namespace Engine
// [Link]
397
void Engine::Initialize() {
// ... existing initialization code ...
m_PhysicsSystem.Initialize();
}
void Engine::Shutdown() {
m_PhysicsSystem.Shutdown();
1) Accumulate forces (e.g., gravity, user forces) 2) Integrate forces (update velocities with damping)
3) Detect collisions (broad/narrow checks per pair) 4) Resolve collisions (impulses + positional
correction) 5) Integrate velocities (update positions and orientations) 6) Clear forces (prepare for
next step)
// [Link]
#include "Physics.h"
namespace Engine {
namespace Physics {
// Integrate forces
for (auto& body : m_RigidBodies) {
if (!body->IsKinematic()) {
IntegrateForces(*body, fixedTimeStep);
}
}
398
DetectCollisions(collisions);
ResolveCollisions(collisions);
// Integrate velocities
for (auto& body : m_RigidBodies) {
if (!body->IsKinematic()) {
IntegrateVelocities(*body, fixedTimeStep);
}
}
// Apply damping
const float linearDamping = 0.01f;
const float angularDamping = 0.01f;
body.m_LinearVelocity *= (1.0f - linearDamping);
body.m_AngularVelocity *= (1.0f - angularDamping);
}
// Update rotation
glm::quat angularVelocityQuat(0.0f, body.m_AngularVelocity.x,
body.m_AngularVelocity.y, body.m_AngularVelocity.z);
body.m_Rotation += (angularVelocityQuat * body.m_Rotation) * 0.5f * deltaTime;
body.m_Rotation = glm::normalize(body.m_Rotation);
}
399
// Skip if both bodies are kinematic
if (bodyA->IsKinematic() && bodyB->IsKinematic()) {
continue;
}
CollisionInfo info;
if (CheckCollision(*bodyA, *bodyB, info)) {
[Link] = bodyA;
[Link] = bodyB;
collisions.push_back(info);
}
}
}
}
// Apply impulse
glm::vec3 impulse = [Link] * j;
if (!bodyA->IsKinematic()) {
bodyA->m_LinearVelocity -= impulse * bodyA->m_InverseMass;
}
400
if (!bodyB->IsKinematic()) {
bodyB->m_LinearVelocity += impulse * bodyB->m_InverseMass;
}
if (!bodyA->IsKinematic()) {
bodyA->m_Position -= correction * bodyA->m_InverseMass;
}
if (!bodyB->IsKinematic()) {
bodyB->m_Position += correction * bodyB->m_InverseMass;
}
}
}
401
bool PhysicsSystem::SphereVsSphere(const RigidBody& bodyA, const RigidBody& bodyB,
CollisionInfo& info) {
auto sphereA = std::static_pointer_cast<SphereCollider>([Link]());
auto sphereB = std::static_pointer_cast<SphereCollider>([Link]());
// Normalize direction
direction = distance > 0.0001f ? direction / distance : glm::vec3(0, 1, 0);
return true;
}
} // namespace Physics
} // namespace Engine
// Game code
void Game::Initialize() {
// Create a ground plane
auto ground = m_Engine.GetPhysicsSystem().CreateRigidBody();
ground->SetPosition(glm::vec3(0.0f, -1.0f, 0.0f));
ground->SetKinematic(true); // Static object
auto groundCollider = std::make_shared<Physics::BoxCollider>(glm::vec3(50.0f,
1.0f, 50.0f));
ground->SetCollider(groundCollider);
402
// Create a dynamic box
auto box = m_Engine.GetPhysicsSystem().CreateRigidBody();
box->SetPosition(glm::vec3(0.0f, 5.0f, 0.0f));
box->SetMass(1.0f);
auto boxCollider = std::make_shared<Physics::BoxCollider>(glm::vec3(0.5f, 0.5f,
0.5f));
box->SetCollider(boxCollider);
403
1.6. Limitations of Basic Physics Systems
While this basic physics system provides the essential functionality for simulating rigid bodies in a
game, it has several limitations:
1. Performance: The O(n²) collision detection becomes a bottleneck with many objects.
2. Limited Collision Shapes: We’ve only implemented basic shapes like boxes and spheres.
3. Stability Issues: Simple integrators and collision resolution can lead to instability.
4. No Continuous Collision Detection: Fast-moving objects might tunnel through thin obstacles.
6. CPU-Bound Processing: All calculations are performed on the CPU, limiting scalability.
In the next section, we’ll explore how Vulkan compute shaders can address these limitations by
offloading physics calculations to the GPU, particularly for large-scale simulations with many
objects.
Previous: Vulkan for Audio Processing | Next: Vulkan for Physics Simulation :pp: ++
1. Parallelism: Physics calculations for multiple objects can be performed in parallel, making
them well-suited for GPU computation.
2. Scalability: GPU-based physics can handle thousands or even millions of objects with relatively
little performance degradation.
3. Reduced CPU Load: Offloading physics to the GPU frees up CPU resources for game logic, AI,
and other tasks.
4. Unified Memory: With Vulkan, we can share memory between physics and graphics, reducing
data transfer overhead.
5. Specialized Hardware: Modern GPUs often include hardware features specifically designed to
404
accelerate physics-like calculations.
1. Particle Systems: Simulating thousands of particles for effects like smoke, fire, or fluid.
2. Cloth Simulation: Calculating the behavior of cloth, hair, or other deformable objects.
3. Soft Body Physics: Simulating objects that can bend, stretch, or compress.
4. Broad-Phase Collision Detection: Quickly identifying potential collision pairs among many
objects.
5. Rigid Body Dynamics: Simulating the movement of large numbers of rigid bodies.
Let’s focus on implementing GPU-accelerated rigid body dynamics and collision detection using
Vulkan compute shaders.
Let’s extend our physics system to include Vulkan-accelerated components. We’ll approach it in
four steps:
// Physics.h (additions)
#include <vulkan/vulkan_raii.hpp>
namespace Engine {
namespace Physics {
405
glm::vec4 position; // xyz = position, w = inverse mass
glm::vec4 rotation; // quaternion
glm::vec4 linearVelocity; // xyz = velocity, w = restitution
glm::vec4 angularVelocity; // xyz = angular velocity, w = friction
glm::vec4 force; // xyz = force, w = is kinematic (0 or 1)
glm::vec4 torque; // xyz = torque, w = use gravity (0 or 1)
glm::vec4 colliderData; // type-specific data (e.g., radius for spheres)
glm::vec4 colliderData2; // additional collider data (e.g., box half extents)
};
// Set the maximum number of objects that can be simulated on the GPU
void SetMaxGPUObjects(uint32_t maxObjects);
private:
// ... existing members ...
// GPU acceleration
bool m_GPUAccelerationEnabled = false;
uint32_t m_MaxGPUObjects = 1024;
uint32_t m_MaxGPUCollisions = 4096;
406
vk::raii::Pipeline broadPhasePipeline = nullptr;
vk::raii::Pipeline narrowPhasePipeline = nullptr;
vk::raii::Pipeline resolvePipeline = nullptr;
VulkanResources m_VulkanResources;
} // namespace Physics
} // namespace Engine
// [Link] (implementation)
void PhysicsSystem::InitializeVulkanResources() {
// Get Vulkan device from the engine
auto& device = m_Engine.GetVulkanDevice();
407
// Create compute shader modules
auto integrateShaderCode = LoadShaderFile("shaders/physics_integrate.[Link]");
vk::ShaderModuleCreateInfo integrateShaderModuleCreateInfo({},
[Link]() * sizeof(uint32_t),
reinterpret_cast<const
uint32_t*>([Link]()));
m_VulkanResources.integrateShaderModule = vk::raii::ShaderModule(device,
integrateShaderModuleCreateInfo);
auto broadPhaseShaderCode =
LoadShaderFile("shaders/physics_broad_phase.[Link]");
vk::ShaderModuleCreateInfo broadPhaseShaderModuleCreateInfo({},
[Link]() * sizeof(uint32_t),
reinterpret_cast<const
uint32_t*>([Link]()));
m_VulkanResources.broadPhaseShaderModule = vk::raii::ShaderModule(device,
broadPhaseShaderModuleCreateInfo);
auto narrowPhaseShaderCode =
LoadShaderFile("shaders/physics_narrow_phase.[Link]");
vk::ShaderModuleCreateInfo narrowPhaseShaderModuleCreateInfo({},
[Link]() * sizeof(uint32_t),
reinterpret_cast<const
uint32_t*>([Link]()));
m_VulkanResources.narrowPhaseShaderModule = vk::raii::ShaderModule(device,
narrowPhaseShaderModuleCreateInfo);
408
vk::DescriptorSetLayoutCreateInfo descriptorSetLayoutCreateInfo({}, bindings);
m_VulkanResources.descriptorSetLayout = vk::raii::DescriptorSetLayout(device,
descriptorSetLayoutCreateInfo);
*m_VulkanResources.integrateShaderModule, "main");
vk::ComputePipelineCreateInfo integrateComputePipelineCreateInfo({},
integrateShaderStageCreateInfo,
*m_VulkanResources.pipelineLayout);
m_VulkanResources.integratePipeline = vk::raii::Pipeline(device, nullptr,
integrateComputePipelineCreateInfo);
vk::PipelineShaderStageCreateInfo broadPhaseShaderStageCreateInfo({},
vk::ShaderStageFlagBits::eCompute,
*m_VulkanResources.broadPhaseShaderModule, "main");
vk::ComputePipelineCreateInfo broadPhaseComputePipelineCreateInfo({},
broadPhaseShaderStageCreateInfo,
*m_VulkanResources.pipelineLayout);
m_VulkanResources.broadPhasePipeline = vk::raii::Pipeline(device, nullptr,
broadPhaseComputePipelineCreateInfo);
vk::PipelineShaderStageCreateInfo narrowPhaseShaderStageCreateInfo({},
vk::ShaderStageFlagBits::eCompute,
*m_VulkanResources.narrowPhaseShaderModule, "main");
vk::ComputePipelineCreateInfo narrowPhaseComputePipelineCreateInfo({},
narrowPhaseShaderStageCreateInfo,
*m_VulkanResources.pipelineLayout);
m_VulkanResources.narrowPhasePipeline = vk::raii::Pipeline(device, nullptr,
narrowPhaseComputePipelineCreateInfo);
vk::PipelineShaderStageCreateInfo resolveShaderStageCreateInfo({},
vk::ShaderStageFlagBits::eCompute,
*m_VulkanResources.resolveShaderModule, "main");
vk::ComputePipelineCreateInfo resolveComputePipelineCreateInfo({},
resolveShaderStageCreateInfo,
409
*m_VulkanResources.pipelineLayout);
m_VulkanResources.resolvePipeline = vk::raii::Pipeline(device, nullptr,
resolveComputePipelineCreateInfo);
CreateBuffer(device, sizeof(uint32_t) * 2,
vk::BufferUsageFlagBits::eStorageBuffer,
m_VulkanResources.counterBuffer,
m_VulkanResources.counterBufferMemory);
410
vk::DescriptorType::eStorageBuffer, nullptr,
&bufferInfos[0]),
vk::WriteDescriptorSet(*m_VulkanResources.descriptorSets[0], 1, 0, 1,
vk::DescriptorType::eStorageBuffer, nullptr,
&bufferInfos[1]),
vk::WriteDescriptorSet(*m_VulkanResources.descriptorSets[0], 2, 0, 1,
vk::DescriptorType::eStorageBuffer, nullptr,
&bufferInfos[2]),
vk::WriteDescriptorSet(*m_VulkanResources.descriptorSets[0], 3, 0, 1,
vk::DescriptorType::eStorageBuffer, nullptr,
&bufferInfos[3])
};
[Link](descriptorWrites, {});
vk::CommandBufferAllocateInfo
commandBufferAllocateInfo(*m_VulkanResources.commandPool,
vk::CommandBufferLevel::ePrimary, 1);
auto commandBuffers = vk::raii::CommandBuffers(device, commandBufferAllocateInfo);
m_VulkanResources.commandBuffer = std::move(commandBuffers[0]);
void PhysicsSystem::UpdateGPUPhysicsData() {
auto& device = m_Engine.GetVulkanDevice();
411
gpuData[i].rotation = glm::vec4(body->GetRotation().x, body->GetRotation().y,
body->GetRotation().z, body->GetRotation().w);
gpuData[i].linearVelocity = glm::vec4(body->GetLinearVelocity(), body-
>GetRestitution());
gpuData[i].angularVelocity = glm::vec4(body->GetAngularVelocity(), body-
>GetFriction());
gpuData[i].force = glm::vec4(body->m_AccumulatedForce, body->IsKinematic() ?
1.0f : 0.0f);
gpuData[i].torque = glm::vec4(body->m_AccumulatedTorque, body-
>IsGravityEnabled() ? 1.0f : 0.0f);
static_cast<float>(ColliderType::Sphere));
gpuData[i].colliderData2 = glm::vec4(collider->GetOffset(), 0.0f);
break;
}
case ColliderType::Box: {
auto boxCollider =
std::static_pointer_cast<BoxCollider>(collider);
gpuData[i].colliderData = glm::vec4(boxCollider->GetHalfExtents(),
static_cast<float>(ColliderType::Box));
gpuData[i].colliderData2 = glm::vec4(collider->GetOffset(), 0.0f);
break;
}
default:
// Unsupported collider type
gpuData[i].colliderData = glm::vec4(0.0f, 0.0f, 0.0f, -1.0f);
gpuData[i].colliderData2 = glm::vec4(0.0f);
break;
}
} else {
// No collider
gpuData[i].colliderData = glm::vec4(0.0f, 0.0f, 0.0f, -1.0f);
gpuData[i].colliderData2 = glm::vec4(0.0f);
}
}
vkUnmapMemory(device, *m_VulkanResources.physicsBufferMemory);
// Reset counters
uint32_t initialCounters[2] = { 0, 0 }; // [0] = pair count, [1] = collision count
412
vkMapMemory(device, *m_VulkanResources.counterBufferMemory, 0,
sizeof(initialCounters), 0, &data);
memcpy(data, initialCounters, sizeof(initialCounters));
vkUnmapMemory(device, *m_VulkanResources.counterBufferMemory);
}
void PhysicsSystem::ReadbackGPUPhysicsData() {
auto& device = m_Engine.GetVulkanDevice();
body->SetPosition(glm::vec3(gpuData[i].position));
body->SetRotation(glm::quat(gpuData[i].rotation.w, gpuData[i].rotation.x,
gpuData[i].rotation.y, gpuData[i].rotation.z));
body->SetLinearVelocity(glm::vec3(gpuData[i].linearVelocity));
body->SetAngularVelocity(glm::vec3(gpuData[i].angularVelocity));
}
vkUnmapMemory(device, *m_VulkanResources.physicsBufferMemory);
}
m_VulkanResources.[Link](vk::PipelineBindPoint::eCompute,
*m_VulkanResources.pipelineLayout, 0,
413
*m_VulkanResources.descriptorSets[0], {});
[Link] = deltaTime;
[Link][0] = m_Gravity.x;
[Link][1] = m_Gravity.y;
[Link][2] = m_Gravity.z;
[Link] = static_cast<uint32_t>(m_RigidBodies.size());
m_VulkanResources.[Link](*m_VulkanResources.pipelineLayout,
vk::ShaderStageFlagBits::eCompute, 0,
sizeof(pushConstants),
&pushConstants);
m_VulkanResources.[Link](vk::PipelineStageFlagBits::eComputeSha
der,
vk::PipelineStageFlagBits::eComputeShader,
{}, memoryBarrier, {}, {});
m_VulkanResources.[Link](vk::PipelineStageFlagBits::eComputeSha
der,
vk::PipelineStageFlagBits::eComputeShader,
{}, memoryBarrier, {}, {});
414
// Step 3: Narrow-phase collision detection
m_VulkanResources.[Link](vk::PipelineBindPoint::eCompute,
*m_VulkanResources.narrowPhasePipeline);
// We don't know how many pairs were generated, so we use a conservative estimate
m_VulkanResources.[Link]((m_MaxGPUCollisions + 63) / 64, 1, 1);
m_VulkanResources.[Link](vk::PipelineStageFlagBits::eComputeSha
der,
vk::PipelineStageFlagBits::eComputeShader,
{}, memoryBarrier, {}, {});
m_VulkanResources.[Link]();
// physics_integrate.comp
415
#version 450
// Push constants
layout(push_constant) uniform PushConstants {
float deltaTime;
vec3 gravity;
uint numBodies;
} pushConstants;
// Physics data
struct PhysicsData {
vec4 position; // xyz = position, w = inverse mass
vec4 rotation; // quaternion
vec4 linearVelocity; // xyz = velocity, w = restitution
vec4 angularVelocity; // xyz = angular velocity, w = friction
vec4 force; // xyz = force, w = is kinematic (0 or 1)
vec4 torque; // xyz = torque, w = use gravity (0 or 1)
vec4 colliderData; // type-specific data (e.g., radius for spheres)
vec4 colliderData2; // additional collider data (e.g., box half extents)
};
// Quaternion multiplication
vec4 quatMul(vec4 q1, vec4 q2) {
return vec4(
q1.w * q2.x + q1.x * q2.w + q1.y * q2.z - q1.z * q2.y,
q1.w * q2.y - q1.x * q2.z + q1.y * q2.w + q1.z * q2.x,
q1.w * q2.z + q1.x * q2.y - q1.y * q2.x + q1.z * q2.w,
q1.w * q2.w - q1.x * q2.x - q1.y * q2.y - q1.z * q2.z
);
}
// Quaternion normalization
vec4 quatNormalize(vec4 q) {
float len = length(q);
if (len > 0.0001) {
return q / len;
}
return vec4(0, 0, 0, 1);
}
void main() {
uint gID = gl_GlobalInvocationID.x;
416
return;
}
// Integrate forces
[Link] += [Link] * [Link].w *
[Link];
[Link] += [Link] * [Link]; //
Simplified, should use inertia tensor
// Apply damping
const float linearDamping = 0.01;
const float angularDamping = 0.01;
[Link] *= (1.0 - linearDamping);
[Link] *= (1.0 - angularDamping);
// Integrate velocities
[Link] += [Link] * [Link];
// Update rotation
vec4 angularVelocityQuat = vec4([Link] * 0.5, 0.0);
vec4 rotationDelta = quatMul(angularVelocityQuat, [Link]);
[Link] = quatNormalize([Link] + rotationDelta *
[Link]);
// physics_broad_phase.comp
#version 450
// Push constants
layout(push_constant) uniform PushConstants {
float deltaTime;
vec3 gravity;
417
uint numBodies;
} pushConstants;
// Physics data
struct PhysicsData {
vec4 position; // xyz = position, w = inverse mass
vec4 rotation; // quaternion
vec4 linearVelocity; // xyz = velocity, w = restitution
vec4 angularVelocity; // xyz = angular velocity, w = friction
vec4 force; // xyz = force, w = is kinematic (0 or 1)
vec4 torque; // xyz = torque, w = use gravity (0 or 1)
vec4 colliderData; // type-specific data (e.g., radius for spheres)
vec4 colliderData2; // additional collider data (e.g., box half extents)
};
// Counter buffer
layout(std430, binding = 3) buffer CounterBuffer {
uint pairCount;
uint collisionCount;
} counterBuffer;
if (colliderType == 0) { // Sphere
float radius = [Link].x;
vec3 center = [Link] + [Link];
min = center - vec3(radius);
max = center + vec3(radius);
}
else if (colliderType == 1) { // Box
vec3 halfExtents = [Link];
vec3 center = [Link] + [Link];
// This is simplified - should account for rotation
min = center - halfExtents;
max = center + halfExtents;
418
}
}
void main() {
uint gID = gl_GlobalInvocationID.x;
// Ensure j > i
j += i + 1;
// Compute AABBs
vec3 minA, maxA, minB, maxB;
computeAABB(bodyA, minA, maxA);
computeAABB(bodyB, minB, maxB);
419
// Add to potential collision pairs
uint pairIndex = atomicAdd([Link], 1);
[Link][pairIndex] = uvec2(i, j);
}
}
The narrow-phase and resolve shaders would follow a similar pattern, implementing the detailed
collision detection and resolution algorithms.
1. Batch Processing: Process multiple physics steps in a single dispatch to amortize the overhead
of command submission.
2. Memory Transfers: Minimize transfers between CPU and GPU memory by keeping physics data
on the GPU when possible.
3. Spatial Partitioning: Implement grid or tree-based spatial partitioning to reduce the number of
potential collision pairs.
4. Workgroup Size: Tune the workgroup size based on your target hardware for optimal
performance.
5. Memory Layout: Organize physics data for optimal cache coherency on the GPU.
void PhysicsSystem::Initialize() {
// Initialize basic physics system
// ...
void PhysicsSystem::Shutdown() {
// Cleanup Vulkan resources
if (m_Engine.IsVulkanInitialized()) {
CleanupVulkanResources();
}
420
// Shutdown basic physics system
// ...
}
1. Scalability: The GPU can simulate thousands or even millions of objects in parallel.
3. CPU Offloading: Physics processing no longer competes with game logic for CPU resources.
4. Advanced Simulations: The GPU’s computational power enables more complex physics
simulations like fluid dynamics or cloth.
1. Complexity: Implementing and debugging GPU-based physics is more complex than CPU-based
solutions.
2. Precision: GPUs typically use single-precision floating-point, which may lead to numerical
stability issues in some simulations.
3. Platform Support: Not all platforms support Vulkan, so you may need fallback CPU
implementations.
4. Synchronization: Keeping CPU and GPU physics data in sync can be challenging and may
introduce latency.
3. Flex: NVIDIA’s particle-based physics solver designed specifically for GPU acceleration.
4. Custom Solutions: AAA game studios often implement custom GPU-accelerated physics for
their titles.
By implementing Vulkan-based physics in our engine, we’re following industry best practices for
high-performance physics in modern games.
421
1.10. Conclusion
In this chapter, we’ve explored how Vulkan compute shaders can be used to accelerate both audio
and physics processing in a game engine. By leveraging the GPU’s massive parallel processing
capabilities, we can create more immersive and dynamic game worlds with realistic audio and
physics simulations.
The techniques we’ve covered demonstrate the versatility of Vulkan beyond traditional graphics
rendering. As you continue to develop your engine, consider other areas where GPU acceleration
might provide benefits, such as AI pathfinding, procedural generation, or particle systems.
Subsystems: Conclusion
1. Conclusion
In this chapter, we’ve explored how to implement and enhance two critical engine
subsystems—Audio and Physics—using Vulkan’s compute capabilities. Let’s summarize what we’ve
learned and discuss potential future directions.
We started by implementing a basic audio system that provides the foundation for sound playback
in our engine. This system includes:
We then enhanced this basic system with Vulkan compute shaders to implement Head-Related
Transfer Function (HRTF) processing for more realistic 3D audio. This approach demonstrated:
• Methods for sharing data efficiently between CPU and GPU audio processing
Similarly, we implemented a basic physics system that provides rigid body dynamics and collision
detection. This system includes:
422
• Various collider types for different geometric shapes
• Integration with the rest of our engine for visual representation of physics objects
We then enhanced this system with Vulkan compute shaders to accelerate physics calculations,
demonstrating:
• Advanced HRTF Models: Implement more sophisticated HRTF models that account for
individual differences in head and ear shapes.
• Environmental Effects: Add reverb, occlusion, and other environmental effects based on scene
geometry.
• Streaming Audio: Implement streaming for large audio files to reduce memory usage.
• Compression: Add support for compressed audio formats to reduce memory and bandwidth
requirements.
• Advanced Collision Shapes: Add support for more complex collision shapes like convex hulls
and trimeshes.
• Constraints and Joints: Implement various types of constraints and joints for more complex
mechanical systems.
• Continuous Collision Detection: Add support for detecting collisions between fast-moving
objects.
423
• Soft Body Physics: Extend the system to support deformable objects like cloth, ropes, and soft
bodies.
• Fluid Simulation: Implement fluid dynamics for realistic water, smoke, and fire effects.
• Driver and Platform Coverage: Test the subsystems across a representative set of Vulkan-
capable platforms and drivers (e.g., Windows/Linux, major IHVs, Android, and macOS via
MoltenVK). Non-Vulkan fallbacks are out of scope for this tutorial.
• Profiling and Optimization: Add detailed profiling to identify and address performance
bottlenecks.
• Memory Management: Use allocator suballocation strategies (e.g., Vulkan Memory Allocator or
custom pools), batch buffer/image allocations, and group resources by usage to reduce
fragmentation and improve cache locality.
• Rendering System: Visualize physics debug information, audio sources, and listener positions.
• Animation System: Synchronize animations with audio events and physics interactions.
• Scripting System: Provide high-level interfaces for controlling audio and physics from game
scripts.
• Networking: Implement efficient synchronization of audio and physics state across networked
clients.
• Performance Profiling: Regularly profile your audio and physics systems to ensure they’re not
becoming bottlenecks.
• Memory Usage: Monitor memory usage, especially for large numbers of audio sources or
physics objects.
• Power Consumption: Be mindful of power usage, especially on mobile devices where GPU
compute can drain batteries quickly.
424
1.5. Final Thoughts
Audio and physics are essential components that contribute significantly to the immersion and
interactivity of modern games. By leveraging Vulkan’s compute capabilities, we can create more
sophisticated and performant implementations of these subsystems, enabling richer and more
dynamic game experiences.
The techniques we’ve explored in this chapter demonstrate the versatility of Vulkan beyond
traditional graphics rendering. As you continue to develop your engine, consider other areas where
GPU acceleration might provide benefits, such as AI pathfinding, procedural generation, or particle
systems.
Remember that the implementations provided here are starting points. Real-world engines often
require customization and optimization based on the specific needs of your games and target
platforms. Don’t hesitate to experiment and extend these systems to meet your unique
requirements.
Previous: Vulkan for Physics Simulation | Next: Tooling | Back to Building a Simple Engine :pp: ++
Subsystems
This chapter covers the implementation of critical engine subsystems - Audio and Physics - with a
focus on leveraging Vulkan’s compute capabilities for enhanced performance.
• Introduction
• Audio Basics
• Physics Basics
• Conclusion
425
Tooling: Introduction
1. Introduction to Engine Tooling
In previous chapters, we’ve built the foundation of our simple engine, implementing core
components like the rendering pipeline, camera systems, model loading, and essential subsystems
like audio and physics. Now, we’re ready to explore the tooling ecosystem that supports the
development, debugging, and distribution of a professional Vulkan application.
Effective tooling is critical for maintaining productivity, ensuring quality, and delivering a robust
final product. While these tools may seem separate from the engine itself, they are integral to the
development process and can significantly impact the quality and maintainability of your code.
Debugging Vulkan applications presents unique challenges that traditional debugging approaches
can’t address effectively. We’ll master both Vulkan’s built-in debugging extensions like
VK_KHR_debug_utils and external tools like RenderDoc, creating a comprehensive debugging
workflow that can diagnose everything from validation layer warnings to complex rendering
pipeline issues.
Robust crash handling becomes crucial as your application moves toward production deployment.
We’ll implement systems that can gracefully handle unexpected failures, generate detailed
minidumps for post-mortem analysis, and provide users with meaningful recovery options rather
than abrupt terminations.
Finally, we’ll explore Vulkan extensions designed specifically for application robustness, such as
VK_EXT_robustness2, which help your application handle edge cases and undefined behavior
gracefully. These extensions transform potential crashes into recoverable situations, improving the
overall user experience.
1.2. Prerequisites
This chapter assumes solid understanding of the Vulkan fundamentals and engine architecture
we’ve built throughout the previous chapters. The tooling we’ll implement needs to integrate with
your existing systems—CI/CD pipelines must understand your project structure, debugging tools
must work with your rendering pipeline, and crash handling must respect your engine’s resource
management patterns.
Experience with modern C concepts becomes particularly important here, as professional tooling
426
often leverages advanced language features for reliability and maintainability. C17 and C++20
features like structured bindings, concepts, and coroutines appear frequently in production tooling
code, and understanding these patterns will help you implement robust solutions.
A basic familiarity with software development workflows and tools will provide context for the
systems we’ll build. While we’ll explain the specific implementations, understanding why
continuous integration matters, how debugging fits into development cycles, and why crash
reporting improves user experience will help you appreciate the architectural decisions we make
throughout this chapter.
You should also be familiar with the following chapters from the main tutorial:
◦ Command buffers
◦ Graphics pipelines
• Uniform buffers
Let’s begin by exploring how to set up a CI/CD pipeline for Vulkan projects.
A typical CI/CD pipeline for a Vulkan project might include these stages:
427
3. Package: Create distributable packages for each platform
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build:
runs-on: ${{ [Link] }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
build_type: [Debug, Release]
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Build
run: cmake --build ${{[Link]}}/build --config ${{matrix.build_type}}
- name: Test
working-directory: ${{[Link]}}/build
run: ctest -C ${{matrix.build_type}}
- name: Package
if: matrix.build_type == 'Release'
run: |
# Platform-specific packaging commands
if [ "${{ [Link] }}" == "ubuntu-latest" ]; then
# Linux packaging (e.g., .deb or .AppImage)
echo "Packaging for Linux"
428
elif [ "${{ [Link] }}" == "windows-latest" ]; then
# Windows packaging (e.g., .exe installer)
echo "Packaging for Windows"
elif [ "${{ [Link] }}" == "macos-latest" ]; then
# macOS packaging (e.g., .app bundle or .dmg)
echo "Packaging for macOS"
fi
When setting up CI/CD for Vulkan projects, consider these specific challenges:
Ensure your CI environment has the Vulkan SDK installed. Many CI platforms don’t include it by
default. In the example above, we used a GitHub Action to install the SDK.
Most CI environments don’t have GPUs available, which can make testing Vulkan applications
challenging. Consider these approaches:
Different platforms handle Vulkan loading differently. Ensure your build system correctly handles
these differences:
Shader compilation can be a complex part of the build process. Consider these approaches:
429
1.2.1. Unit Testing Vulkan Code
import std;
import vulkan_raii;
// In a test file
TEST_CASE("Pipeline creation") {
// Setup test environment with mock or real Vulkan objects
vk::raii::Context context;
auto instance = create_test_instance(context);
auto device = create_test_device(instance);
auto render_pass = create_test_render_pass(device);
auto layout = create_test_pipeline_layout(device);
vk::raii::Pipeline pipeline{nullptr};
REQUIRE(create_pipeline(device, render_pass, layout, pipeline));
REQUIRE(pipeline);
}
For integration testing, consider creating a headless rendering mode that can run in CI
environments:
import std;
import vulkan_raii;
class HeadlessRenderer {
public:
HeadlessRenderer() {
// Initialize Vulkan without surface
init_vulkan();
}
bool render_frame() {
430
// Render to an image without presenting
try {
// Rendering code
return true;
} catch (vk::SystemError& err) {
std::cerr << "Render failed: " << [Link]() << std::endl;
return false;
}
}
private:
void init_vulkan() {
// Vulkan initialization code
}
vk::raii::Context context;
vk::raii::Instance instance{nullptr};
vk::raii::PhysicalDevice physical_device{nullptr};
vk::raii::Device device{nullptr};
// Other Vulkan objects
};
// In a test file
TEST_CASE("Render output matches reference") {
HeadlessRenderer renderer;
REQUIRE(renderer.render_frame());
REQUIRE(renderer.verify_output("reference_image.png"));
}
431
◦ macOS: DMG or App Store packages
• Application version
1.4. Conclusion
A well-designed CI/CD pipeline is essential for maintaining quality and productivity when
developing Vulkan applications. By automating building, testing, and packaging, you can focus
more on developing features and less on manual processes.
In the next section, we’ll explore debugging tools for Vulkan applications, including the powerful
VK_KHR_debug_utils extension and external tools like RenderDoc.
2. Using external tools like RenderDoc for frame capture and analysis
432
1.1. Using VK_KHR_debug_utils
The VK_KHR_debug_utils extension provides a comprehensive set of tools for debugging Vulkan
applications. It allows you to:
Let’s explore how to use these features with C++20 modules and vk::raii.
import std;
import vulkan_raii;
433
std::cerr << "[" << severity << ": " << type << "] "
<< callback_data->pMessage << std::endl;
One of the most useful features of VK_KHR_debug_utils is the ability to give meaningful names to
Vulkan objects. This makes debugging much easier, as you can identify objects in validation layer
messages and tools like RenderDoc:
[Link](name_info);
}
// Example usage
void name_vulkan_objects(vk::raii::Device& device) {
// Name the device itself
set_object_name(device, *device, "Main Device");
434
// Name a buffer
vk::BufferCreateInfo buffer_info{};
// ... set buffer creation parameters
vk::raii::Buffer buffer(device, buffer_info);
set_object_name(device, *buffer, "Vertex Buffer");
// Name a pipeline
vk::raii::Pipeline pipeline = create_graphics_pipeline(device);
set_object_name(device, *pipeline, "Main Render Pipeline");
}
You can also label regions of command buffer execution, which helps identify where issues occur
during rendering:
435
// End the labeled region
cmd_buffer.endDebugUtilsLabelEXT();
cmd_buffer.end();
}
You can integrate RenderDoc directly into your application using its in-application API:
import std;
import vulkan_raii;
#include <renderdoc_app.h>
436
bool load_renderdoc_api() {
#if defined(_WIN32)
HMODULE renderdoc_module = LoadLibraryA("[Link]");
#else
void* renderdoc_module = dlopen("[Link]", RTLD_NOW | RTLD_NOLOAD);
#endif
if (!renderdoc_module) {
std::cerr << "RenderDoc not loaded in this application" << std::endl;
return false;
}
#if defined(_WIN32)
pRENDERDOC_GetAPI get_api = (pRENDERDOC_GetAPI)GetProcAddress(renderdoc_module,
"RENDERDOC_GetAPI");
#else
pRENDERDOC_GetAPI get_api = (pRENDERDOC_GetAPI)dlsym(renderdoc_module,
"RENDERDOC_GetAPI");
#endif
if (!get_api) {
std::cerr << "Failed to get RenderDoc API function" << std::endl;
return false;
}
// Trigger a capture
void capture_frame() {
if (renderdoc_api) {
renderdoc_api->TriggerCapture();
}
}
Once you’ve captured a frame, you can analyze it in the RenderDoc application. Here are some key
features to look for:
1. Pipeline State: Examine the full graphics pipeline state for each draw call
2. Resource Inspection: View the contents of buffers, textures, and other resources
437
3. Shader Debugging: Step through shader execution for specific pixels
1. Use Object Names: As discussed earlier, naming your Vulkan objects makes them much easier
to identify in RenderDoc (you’ll see them in the Resource Inspector and Pipeline State views).
2. Use Command Buffer Labels: These appear in RenderDoc’s Event Browser and help you
navigate to the relevant draw/dispatch quickly.
3. Capture the Problem Frame: Trigger a capture exactly when the issue occurs (via hotkey or
the in-application API) to minimize unrelated events and noise.
4. Minimize to a Repro: Create a minimal reproducible scene or toggle features off to isolate the
problem. If you reduce resolution, make sure it doesn’t alter ordering/timing in a way that hides
the bug.
import std;
import vulkan_raii;
class DebugManager {
public:
DebugManager() {
// Try to load RenderDoc API
load_renderdoc_api();
}
438
for (uint32_t i = 0; i < create_info.enabledExtensionCount; i++) {
extensions.push_back(create_info.ppEnabledExtensionNames[i]);
}
}
create_info.setPEnabledExtensionNames(extensions);
template<typename T>
void set_name(vk::raii::Device& device, T handle, const std::string& name) {
try {
vk::DebugUtilsObjectNameInfoEXT name_info{};
name_info.setObjectType(get_object_type<T>());
name_info.setObjectHandle(reinterpret_cast<uint64_t>(static_cast<T>(handle)));
name_info.setPObjectName(name.c_str());
[Link](name_info);
} catch (vk::SystemError& err) {
std::cerr << "Failed to set object name: " << [Link]() << std::endl;
}
}
void capture_next_frame() {
if (renderdoc_api) {
renderdoc_api->TriggerCapture();
}
}
439
private:
vk::DebugUtilsMessengerCreateInfoEXT debug_create_info{};
vk::raii::DebugUtilsMessengerEXT debug_messenger{nullptr};
RENDERDOC_API_1_4_1* renderdoc_api = nullptr;
};
1.4. Conclusion
Effective debugging is essential for developing complex Vulkan applications. By combining the
power of VK_KHR_debug_utils for in-application debugging and RenderDoc for frame capture and
analysis, you can quickly identify and fix issues in your rendering pipeline.
In the next section, we’ll explore crash handling and minidumps, which are crucial for diagnosing
issues that occur in production environments.
Previous: CI/CD for Vulkan Projects | Next: Crash Handling and Minidumps :pp: ++
1. API Usage Errors: Incorrect use of the Vulkan API that validation layers would catch in debug
builds
2. Driver Bugs: Issues in the GPU driver that may only manifest with specific hardware or
workloads
3. Resource Management Issues: Memory leaks, double frees, or accessing destroyed resources
4. Shader Errors: Runtime errors in shaders that cause the GPU to hang
Let’s explore how to handle these crashes and gather diagnostic information.
440
1.2. Implementing Basic Crash Handling
First, let’s implement a basic crash handler that can catch unhandled exceptions and segmentation
faults:
import std;
import vulkan_raii;
// Log OS information
#if defined(_WIN32)
log << "OS: Windows" << std::endl;
#elif defined(__linux__)
log << "OS: Linux" << std::endl;
#elif defined(__APPLE__)
log << "OS: macOS" << std::endl;
#else
log << "OS: Unknown" << std::endl;
#endif
441
void log_vulkan_info(std::ofstream& log, vk::raii::PhysicalDevice* physical_device
= nullptr) {
if (physical_device) {
auto properties = physical_device->getProperties();
log << "GPU: " << [Link] << std::endl;
log << "Driver Version: " << [Link] << std::endl;
log << "Vulkan API Version: "
<< VK_VERSION_MAJOR([Link]) << "."
<< VK_VERSION_MINOR([Link]) << "."
<< VK_VERSION_PATCH([Link]) << std::endl;
} else {
log << "No Vulkan physical device information available" << std::endl;
}
}
[Link]();
} catch (...) {
// Last resort if we can't even write to the log
std::cerr << "Failed to write crash log" << std::endl;
}
}
442
log << "==== End of Crash Report ====" << std::endl << std::endl;
[Link]();
} catch (...) {
// Last resort if we can't even write to the log
std::cerr << "Failed to write crash log" << std::endl;
}
app_name = application_name;
crash_log_path = log_path;
initialized = true;
}
}
// Initialize Vulkan
vk::raii::Context context;
auto instance = create_instance(context);
auto physical_device = select_physical_device(instance);
auto device = create_device(physical_device);
443
}
}
} catch (const std::exception& e) {
// Handle unrecoverable exceptions
crash_handler::handle_exception(e);
return 1;
}
return 0;
}
Overview:
• Collects GPU crash dumps with information about the last executed draw/dispatch, bound
pipeline/shaders, markers, and resource identifiers.
• Works alongside your Vulkan app; you analyze dumps with NVIDIA tools to pinpoint the failing
work and shader.
Practical steps:
◦ Use VK_EXT_debug_utils to name pipelines, shaders, images, buffers, and to insert command
buffer labels for major passes and draw/dispatch groups. These names surface in crash
reports and greatly aid triage.
◦ Insert meaningful labels before/after critical rendering phases. If available on your target,
also use vendor checkpoint/marker extensions (e.g., VK_NV_device_diagnostic_checkpoints)
to provide fine-grained breadcrumbs.
◦ Ensure each pipeline/shader can be correlated (e.g., include a stable hash/UUID in your
pipeline cache and application logs). Keep the mapping from IDs to source for analysis.
◦ Integrate the Nsight Aftermath Vulkan SDK per NVIDIA’s documentation. Register a callback
to receive crash dump data, write it to disk, and include your marker string table for
symbolication.
444
◦ On VK_ERROR_DEVICE_LOST (or Windows TDR), flush any in-memory marker logs, persist
the crash dump, and then terminate cleanly. Attempting to continue rendering is undefined.
• AMD provides tools to capture and analyze GPU crash information on RDNA hardware. Similar
principles apply: enable object names, label command buffers, and preserve pipeline/shader
identifiers so RGD can point back to your content.
• See AMD Radeon GPU Detective and related documentation for Vulkan integration and analysis
workflows.
• Insert command buffer labels at meaningful boundaries (frame, pass, material batch, etc.).
• Persist build/version, driver, Vulkan API/UUID, and pipeline cache UUID in your logs and crash
artifacts.
• Implement robust device lost handling: stop submitting, free/teardown safely, write artifacts,
exit.
Below is a brief outline for generating minidumps with platform APIs (useful for correlating CPU
context with a GPU crash):
import std;
import vulkan_raii;
namespace crash_handler {
std::string app_name;
std::string dump_path;
bool initialized = false;
#if defined(_WIN32)
// Windows implementation using Windows Error Reporting (WER)
LONG WINAPI windows_exception_handler(EXCEPTION_POINTERS* exception_pointers) {
// Create a unique filename for the minidump
std::string filename = dump_path + "\\" + app_name + "_" +
std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + ".dmp";
445
// Create the minidump file
HANDLE file = CreateFileA(
filename.c_str(),
GENERIC_WRITE,
0,
nullptr,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
nullptr
);
if (file != INVALID_HANDLE_VALUE) {
// Initialize minidump info
MINIDUMP_EXCEPTION_INFORMATION exception_info;
exception_info.ThreadId = GetCurrentThreadId();
exception_info.ExceptionPointers = exception_pointers;
exception_info.ClientPointers = FALSE;
CloseHandle(file);
std::cerr << "Minidump written to: " << filename << std::endl;
} else {
std::cerr << "Failed to create minidump file" << std::endl;
}
app_name = application_name;
dump_path = minidump_path;
446
SetUnhandledExceptionFilter(windows_exception_handler);
initialized = true;
}
#elif defined(__linux__)
// Linux implementation using Google Breakpad
// Note: This requires linking against the Google Breakpad library
#include "client/linux/handler/exception_handler.h"
app_name = application_name;
dump_path = minidump_path;
initialized = true;
}
#elif defined(__APPLE__)
// macOS implementation using Google Breakpad
// Similar to Linux implementation
#endif
}
447
1.5. Analyzing Minidumps
Minidumps are best used to understand CPU-side state around a crash (e.g., which thread faulted,
call stacks leading to vkQueueSubmit/vkQueuePresent, allocator misuse) and to correlate with a
GPU crash dump from vendor tools. Here’s a brief workflow on different platforms:
1.5.1. Windows
1. Visual Studio:
◦ Go to File > Open > File and select the .dmp file
◦ Visual Studio will load the minidump and show the call stack at the time of the crash
2. WinDbg:
◦ Open WinDbg
On Linux and macOS, you can use tools like GDB or LLDB to analyze minidumps generated by
Google Breakpad:
448
// Log memory usage
auto memory_properties = physical_device.getMemoryProperties();
log << "Memory Heaps:" << std::endl;
for (uint32_t i = 0; i < memory_properties.memoryHeapCount; i++) {
log << " Heap " << i << ": "
<< (memory_properties.memoryHeaps[i].size / (1024 * 1024)) << " MB";
if (memory_properties.memoryHeaps[i].flags &
vk::MemoryHeapFlagBits::eDeviceLocal) {
log << " (Device Local)";
}
log << std::endl;
}
import std;
import vulkan_raii;
#include <curl/curl.h>
namespace crash_handler {
// ... existing code ...
std::string telemetry_url;
bool telemetry_enabled = false;
449
if (!telemetry_enabled || telemetry_url.empty()) {
return false;
}
// Clean up
curl_mime_free(form);
curl_easy_cleanup(curl);
if (res != CURLE_OK) {
std::cerr << "Failed to upload minidump: " << curl_easy_strerror(res) <<
std::endl;
return false;
}
return true;
}
// Enable telemetry
void enable_telemetry(const std::string& url) {
450
telemetry_url = url;
telemetry_enabled = true;
// Initialize curl
curl_global_init(CURL_GLOBAL_ALL);
}
// Disable telemetry
void disable_telemetry() {
telemetry_enabled = false;
// Clean up curl
curl_global_cleanup();
}
}
◦ Use VK_EXT_debug_utils to name all objects and insert command buffer labels at
pass/material boundaries and before large draw/dispatch batches. Persist a small in-
memory ring buffer of recent labels for inclusion in crash artifacts.
◦ Integrate NVIDIA Nsight Aftermath and/or AMD RGD depending on your target audience.
Ship with crash dumps enabled in development/beta builds; provide a toggle for users.
◦ Keep a mapping from pipeline/shader hashes to source/IR/SPIR-V and build IDs. Enable
shader debug info where feasible for diagnosis builds.
◦ Log driver version, Vulkan version, GPU name/PCI ID, pipeline cache UUID, app
build/version, and relevant feature toggles. Include this alongside minidumps and GPU
crash dumps.
6. Reproduce deterministically
◦ Provide a way to disable background variability (e.g., async streaming) and to replay a
captured sequence of commands/scenes to reproduce the crash locally.
451
◦ Clearly document what crash data is collected (minidumps, GPU crash dumps, logs) and
require opt‑in for uploads. Strip user-identifiable data.
1.9. Conclusion
Robust crash handling is essential for maintaining a high-quality Vulkan application. Combine
vendor GPU crash dumps (Aftermath, RGD, etc.) with CPU-side minidumps and thorough logging to
quickly diagnose and fix issues in production. Treat minidumps as complementary context; the
actionable details for graphics faults typically come from GPU crash dump tooling.
In the next section, we’ll explore Vulkan extensions for robustness, which can reduce undefined
behavior and help prevent crashes in the first place.
Previous: Debugging with VK_KHR_debug_utils and RenderDoc | Next: Vulkan Extensions for
Robustness :pp: ++
• Application crashes
• Corrupted rendering
452
• Security vulnerabilities
Robustness extensions aim to provide more predictable behavior in these scenarios, often at a
small performance cost.
1. Robust Buffer Access: Out-of-bounds reads from buffers return zero values instead of causing
undefined behavior
2. Robust Image Access: Out-of-bounds reads from images return zero or transparent black
3. Null Descriptor Handling: Reads from null descriptors return zero values
4. Robust Buffer Access 2: An improved version that also handles out-of-bounds writes by
discarding them
return false;
}
453
// Set up the robustness2 features
vk::PhysicalDeviceRobustness2FeaturesEXT robustness2_features{};
robustness2_features.setRobustBufferAccess2(VK_TRUE);
robustness2_features.setRobustImageAccess2(VK_TRUE);
robustness2_features.setNullDescriptor(VK_TRUE);
vk::DeviceCreateInfo create_info{};
// Set up your queues, features, etc.
// Enable robustness2
enable_robustness2(create_info, enabled_extensions);
Once you’ve enabled the extension, robust buffer and image access will be applied automatically.
However, you should be aware of some considerations:
1. Performance Impact: Robust access can have a performance cost, as the GPU needs to perform
bounds checking
2. Not a Substitute for Correctness: While robustness extensions make your application more
resilient, they don’t fix the underlying bugs
3. Debug vs. Release: Consider enabling robustness in debug builds for development and testing,
but evaluate the performance impact for release builds
// Without robust buffer access, this could crash or produce undefined results
454
void potentially_dangerous_operation(vk::raii::CommandBuffer& cmd_buffer,
vk::raii::Buffer& buffer,
vk::raii::DescriptorSet& descriptor_set,
uint32_t dynamic_offset,
uint32_t buffer_size) {
// If dynamic_offset is too large, this would normally cause undefined behavior
// With robust buffer access, out-of-bounds reads will return zero
cmd_buffer.bindDescriptorSets(
vk::PipelineBindPoint::eCompute,
pipeline_layout,
0,
1,
&(*descriptor_set),
1,
&dynamic_offset
);
1.3.1. VK_KHR_buffer_device_address
This extension allows you to use physical device addresses for buffers, which can be useful for
advanced techniques. It includes robustness features for handling invalid addresses (when
combined with robust access features like VK_EXT_robustness2 or core robustBufferAccess):
455
robustness2_features.setRobustImageAccess2(VK_TRUE);
robustness2_features.setNullDescriptor(VK_TRUE);
1.3.2. VK_EXT_descriptor_indexing
This extension allows for more flexible descriptor indexing, including robustness-related
capabilities such as tolerating out-of-bounds indices (reads become zero when robust access is
enabled), partially bound descriptor sets, and update-after-bind. To actually make use of these
behaviors you need to enable both device features and descriptor set layout binding flags:
vk::PhysicalDeviceDescriptorIndexingFeatures indexing_features{};
// Shader indexing capabilities (commonly needed alongside robustness)
indexing_features.setShaderSampledImageArrayNonUniformIndexing(VK_TRUE);
indexing_features.setShaderStorageBufferArrayNonUniformIndexing(VK_TRUE);
// Robustness-enabling behaviors
indexing_features.setRuntimeDescriptorArray(VK_TRUE);
indexing_features.setDescriptorBindingPartiallyBound(VK_TRUE);
indexing_features.setDescriptorBindingSampledImageUpdateAfterBind(VK_TRUE);
indexing_features.setDescriptorBindingStorageBufferUpdateAfterBind(VK_TRUE);
indexing_features.setDescriptorBindingUpdateUnusedWhilePending(VK_TRUE);
For descriptor arrays, you must also specify binding flags at layout creation time:
// Example: descriptor set layout with a runtime-sized array that can be partially
bound
vk::DescriptorSetLayoutBinding binding{};
[Link] = 0;
[Link] = vk::DescriptorType::eCombinedImageSampler;
[Link] = 128; // example array size; for true runtime arrays also
enable variable descriptor counts
[Link] = vk::ShaderStageFlagBits::eFragment;
456
vk::DescriptorBindingFlags binding_flags =
vk::DescriptorBindingFlagBits::ePartiallyBound |
vk::DescriptorBindingFlagBits::eUpdateAfterBind;
vk::DescriptorSetLayoutBindingFlagsCreateInfo flags_ci{};
flags_ci.setBindingCount(1);
flags_ci.setPBindingFlags(&binding_flags);
vk::DescriptorSetLayoutCreateInfo dsl_ci{};
dsl_ci.setPBindings(&binding);
dsl_ci.setBindingCount(1);
// Required when using update-after-bind flags
// (some descriptor types require pool and layout flags to match update-after-bind
usage)
dsl_ci.flags |= vk::DescriptorSetLayoutCreateFlagBits::eUpdateAfterBindPool;
dsl_ci.pNext = &flags_ci;
If you need truly variable-length descriptor arrays at runtime, also enable variable descriptor
counts and use the corresponding allocate info:
vk::DescriptorSetVariableDescriptorCountAllocateInfo variable_counts_info{};
variable_counts_info.setDescriptorSetCount(1);
variable_counts_info.setPDescriptorCounts(&max_descriptors_for_set0);
vk::DescriptorSetAllocateInfo alloc_info{};
alloc_info.setDescriptorPool(descriptor_pool);
alloc_info.setDescriptorSetCount(1);
alloc_info.setPSetLayouts(&*set_layout);
alloc_info.pNext = &variable_counts_info;
457
1.4. Combining Robustness Extensions with Debugging
Tools
For maximum effectiveness, combine robustness extensions with the debugging tools we discussed
in previous sections:
class RobustVulkanApplication {
public:
RobustVulkanApplication() {
initialize_vulkan();
}
void run() {
// Main application loop
while (!should_close()) {
try {
update();
render();
} catch (const vk::SystemError& e) {
// Handle recoverable Vulkan errors
std::cerr << "Vulkan error: " << [Link]() << std::endl;
// Attempt recovery
if (!recover_from_error()) {
break;
}
}
}
cleanup();
}
private:
void initialize_vulkan() {
// Create instance with validation layers in debug builds
#ifdef _DEBUG
enable_validation_layers = true;
#else
enable_validation_layers = false;
#endif
instance = create_instance();
458
// Check for robustness support
has_robustness2 = check_robustness2_support(physical_device);
vk::DeviceCreateInfo create_info{};
// Set up queues, etc.
bool recover_from_error() {
// Attempt to recover from errors
// This might involve recreating swapchain, command buffers, etc.
try {
// Reset command buffers
// Recreate swapchain if needed
// ...
return true;
} catch (const std::exception& e) {
std::cerr << "Failed to recover: " << [Link]() << std::endl;
return false;
}
}
// Vulkan objects
vk::raii::Context context;
vk::raii::Instance instance{nullptr};
vk::raii::DebugUtilsMessengerEXT debug_messenger{nullptr};
vk::raii::PhysicalDevice physical_device{nullptr};
vk::raii::Device device{nullptr};
459
// Flags
bool enable_validation_layers = false;
bool has_robustness2 = false;
};
1. Check for Support: Always check if the extension is supported before trying to use it
2. Fallback Behavior: Implement fallback behavior for devices that don’t support the extensions
4. Combine with Validation: Use validation layers during development to catch issues early
5. Don’t Rely on Robustness: Fix the underlying issues rather than relying on robustness
extensions to mask them
6. Document Usage: Clearly document which extensions your application requires and why
1.6. Conclusion
Vulkan robustness extensions, particularly VK_EXT_robustness2, provide valuable tools for making
your application more resilient to undefined behavior. By combining these extensions with proper
error handling, validation layers, and debugging tools, you can create a more stable and reliable
Vulkan application.
In the next and final section, we’ll summarize what we’ve learned about tooling for Vulkan
applications and discuss how to apply these techniques in your own projects.
Previous: Crash Handling and Minidumps | Next: Packaging and Distribution :pp: ++
460
1.1. Platform-Specific Packaging Considerations
Each platform has its own packaging formats and distribution mechanisms. Let’s explore the
considerations for the major platforms:
• Executable Installers: Created with tools like NSIS (Nullsoft Scriptable Install System), Inno
Setup, or WiX Toolset
• MSIX Packages: Modern Windows app packages that support clean installation and
uninstallation
Here’s an example of creating a basic NSIS installer script for a Vulkan application:
!include "[Link]"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
Section "Install"
SetOutPath "$INSTDIR"
; Application files
File "[Link]"
File "*.dll"
File /r "shaders"
File /r "assets"
; Vulkan Runtime
File "[Link]"
; Create uninstaller
WriteUninstaller "$INSTDIR\[Link]"
461
; Create shortcuts
CreateDirectory "$SMPROGRAMS\MyVulkanApp"
CreateShortcut "$SMPROGRAMS\MyVulkanApp\[Link]" "$INSTDIR\[Link]"
CreateShortcut "$SMPROGRAMS\MyVulkanApp\[Link]" "$INSTDIR\[Link]"
SectionEnd
Section "Uninstall"
; Remove application files
Delete "$INSTDIR\[Link]"
Delete "$INSTDIR\*.dll"
RMDir /r "$INSTDIR\shaders"
RMDir /r "$INSTDIR\assets"
; Remove uninstaller
Delete "$INSTDIR\[Link]"
; Remove shortcuts
Delete "$SMPROGRAMS\MyVulkanApp\[Link]"
Delete "$SMPROGRAMS\MyVulkanApp\[Link]"
RMDir "$SMPROGRAMS\MyVulkanApp"
#!/bin/bash
# Script to create an AppImage for a Vulkan application
462
# Copy application binary
cp build/MyVulkanApp AppDir/usr/bin/
# Copy icon
cp [Link] AppDir/usr/share/icons/hicolor/256x256/apps/[Link]
463
HERE=\$(dirname "\$SELF")
export PATH="\${HERE}/usr/bin:\${PATH}"
export LD_LIBRARY_PATH="\${HERE}/usr/lib:\${LD_LIBRARY_PATH}"
export VK_LAYER_PATH="\${HERE}/usr/share/vulkan/explicit_layer.d"
export VK_ICD_FILENAMES="\${HERE}/usr/share/vulkan/icd.d/vulkan_icd.json"
"\${HERE}/usr/bin/MyVulkanApp" "$@"
EOF
chmod +x AppDir/AppRun
# Download appimagetool
wget -c
"[Link]
x86_64.AppImage"
chmod +x appimagetool-x86_64.AppImage
Here’s an example of creating a basic macOS application bundle structure for a Vulkan application
using MoltenVK:
#!/bin/bash
# Script to create a macOS application bundle for a Vulkan application
464
# Create [Link]
cat > [Link]/Contents/[Link] << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"[Link]
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>MyVulkanApp</string>
<key>CFBundleIconFile</key>
<string>[Link]</string>
<key>CFBundleIdentifier</key>
<string>[Link]</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>My Vulkan Application</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
The Vulkan loader is the component that connects your application to the Vulkan implementation
on the user’s system. There are different approaches to handling the loader:
1. Rely on System-Installed Loader: Require users to have the Vulkan SDK or drivers installed
2. Bundle the Loader: Include the Vulkan loader with your application
3. Hybrid Approach: Check for a system-installed loader and fall back to a bundled one if not
found
465
Here’s an example of a hybrid approach:
import std;
import vulkan_raii;
class VulkanLoader {
public:
static bool initialize() {
try {
// First, try to use the system-installed Vulkan loader
if (try_system_loader()) {
std::cout << "Using system-installed Vulkan loader" << std::endl;
return true;
}
private:
static bool try_system_loader() {
try {
// Create a Vulkan instance to test if the system loader works
vk::raii::Context context;
vk::ApplicationInfo app_info{};
app_info.setApiVersion(VK_API_VERSION_1_2);
vk::InstanceCreateInfo create_info{};
create_info.setPApplicationInfo(&app_info);
466
try {
// Set the path to the bundled Vulkan loader
#if defined(_WIN32)
std::string loader_path = get_executable_path() + "\\[Link]";
SetDllDirectoryA(get_executable_path().c_str());
#elif defined(__linux__)
std::string loader_path = get_executable_path() + "/[Link].1";
setenv("LD_LIBRARY_PATH", get_executable_path().c_str(), 1);
#elif defined(__APPLE__)
std::string loader_path = get_executable_path() +
"/../Frameworks/[Link]";
setenv("DYLD_LIBRARY_PATH", (get_executable_path() +
"/../Frameworks").c_str(), 1);
#endif
vk::InstanceCreateInfo create_info{};
create_info.setPApplicationInfo(&app_info);
467
return exe_path.substr(0, exe_path.find_last_of("/"));
}
return "";
#endif
}
};
If your application requires specific Vulkan layers or extensions, you need to handle them
appropriately:
2. Check for Support: Always check if required extensions are available before using them
3. Provide Fallbacks: Implement fallback behavior for missing extensions when possible
1. Pre-Compile Shaders: Package pre-compiled SPIR-V shaders rather than GLSL source
import std;
import vulkan_raii;
class ShaderManager {
public:
ShaderManager(vk::raii::Device& device) : device(device) {
// Determine the shader directory based on the application's location
shader_dir = get_application_directory() + "/shaders";
468
return vk::raii::ShaderModule(nullptr, nullptr, nullptr); // Return a copy
of the cached module
}
void clear_cache() {
shader_cache.clear();
}
private:
std::string get_application_directory() {
// Platform-specific code to get the application directory
// ...
return "."; // Placeholder
}
[Link](0);
[Link]([Link](), file_size);
[Link]();
return buffer;
}
vk::raii::Device& device;
std::string shader_dir;
std::unordered_map<std::string, vk::raii::ShaderModule> shader_cache;
469
};
1. Build Matrix: Set up a build matrix for different platforms and configurations
3. Version Management: Automatically increment version numbers based on git tags or other
criteria
on:
push:
tags:
- 'v*'
jobs:
build-and-package:
runs-on: ${{ [Link] }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
include:
- os: ubuntu-latest
package-script: ./scripts/package_linux.sh
artifact-name: MyVulkanApp-Linux
artifact-path: MyVulkanApp-x86_64.AppImage
- os: windows-latest
package-script: .\scripts\package_windows.bat
artifact-name: MyVulkanApp-Windows
artifact-path: MyVulkanApp_Installer.exe
- os: macos-latest
package-script: ./scripts/package_macos.sh
artifact-name: MyVulkanApp-macOS
artifact-path: [Link]
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
470
- name: Install Vulkan SDK
uses: humbletim/install-vulkan-sdk@v1.1.1
with:
version: latest
cache: true
- name: Build
run: cmake --build ${{[Link]}}/build --config Release
- name: Package
run: ${{ [Link]-script }}
create-release:
needs: build-and-package
runs-on: ubuntu-latest
steps:
- name: Download all artifacts
uses: actions/download-artifact@v3
1.5. Conclusion
Packaging and distribution are critical steps in the lifecycle of a Vulkan application. By carefully
considering platform-specific requirements, handling dependencies appropriately, and automating
the packaging process, you can ensure a smooth experience for your users across different
platforms.
Remember that the goal of packaging is to make installation and updates as seamless as possible for
your users. Invest time in creating a robust packaging and distribution system, and your users will
benefit from a more professional and reliable application.
In the next and final section, we’ll summarize what we’ve learned throughout this chapter on
471
tooling for Vulkan applications.
Tooling: Conclusion
1. Conclusion
In this chapter, we’ve explored a comprehensive set of tools and techniques for developing,
debugging, and distributing Vulkan applications. Let’s summarize what we’ve learned and discuss
how to apply these techniques in your own projects.
A well-designed CI/CD pipeline helps ensure consistent quality across builds and platforms,
catching issues early in the development process.
• Labeling objects, command buffers, and queue operations for better debugging
These tools provide visibility into the complex operations happening on the GPU, making it easier
to identify and fix issues in your rendering pipeline.
472
• Generating minidumps for detailed crash analysis
Proper crash handling helps you diagnose and fix issues that occur in production environments,
leading to a more stable and reliable application.
Finally, we explored Vulkan extensions that can help make your application more resilient to
undefined behavior:
These extensions provide valuable tools for making your application more robust against common
errors, though they should not be seen as a substitute for fixing the underlying issues.
Let’s see how all these components can work together in a complete application:
import std;
import vulkan_raii;
class VulkanApplication {
public:
VulkanApplication() {
// Initialize crash handler
crash_handler::initialize("MyVulkanApp", "crash_logs");
void run() {
473
// Main application loop
while (!should_close()) {
try {
update();
render();
} catch (const vk::SystemError& e) {
// Handle recoverable Vulkan errors
std::cerr << "Vulkan error: " << [Link]() << std::endl;
if (!recover_from_error()) {
break;
}
}
}
cleanup();
}
private:
void initialize_vulkan() {
// Create instance with validation layers in debug builds
#ifdef _DEBUG
enable_validation_layers = true;
#else
enable_validation_layers = false;
#endif
// Create instance
instance = create_instance();
474
}
void render() {
// Begin frame
auto cmd_buffer = begin_frame();
// End frame
end_frame(cmd_buffer);
// Vulkan objects
vk::raii::Context context;
vk::raii::Instance instance{nullptr};
vk::raii::DebugUtilsMessengerEXT debug_messenger{nullptr};
vk::raii::PhysicalDevice physical_device{nullptr};
vk::raii::Device device{nullptr};
// Flags
bool enable_validation_layers = false;
bool has_robustness2 = false;
bool capture_next_frame = false;
// RenderDoc API
RENDERDOC_API_1_4_1* renderdoc_api = nullptr;
};
475
1.3. Best Practices for Professional Vulkan
Development
Based on what we’ve covered in this chapter, here are some best practices for professional Vulkan
development:
1. Automate Your Workflow: Use CI/CD pipelines to automate building, testing, and packaging
your application.
2. Debug Early and Often: Use validation layers and debugging tools throughout development,
not just when issues arise.
3. Name Your Objects: Use VK_KHR_debug_utils to give meaningful names to Vulkan objects,
making debugging much easier.
4. Prepare for Crashes: Implement robust crash handling and reporting mechanisms from the
start of your project.
5. Consider Robustness: Evaluate the trade-offs of using robustness extensions based on your
application’s needs.
6. Test Across Platforms: Vulkan applications can behave differently across different hardware
and drivers, so test extensively.
7. Document Your Requirements: Clearly document which Vulkan extensions and features your
application requires.
8. Stay Updated: The Vulkan ecosystem is constantly evolving, so stay informed about new
extensions and tools.
• Improved Debugging Tools: Tools like RenderDoc continue to add new features for Vulkan
debugging.
• Ray Tracing Tooling: As ray tracing becomes more common, expect more specialized tools for
debugging and optimizing ray tracing pipelines.
• Machine Learning Integration: Tools that use machine learning to identify potential issues or
optimize performance.
• Cross-API Development: Tools that help manage development across multiple graphics APIs
(Vulkan, DirectX, Metal).
476
Remember that tooling is an investment that pays dividends throughout the development process.
Time spent setting up good CI/CD pipelines, debugging tools, and crash handling mechanisms will
save you countless hours of troubleshooting and manual work later on.
CI/CD Setup C++ code Debug Utils C++ code Crash Handling C++ code Robustness Extensions C++
code
Tooling
This chapter covers essential tooling and techniques for developing, debugging, and distributing
Vulkan applications, with a focus on using modern C++20 modules and the vk::raii namespace.
• Introduction
• Conclusion
477
Android and iOS.
Mobile development presents unique challenges and opportunities for Vulkan applications. The
constraints of mobile hardware—limited power, memory, and thermal capacity—require careful
optimization and consideration of platform-specific features. At the same time, mobile platforms
offer exciting possibilities for reaching a wider audience with your applications.
Performance optimization takes on critical importance in mobile environments where every watt
of power consumption and every millisecond of frame time affects user experience. We’ll explore
essential techniques like efficient texture formats, along with mobile-specific optimizations that can
mean the difference between smooth performance and user frustration.
Understanding the fundamental architectural differences between mobile and desktop GPUs
becomes essential for effective optimization. We’ll compare Tile-Based Rendering (TBR) and
Immediate Mode Rendering (IMR) approaches, helping you understand why techniques that work
well on desktop might perform poorly on mobile, and how to design rendering strategies that
leverage mobile GPU strengths.
Finally, we’ll explore the Vulkan extensions specifically designed for mobile platforms. Extensions
like VK_KHR_dynamic_rendering_local_read, VK_KHR_dynamic_rendering, and
VK_EXT_shader_tile_image unlock performance opportunities that can dramatically improve your
application’s efficiency on mobile hardware, transforming acceptable performance into
exceptional user experiences.
1.2. Prerequisites
This chapter represents the culmination of everything we’ve built throughout the previous
chapters, as mobile development requires deep integration with all engine systems. You’ll need
solid mastery of Vulkan fundamentals and the engine architecture we’ve developed, since mobile
optimization often requires fine-tuning at every level—from resource management and rendering
pipelines to memory allocation and synchronization.
Modern C++ expertise becomes particularly valuable in mobile development, where performance
constraints demand efficient code and careful resource management. C++17 and C++20 features like
constexpr, structured bindings, and concepts help create mobile-optimized code that performs well
under strict power and thermal limitations.
Understanding basic mobile development concepts will provide crucial context for the platform-
specific decisions we’ll make. Mobile applications operate under constraints that desktop
478
applications rarely face—app lifecycle events, varying screen densities, touch input paradigms, and
the need to preserve battery life all influence how we design and implement our Vulkan engine for
mobile platforms.
You should also be familiar with the following chapters from the main tutorial:
◦ Command buffers
◦ Graphics pipelines
• Uniform buffers
• Compute shaders
Let’s begin by exploring the platform considerations for Android and iOS.
Previous: Tooling Conclusion | Next: Platform Considerations for Android and iOS :pp: ++
• Declare Vulkan Support: In your [Link], declare that your application uses
Vulkan:
<manifest xmlns:android="[Link]
package="[Link]">
<uses-feature android:name="[Link]"
479
android:version="0x400003" android:required="true" />
<uses-feature android:name="[Link]" android:version="0"
android:required="true" />
<!-- For devices that support Vulkan but don't declare it -->
<uses-feature android:name="[Link]"
android:required="false" />
<!-- ... -->
</manifest>
• Initialize Vulkan: Use the Android Native Development Kit (NDK) to initialize Vulkan. The
process is similar to desktop Vulkan, but you’ll need to obtain the native window handle from
Android:
VkSurfaceKHR vulkan_surface;
vkCreateAndroidSurfaceKHR(instance, &create_info, nullptr, &vulkan_surface);
Android applications have a complex lifecycle that you need to handle properly:
1. Activity Pausing and Resuming: When your application is paused (e.g., when the user switches
to another app), you should release Vulkan resources and recreate them when the application
resumes.
2. Surface Changes: The surface can change due to configuration changes (e.g., rotation). You
need to handle these changes by recreating the swapchain.
3. Memory Pressure: Android can reclaim memory from your application at any time. Design
your engine to handle memory pressure gracefully.
1. Touch Input: Instead of mouse input, you’ll need to handle touch events, including multi-touch
gestures.
2. Sensors: Android devices have various sensors (accelerometer, gyroscope, etc.) that you can use
for input.
480
1.1.4. Vendor-Specific Considerations
1. Custom Android Versions: Many manufacturers use customized versions of Android. Test your
application on various devices to ensure compatibility.
2. GPU Architectures: Different vendors use different GPU architectures (Adreno, Mali, PowerVR,
etc.). Each has unique performance characteristics.
3. Alternative App Stores: Some devices may not have Google Play Services. Consider distributing
through alternative app stores when necessary.
4. SoC Variations: System-on-Chip variations affect performance. Most mobile GPUs are tile-based
renderers, so optimize your rendering pipeline accordingly using the techniques described in
the TBR section.
481
// Initialize MoltenVK
MVKConfiguration config{};
vkGetMoltenVKConfigurationMVK(nullptr, &config);
[Link] = true; // Enable debug mode during development
vkSetMoltenVKConfigurationMVK(nullptr, &config);
VkSurfaceKHR vulkan_surface;
vkCreateMetalSurfaceEXT(instance, &create_info, nullptr, &vulkan_surface);
2. Memory Warnings: iOS can send memory warnings when the system is low on memory.
Handle these by releasing non-essential resources.
1. Touch Input: iOS has its own touch event system that you’ll need to integrate with your engine.
2. Sensors: iOS devices also have various sensors that you can use for input.
• Abstraction Layers: Create platform-specific abstraction layers for window creation, input
handling, and other platform-specific functionality.
482
#ifdef __ANDROID__
// Android-specific code
#elif defined(__APPLE__)
// iOS-specific code
#else
// Desktop-specific code
#endif
• Feature Detection: Use Vulkan’s feature detection mechanisms to adapt to the capabilities of
the device, rather than making assumptions based on the platform.
2. Handle Different Screen Sizes and Aspect Ratios: Mobile devices come in various sizes and
aspect ratios. Design your UI and rendering to adapt accordingly.
3. Consider Battery Life: Mobile users are sensitive to battery drain. Optimize your engine to
minimize power consumption.
4. Respect Platform Guidelines: Follow the design and user experience guidelines for each
platform to ensure your application feels native.
In the next section, we’ll explore performance optimizations specifically tailored for mobile
hardware, focusing on texture formats and memory usage.
This chapter covers general mobile performance. For practices that arise
specifically because the GPU is tile-based (TBR), see Rendering Approaches: Tile-
Based Rendering.
483
1.1. Texture Optimizations
We focus on mobile‑specific decisions here. For general Vulkan image creation,
staging uploads, and descriptor setup, refer back to earlier chapters in the engine
series—Resource Management, Rendering Pipeline—or the Vulkan Guide
([Link]
Textures are often the largest consumers of memory in a graphics application. Optimizing them is
crucial for performance on both mobile and desktop.
Choosing the right texture format is crucial across platforms; what differs is which formats are
natively supported by a given device/driver:
◦ ASTC (Adaptive Scalable Texture Compression): Widely supported on modern mobile GPUs
and increasingly available on desktop; excellent quality-to-size ratio with flexible block
sizes.
◦ ETC2/EAC: Required for OpenGL ES 3.0 and supported by most Android devices; commonly
available on Vulkan stacks, too.
2. Format Selection Based on Content and Support: Choose formats based on the type of texture
and what the device reports:
◦ For high detail (normals, roughness): prefer ASTC 4x4 or 6x6 when supported; on desktop,
BC5/BC7 are common alternatives.
◦ For albedo/basecolor: ASTC 6x6–8x8 works well when available; on desktop, BC1/BC7 are
typical.
484
vk::FormatProperties props = physical_device.getFormatProperties(format);
if (tiling == vk::ImageTiling::eLinear) {
return ([Link] & features) == features;
} else if (tiling == vk::ImageTiling::eOptimal) {
return ([Link] & features) == features;
}
return false;
}
1. Pool Allocations: Use memory pools to reduce the overhead of frequent allocations and
deallocations.
2. Suballocate from Larger Blocks: Instead of creating many small Vulkan memory allocations,
allocate larger blocks and suballocate from them:
class VulkanMemoryPool {
public:
VulkanMemoryPool(vk::Device device, vk::PhysicalDevice physical_device,
vk::DeviceSize block_size, uint32_t memory_type_index)
: device(device), block_size(block_size), memory_type_index(memory_type_index)
{
allocate_new_block();
}
~VulkanMemoryPool() {
for (auto& block : memory_blocks) {
[Link]([Link]);
485
}
}
struct Allocation {
vk::DeviceMemory memory;
vk::DeviceSize offset;
vk::DeviceSize size;
};
private:
struct MemoryBlock {
vk::DeviceMemory memory;
vk::DeviceSize next_offset = 0;
};
void allocate_new_block() {
vk::MemoryAllocateInfo alloc_info;
alloc_info.setAllocationSize(block_size);
alloc_info.setMemoryTypeIndex(memory_type_index);
MemoryBlock block;
[Link] = [Link](alloc_info);
block.next_offset = 0;
memory_blocks.push_back(block);
}
486
vk::Device device;
vk::DeviceSize block_size;
uint32_t memory_type_index;
std::vector<MemoryBlock> memory_blocks;
};
1. Minimize State Changes: Group draw calls by material to reduce state changes.
2. Use Smaller Data Types: Use 16-bit indices and half-precision floats where appropriate.
3. Optimize Vertex Formats: Use packed vertex formats to reduce memory bandwidth:
If you are targeting tile-based GPUs (TBR), bandwidth can be heavily impacted by
attachment load/store behavior and tile flushes. See Rendering Approaches —
sections “Attachment Load/Store Operations on Tilers” and “Pipelining on Tilers:
Subpass Dependencies and BY_REGION” for concrete guidance.
487
3. Level of Detail (LOD): Implement LOD systems to reduce geometry complexity for distant
objects.
On tile-based GPUs, reducing CPU overhead is important, but keeping work and
data on-chip via proper pipelining and subpasses often yields larger gains. See
Rendering Approaches — “Pipelining on Tilers: Subpass Dependencies and
BY_REGION” for barrier/subpass patterns, and “Attachment Load/Store Operations
on Tilers” for loadOp/storeOp guidance that avoids external memory traffic.
Different mobile GPU vendors have specific architectures that benefit from targeted optimizations:
• Performance Monitoring: Most vendors provide performance monitoring tools that can help
identify bottlenecks specific to their hardware.
488
1.5. Best Practices for Mobile Performance
1. Profile on Target Devices: Performance characteristics vary widely across mobile devices. Test
on a range of hardware from different manufacturers and with different GPU architectures.
2. Monitor Temperature: Mobile devices throttle performance when they get hot. Design your
engine to adapt to thermal throttling.
3. Balance Quality and Performance: Provide graphics settings that allow users to balance
quality and performance based on their device capabilities.
In the next section, we’ll explore different rendering approaches for mobile GPUs, focusing on the
differences between Tile-Based Rendering (TBR) and Immediate Mode Rendering (IMR).
1. Tiling Phase: The screen is divided into small tiles (typically 16x16 or 32x32 pixels).
2. Binning Phase: The GPU determines which primitives (triangles) affect each tile.
489
1.1.2. Advantages of TBR
1. Reduced Memory Bandwidth: Since rendering happens in on-chip memory, there’s less traffic
to main memory.
2. Power Efficiency: Lower memory bandwidth means lower power consumption, which is
crucial for battery-powered devices.
3. Hidden Surface Removal: Many TBR GPUs perform early depth testing during the binning
phase, reducing overdraw.
To get the best performance from TBR GPUs, consider these optimizations:
• Transient Attachments: Use transient attachments for render targets that are only used within
a render pass:
vk::AttachmentDescription depth_attachment{};
depth_attachment.setFormat(depth_format);
depth_attachment.setSamples(vk::SampleCountFlagBits::e1);
depth_attachment.setLoadOp(vk::AttachmentLoadOp::eClear);
depth_attachment.setStoreOp(vk::AttachmentStoreOp::eDontCare); // Don't store the
result
depth_attachment.setStencilLoadOp(vk::AttachmentLoadOp::eDontCare);
depth_attachment.setStencilStoreOp(vk::AttachmentStoreOp::eDontCare);
depth_attachment.setInitialLayout(vk::ImageLayout::eUndefined);
depth_attachment.setFinalLayout(vk::ImageLayout::eDepthStencilAttachmentOptimal);
• Render Pass Structure: Design your render passes to take advantage of tile-based rendering:
490
vk::SubpassDescription subpass1{};
[Link](vk::PipelineBindPoint::eGraphics);
[Link](color_attachment_refs);
[Link](&depth_attachment_ref);
vk::SubpassDescription subpass2{};
[Link](vk::PipelineBindPoint::eGraphics);
[Link](input_attachment_refs); // Use output from subpass1 as
input
[Link](final_color_attachment_refs);
• Avoid External Framebuffer Reads: Avoid reading from images that require the tile to be
flushed to external memory and reloaded; this is expensive on TBR.
◦ Local, same-pixel reads from on-chip/tile memory are fine and encouraged on tile-based
GPUs.
• Optimize for Tile Size: Consider the tile size when designing your rendering algorithm. For
example, if you know the tile size is 16x16, you might organize your data or algorithms to work
efficiently with that size.
On tile-based GPUs, correctly using loadOp and storeOp is one of the highest-impact optimizations:
• Clear attachments with loadOp = CLEAR and initialLayout = UNDEFINED when you don’t need
previous contents. This avoids an external memory read for the tile.
• Use storeOp = DONT_CARE for attachments whose results are not needed after the render pass
491
(e.g., transient depth or intermediate color targets). This can prevent flushing the tile back to
main memory.
• For the swapchain image (or any image you will sample/transfer from later), use storeOp =
STORE and set finalLayout appropriately (e.g., PRESENT_SRC_KHR for the swapchain).
• For MSAA, resolve within the same render pass so the hardware can resolve from tile memory
and only store the resolved image to external memory.
Tile-based GPUs benefit from fine-grained synchronization that keeps work and data on-chip:
• Prefer subpasses with input attachments to keep producer/consumer within the same render
pass, enabling tile-local reads.
Example: dependency from a color-writing subpass to a subpass that reads that color as an input
492
attachment.
vk::SubpassDependency dep{};
[Link](0);
[Link](1);
[Link](vk::PipelineStageFlagBits::eColorAttachmentOutput);
[Link](vk::PipelineStageFlagBits::eFragmentShader);
[Link](vk::AccessFlagBits::eColorAttachmentWrite);
[Link](vk::AccessFlagBits::eInputAttachmentRead);
[Link](vk::DependencyFlagBits::eByRegion);
Example: external dependency to the first subpass of a render pass, allowing pipelining with prior
pass while limiting scope by region.
vk::SubpassDependency externalDep{};
[Link](VK_SUBPASS_EXTERNAL);
[Link](0);
[Link](vk::PipelineStageFlagBits::eColorAttachmentOutput);
[Link](vk::PipelineStageFlagBits::eEarlyFragmentTests |
vk::PipelineStageFlagBits::eColorAttachmentOutput);
[Link](vk::AccessFlagBits::eColorAttachmentWrite);
[Link](vk::AccessFlagBits::eDepthStencilAttachmentWrite |
vk::AccessFlagBits::eColorAttachmentWrite);
[Link](vk::DependencyFlagBits::eByRegion);
For further guidance, see the Vulkan Guide topics on Tile-based GPUs, Render Passes, and
Synchronization.
• Select Optimal Memory Types: Choose the best matching memory type (with the appropriate
VkMemoryPropertyFlags) when using vkAllocateMemory.
• Batch Allocations: For each type of resource (e.g., index buffer, vertex buffer, and uniform
buffer), allocate large chunks of memory with a specific size in one go when possible.
• Reuse Memory Resources: Let multiple passes take turns using the allocated memory through
time slicing.
493
VK_MEMORY_PROPERTY_HOST_COHERENT_BIT because the driver can refresh a large block of
memory at once.
• Minimize Allocation Calls: Avoid frequent calls to vkAllocateMemory. The number of memory
allocations is limited by maxMemoryAllocationCount.
• Vectorized Memory Access: Access memory in a vectorized manner to reduce access cycles
and bandwidth. For example:
void main() {
uint idx = 0u;
TileStructSample ts[3];
while (idx < 3u) {
ts[int(idx)].data = a;
idx++;
}
}
void main() {
uint idx = 0u;
TileStructSample ts[3];
while (idx < 3u) {
ts[int(idx)].data1 = a;
ts[int(idx)].data2 = b;
ts[int(idx)].data3 = c;
ts[int(idx)].data4 = d;
idx++;
}
}
• Optimize Uniform Buffers: Consider using push constants or macro constants instead of
uniform buffers for small data. Avoid dynamic indexing when possible.
• Minimize Branching: Reduce complex branch structures, branch nesting, and loop structures
494
as they can harm parallelism.
• Use Half-Precision: When appropriate, use half-precision floats to reduce bandwidth and
power consumption. In SPIR-V, use relaxed-precision decoration on variables or results.
• Enable Depth Testing and Writing: This allows the GPU to cull hidden primitives and reduce
overdraw.
• Avoid Operations That Disable Early-Z: The following operations can prevent effective early
depth testing:
• Consistent Compare Operations: When using compareOp, try to keep the values consistent for
each draw in the render pass.
• Clear Attachments Properly: Attachments should be cleared at the beginning of the render
pass, or when no valid compareOp value is assigned to previous draw calls.
3. Fragment Processing: Process each fragment and write the result directly to the framebuffer in
main memory.
1. Simplicity: The rendering model is more straightforward and matches the traditional graphics
pipeline.
2. Flexibility: Some algorithms that require reading from the framebuffer are easier to
implement.
495
1.2.3. Optimizing for IMR
1. Front-to-Back Rendering: Render opaque objects from front to back to minimize overdraw.
3. Occlusion Culling: Implement occlusion culling to avoid rendering objects that won’t be visible.
// For other vendors, you might need to maintain a list of known TBR GPUs
// or just assume desktop GPUs are IMR and mobile GPUs are TBR
return false;
}
496
void configure_rendering_pipeline(vk::PhysicalDevice physical_device) {
bool is_tbr = is_likely_tbr_gpu(physical_device);
if (is_tbr) {
// TBR optimizations
use_transient_attachments = true;
prioritize_subpass_dependencies = true;
avoid_framebuffer_reads = true;
} else {
// IMR optimizations
use_front_to_back_sorting = true;
prioritize_early_z = true;
implement_occlusion_culling = true;
}
}
• Fallback Strategy: If you can’t determine the architecture, optimize for TBR, as those
optimizations generally don’t harm IMR performance significantly.
1. Minimize State Changes: Group draw calls by material to reduce state changes.
2. Batch Similar Objects: Use instancing or batching to reduce draw call overhead.
4. Profile on Target Devices: Always test your optimizations on actual target devices.
In the next section, we’ll explore Vulkan extensions that can help you optimize performance on
mobile devices, particularly those that leverage the tile-based architecture.
497
mobile devices.
1.1. VK_KHR_dynamic_rendering
Dynamic rendering is a game-changing extension that simplifies the Vulkan rendering workflow by
eliminating the need for explicit render pass and framebuffer objects.
1.1.1. Overview
The VK_KHR_dynamic_rendering extension (now part of Vulkan 1.3 core) allows you to begin and end
rendering operations directly within a command buffer, without creating render pass and
framebuffer objects. This benefits a wide range of platforms (desktop and mobile) because it:
1. Simplifies Code: Reduces the complexity of managing render passes and framebuffers.
2. Enables More Flexible Rendering: Makes it easier to implement techniques that don’t fit well
into the traditional render pass model.
3. Potentially Lowers API Overhead: Fewer objects to create and manage can simplify setup; any
CPU savings are usually small and workload-dependent.
We first enable the device extension and, if you’re not on Vulkan 1.3 core, load the function
pointers.
This prepares your device to use dynamic rendering and gives access to the commands needed to
begin/end a rendering scope without a traditional render pass.
498
[Link]. Describe attachments for this rendering scope
We define the color and depth attachments and package them into a VkRenderingInfoKHR. Think of
this as an inline, one-off description of what would normally be baked into render
pass/framebuffer objects.
VkRenderingAttachmentInfoKHR color_attachment{};
color_attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR;
color_attachment.imageView = color_image_view;
color_attachment.imageLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL;
color_attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
color_attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE;
color_attachment.clearValue = clear_value;
VkRenderingAttachmentInfoKHR depth_attachment{};
depth_attachment.sType = VK_STRUCTURE_TYPE_RENDERING_ATTACHMENT_INFO_KHR;
depth_attachment.imageView = depth_image_view;
depth_attachment.imageLayout = VK_IMAGE_LAYOUT_DEPTH_ATTACHMENT_OPTIMAL;
depth_attachment.loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR;
depth_attachment.storeOp = VK_ATTACHMENT_STORE_OP_DONT_CARE;
depth_attachment.clearValue = depth_clear_value;
VkRenderingInfoKHR rendering_info{};
rendering_info.sType = VK_STRUCTURE_TYPE_RENDERING_INFO_KHR;
rendering_info.renderArea = render_area;
rendering_info.layerCount = 1;
rendering_info.colorAttachmentCount = 1;
rendering_info.pColorAttachments = &color_attachment;
rendering_info.pDepthAttachment = &depth_attachment;
Each frame (or subpass-equivalent), you can tweak these descriptors directly (e.g., swapchain views
after resize), avoiding pipeline-wide re-creation.
With the attachments described, we open the rendering scope, record draws, then close the scope.
vkCmdBeginRenderingKHR(command_buffer, &rendering_info);
// End rendering
vkCmdEndRenderingKHR(command_buffer);
499
[Link]. C++ bindings ([Link]) variant
If you’re using [Link] (vk::), the structure population is more ergonomic but follows the same
steps.
// Using [Link]
vk::RenderingAttachmentInfoKHR color_attachment;
color_attachment.setImageView(color_image_view);
color_attachment.setImageLayout(vk::ImageLayout::eColorAttachmentOptimal);
color_attachment.setLoadOp(vk::AttachmentLoadOp::eClear);
color_attachment.setStoreOp(vk::AttachmentStoreOp::eStore);
color_attachment.setClearValue(clear_value);
vk::RenderingAttachmentInfoKHR depth_attachment;
depth_attachment.setImageView(depth_image_view);
depth_attachment.setImageLayout(vk::ImageLayout::eDepthAttachmentOptimal);
depth_attachment.setLoadOp(vk::AttachmentLoadOp::eClear);
depth_attachment.setStoreOp(vk::AttachmentStoreOp::eDontCare);
depth_attachment.setClearValue(depth_clear_value);
vk::RenderingInfoKHR rendering_info;
rendering_info.setRenderArea(render_area);
rendering_info.setLayerCount(1);
rendering_info.setColorAttachments(color_attachment);
rendering_info.setPDepthAttachment(&depth_attachment);
Once the description is assembled, begin the rendering scope, submit draws, and end the scope.
command_buffer.beginRenderingKHR(rendering_info);
// End rendering
command_buffer.endRenderingKHR();
1.2. VK_KHR_dynamic_rendering_local_read
The VK_KHR_dynamic_rendering_local_read extension is particularly valuable for tile-based renderers
as it allows shaders to read from attachments without forcing a tile to main memory and back.
1.2.1. Overview
This extension enhances dynamic rendering by allowing fragment shaders to read from color and
depth/stencil attachments within the same rendering scope. On tile-based renderers, this means the
reads can happen directly from tile memory, avoiding expensive round trips to main memory.
500
Key benefits include:
1. Reduced Memory Bandwidth: Reads happen from on-chip memory rather than main memory,
reducing bandwidth usage for bandwidth-intensive operations.
2. Improved Performance: Particularly for algorithms that need to read from previously written
attachments.
1. Eliminates Tile Flush Operations: Without this extension, when a shader needs to read from a
previously written attachment, the GPU must flush the entire tile to main memory and then
read it back. This extension allows the shader to read directly from the tile memory, eliminating
these costly flush operations.
2. Supports Per-Pixel Local Reads: It enables fragment shaders to read the value written at the
same pixel from attachments within the current rendering scope/tile. This suits per-pixel
operations (e.g., tone mapping or reading depth/previous color).
4. Practical Example: Consider a deferred rendering pipeline that needs to read G-buffer data at
the same pixel for lighting. Without this extension, the G-buffer would need to be written to
main memory and then read back for the lighting pass. With this extension, the lighting pass
can read directly from the G-buffer in tile memory, saving bandwidth.
1.2.3. Implementation
501
sample_count_info.setColorAttachmentSamples(vk::SampleCountFlagBits::e1);
sample_count_info.setDepthStencilAttachmentSamples(vk::SampleCountFlagBits::e1);
vk::RenderingAttachmentLocationInfoKHR location_info;
location_info.setColorAttachmentLocations(0); // Location 0 for the color attachment
vk::RenderingInputAttachmentIndexInfoKHR input_index_info;
input_index_info.setColorInputAttachmentIndices(0); // Index 0 for the color
attachment
// In your fragment shader, you can now read from the attachment
// using subpassLoad() or texture sampling with the appropriate extension
// Fragment shader example (GLSL):
// #extension GL_EXT_shader_tile_image : require
// layout(location = 0) out vec4 outColor;
// layout(input_attachment_index = 0, set = 0, binding = 0) uniform subpassInput
inputColor;
// void main() {
// vec4 color = subpassLoad(inputColor);
// outColor = color * 2.0; // Double the brightness
// }
1.3. VK_EXT_shader_tile_image
The VK_EXT_shader_tile_image extension provides direct access to tile memory in shaders, which can
significantly improve performance on tile-based renderers.
1.3.1. Overview
1. Access Tile Memory Directly: Read and write to the current tile’s memory without going
through main memory.
2. Perform Tile-Local Operations: Execute operations that stay entirely within the tile memory.
4. Reduce Memory Bandwidth: Helps lower memory bandwidth by keeping data in tile-local
memory during multi-pass workloads.
502
1. Tile-Based Architecture Optimization: Mobile GPUs typically use tile-based rendering, where
the screen is divided into small tiles that are processed independently. This extension takes full
advantage of this architecture by allowing shaders to work directly with the tile data in fast on-
chip memory.
3. Bandwidth Savings Measurements: Testing on various mobile GPUs has shown meaningful
bandwidth reductions for complex multi-pass pipelines; actual gains are workload- and GPU-
dependent.
4. Practical Applications:
◦ Image Processing Filters: Applying multiple filters (blur, sharpen, color correction) can be
done without leaving tile memory.
◦ Deferred Rendering: G-buffer data can be kept in tile memory for the lighting pass.
◦ Shadow Mapping: Shadow calculations can be performed more efficiently by keeping depth
information in tile memory.
5. Power Efficiency: The reduction in memory bandwidth directly translates to lower power
consumption, which is critical for mobile devices. Tests have shown up to 20% power savings
for graphics-intensive applications.
1.3.3. Implementation
// When creating your shader module, make sure your shader uses the extension
// GLSL example:
// #extension GL_EXT_shader_tile_image : require
//
// layout(tile_image, set = 0, binding = 0) uniform tileImageColor { vec4 color; }
tileColor;
//
// void main() {
// // Read from tile memory
// vec4 current_color = [Link];
//
// // Process the color
// vec4 new_color = process(current_color);
//
// // Write back to tile memory
503
// [Link] = new_color;
// }
// Now you can use the supported extensions in your rendering code
// ...
Different mobile GPU vendors have varying levels of support for Vulkan extensions:
504
traditional render passes, especially on tile-based renderers.
505
std::endl;
std::cout << " Dynamic Rendering Local Read: " <<
(has_dynamic_rendering_local_read ? "Yes" : "No") << std::endl;
std::cout << " Shader Tile Image: " << (has_shader_tile_image ? "Yes" : "No") <<
std::endl;
◦ Prioritize the use of dynamic rendering over traditional render passes on tile-based
renderers
◦ Test different configurations to find the optimal settings for various device models
2. Fallback Paths: Implement fallback paths for when extensions aren’t available.
3. Test on Real Devices: Extensions may behave differently across vendors and devices. Test on a
variety of hardware from different manufacturers.
4. Stay Updated: Keep track of new extensions that could benefit mobile performance, as mobile
GPU vendors continue to enhance their Vulkan support.
In the next section, we’ll conclude our exploration of mobile development with a summary of key
takeaways and best practices.
class MobileOptimizedEngine {
public:
MobileOptimizedEngine() {
506
// Initialize platform-specific components
#ifdef __ANDROID__
initialize_android();
#elif defined(__APPLE__)
initialize_ios();
#else
initialize_desktop();
#endif
void run() {
// Main application loop
while (!should_close()) {
handle_platform_events();
update();
render();
}
cleanup();
}
private:
void initialize_vulkan() {
// Create instance
vk::InstanceCreateInfo instance_info;
// ... set instance parameters
instance = vk::createInstance(instance_info);
if (check_extension_support(available_extensions,
507
VK_KHR_DYNAMIC_RENDERING_LOCAL_READ_EXTENSION_NAME)) {
supported_extensions.push_back(VK_KHR_DYNAMIC_RENDERING_LOCAL_READ_EXTENSION_NAME);
use_dynamic_rendering_local_read = true;
}
if (check_extension_support(available_extensions,
VK_EXT_SHADER_TILE_IMAGE_EXTENSION_NAME)) {
supported_extensions.push_back(VK_EXT_SHADER_TILE_IMAGE_EXTENSION_NAME);
use_shader_tile_image = true;
}
void render() {
// Begin frame
auto cmd_buffer = begin_frame();
if (use_dynamic_rendering) {
// Use dynamic rendering
vk::RenderingAttachmentInfoKHR color_attachment;
// ... set attachment parameters
vk::RenderingInfoKHR rendering_info;
// ... set rendering parameters
cmd_buffer.beginRenderingKHR(rendering_info);
cmd_buffer.endRenderingKHR();
} else {
// Use traditional render passes
// ...
}
// End frame
end_frame(cmd_buffer);
}
// Platform-specific initialization
void initialize_android() {
508
// Android-specific setup
// ...
}
void initialize_ios() {
// iOS-specific setup with MoltenVK
// ...
}
void initialize_desktop() {
// Desktop-specific setup
// ...
}
// Helper functions
bool check_extension_support(const std::vector<vk::ExtensionProperties>&
available, const char* extension_name) {
for (const auto& ext : available) {
if (strcmp(extension_name, [Link]) == 0) {
return true;
}
}
return false;
}
return false;
}
// Vulkan objects
vk::Instance instance;
vk::PhysicalDevice physical_device;
vk::Device device;
// Flags
bool is_tbr_gpu = false;
bool use_dynamic_rendering = false;
bool use_dynamic_rendering_local_read = false;
bool use_shader_tile_image = false;
509
};
2. Render path selection: Switch between TBR-friendly and IMR-neutral paths at runtime based on
a simple vendor/heuristic check.
3. Framebuffer read policy: Prefer tile-local, per-pixel reads (input attachments or dynamic
rendering local read). Avoid patterns that force external memory round-trips.
4. Textures and assets: Use KTX2 as the container; prefer ASTC when available with ETC2/PVRTC
fallbacks as needed. Generate mipmaps offline.
5. Memory/attachments: Use transient attachments where results aren’t needed after the pass;
suballocate to minimize fragmentation.
6. Thermal/perf governor: Implement dynamic resolution or quality tiers and sensible FPS caps to
keep thermals in check.
8. Device matrix: Maintain a small, representative device lab (different vendors/tiers) and run
sanity scenes regularly.
◦ Layout transitions and access masks, especially when using local read.
• Profile efficiently:
◦ Use platform tools (e.g., Android GPU Inspector, RenderDoc, Xcode GPU Capture) to identify
tile flushes, overdraw, and bandwidth hot spots.
◦ A/B test: classic render pass vs dynamic rendering, local read on/off, tile-image on/off.
◦ Track power and thermals over multi‑minute runs, not just single frames.
• Add automated startup probes that dump device/feature info to logs for field telemetry.
• Expand the regression scene suite to cover TBR‑sensitive and bandwidth‑heavy paths.
510
1.4.1. Explore Advanced Topics (Simple Engine Tutorials)
The following short, focused tutorials build directly on the Simple Engine and are great next steps:
• Mipmaps and LOD — practical guidance on stable texture sampling and anisotropy.
• Dynamic Rendering Local Read — optimize same‑pass reads via tile/local memory when
supported.
Mobile Platform Integration C++ code Mobile Optimizations C++ code TBR Optimizations C++ code
Mobile Extensions C++ code
Previous: Vulkan Extensions for Mobile | Back to Building a Simple Engine :pp: ++
Mobile Development
This chapter covers the essential aspects of adapting your Vulkan engine for mobile platforms,
focusing on Android and iOS development, performance optimizations, rendering approaches, and
mobile-specific Vulkan extensions.
• Introduction
• Conclusion
• Planar Reflections
511
• Rendering Pipeline Overview
• Forward+ Rendering
• Separate Image/Sampler
• VK_EXT_robustness2
Culling is the simplest way to keep your GPU focused on what the camera can see. In this engine we
keep it intentionally pragmatic: CPU frustum tests plus a tiny “distance/size LOD” that skips objects
that would contribute only a handful of pixels.
• A tiny distance/size LOD that skips very small objects (projected size threshold)
1. What we do
1. Extract the camera frustum planes from proj * view once per frame.
2. For each mesh instance, transform its local AABB to world space and test against the planes.
3. If enabled, estimate projected pixel size and skip objects below a threshold (separate thresholds
for opaque vs transparent).
• UI controls:
512
thresholds
• A projected‑size cutoff is a practical alternative to a full LOD system for large scenes.
4. Tuning tips
• Start conservative (smaller thresholds), then increase until you can’t notice pop‑in while
moving.
• Transparent objects typically need a slightly higher threshold due to blending artifacts at tiny
sizes.
• Add per-material or per-layer culling rules (e.g., keep signage readable longer).
• Add GPU occlusion culling (HZB) once the pipeline grows beyond “readable sample” scale.
• Replace the projected-size heuristic with real mesh LODs (or meshlets).
• Forward+ Rendering
Vulkan descriptors are powerful, but they’re also one of the easiest places to accidentally violate
“frame in flight” lifetime rules.
That rule keeps streaming stable, keeps validation clean, and (most importantly) keeps the code
readable.
513
the GPU is done with any work that referenced this frame’s descriptor sets. That’s the safe moment
to update this frame’s sets.
Why it matters: updating a set that’s still in use leads to invalid writes or so‑called
“update‑after‑bind” violations unless you deliberately opt into those behaviors and structure your
pipeline around them. The safe point pattern stays portable and clear.
8. What we update
• Material textures that finished streaming.
• Per‑frame buffers for Forward+ (tile headers/indices, lights SSBO) when resized.
In Ray Query mode we also refresh the large texture table (the fixed-size sampler array) so that
newly streamed textures become visible without rebuilding the pipeline.
We refresh only the current frame’s sets at the safe point and leave other frames to update at their
own turn. This prevents cross‑frame “flip‑flop” where a texture looks different on alternating
frames.
• We update at the safe point rather than while a command buffer might still be pending.
When we do use descriptor indexing features, it’s for one specific reason: large, non-uniformly
indexed descriptor arrays (e.g., Ray Query’s texture table). In that case, correctness depends on:
If your project needs truly dynamic descriptor arrays or frequent mid‑frame updates, Descriptor
Indexing can be the right tool—just document the new invariants carefully.
• Use default textures for placeholders, then swap once—don’t bounce between real and default.
• Prefer combined image samplers for samples aimed at teaching; split image/sampler only when
you need the flexibility.
514
11. Where to look in the code
• Frame safe point + per-frame descriptor refresh:
◦ renderer_rendering.cpp
◦ renderer_pipelines.cpp
◦ renderer_core.cpp
◦ renderer_ray_query.cpp
• Move to variable descriptor counts for texture tables (when device support is good enough for
your targets).
• Add a “descriptor stress test” mode (development-only) that rapidly streams textures to validate
lifetime rules.
This conservative approach avoids common pitfalls while keeping the code approachable. =
VK_KHR_dynamic_rendering_local_read — keeping color data in tile memory
Dynamic Rendering lets you render without full render passes/subpasses. The
VK_KHR_dynamic_rendering_local_read feature is a small but handy addition: it allows same‑pass
reads from attachments via tile/local memory paths on hardware that supports it.
515
15. How we approach it
• We enable the feature if present and keep codepaths compatible when it isn’t.
• We still end a rendering instance before doing layout transitions. The feature does not allow
arbitrary barrier misuse — regular Synchronization 2 rules apply.
• Keep stage/access masks precise. In this sample we keep transitions outside active rendering for
clarity.
◦ renderer_rendering.cpp
◦ renderer_pipelines.cpp
• Add a small “same-pass” effect that reads the current color attachment (e.g., a simple local
contrast or edge highlight).
• Add a debug HUD that prints whether the feature is enabled on the current device.
• Compare performance with and without local-read on tile-based GPUs (mobile) using a fixed
camera path.
• Frame Pacing = Forward, Forward+, and Deferred — choosing the right path
Vulkan lets you build many kinds of pipelines. In practice, most real‑time engines gravitate toward
one of three shading architectures: Forward, Forward+, or Deferred.
This page explains what each one is, why this sample chooses Forward+, and where the relevant
pieces live in the code.
516
20. Forward rendering
Forward draws each object with its lighting in a single pass. It’s the most direct model: bind a
material, bind lights (uniforms or textures/SSBOs), draw. It’s easy to reason about and integrates
well with transparency and MSAA.
Pros:
Cons:
• Per‑pixel light loops can get expensive as the number of lights grows.
• You evaluate lights even when most don’t affect the pixel.
Pros:
• Scales to many local lights; you only evaluate lights that might affect the pixel.
Cons:
• Requires a pre‑pass or depth info and a compute dispatch to build the tile lists.
Pros:
517
Cons:
If your project needs hundreds of shadowed lights and complex post‑lighting, explore a deferred
path or a hybrid: deferred for opaque, forward for transparent.
• Per‑frame SSBOs hold tile headers/light indices; the main PBR pass reads those to loop only
relevant lights.
• Descriptor updates happen at the frame’s safe point so we don’t touch in‑use sets.
◦ renderer_rendering.cpp
◦ renderer_pipelines.cpp
• Main PBR shader (reads per-tile light lists when Forward+ is enabled):
◦ shaders/[Link]
• Light count is low, transparency/MSAA are priorities, and you want the simplest pipeline.
518
• You want many local lights but still want forward’s strengths.
• You need to scale to many dynamic lights with complex lighting, and you’re ready to solve
transparency/MSAA separately.
There’s no one answer; pick the simplest that meets your needs. You can always grow the pipeline
later.
• Add clustered Forward+ (3D clusters using depth slices) instead of 2D tiles.
• Add shadows (start with a single directional shadow map, then add point/spot shadows).
• Add a small deferred path for opaque only (keep transparent as forward).
• Add ray query helpers for selective effects (reflection rays, shadow rays, or AO probes) without
building a full RT pipeline.
• Forward+ Rendering
Forward+ keeps the forward shading model you already know, but limits the per‑pixel light loop to
only the lights that might affect that pixel. It does this by dividing the screen into tiles (and
optionally Z‑slices) and building per‑tile light lists with a compute pass.
29. What we do
• Depth pre‑pass (optional): populates depth so the compute stage can cull by Z more effectively.
• Compute pass: assigns lights to tiles (and slices) and writes compact lists to SSBOs.
• Main PBR pass: for each pixel, fetch the tile header and iterate only those lights.
◦ renderer_rendering.cpp
519
• Shader-side light list consumption:
31. Tips
• Tune tile size; 16×16 is a reasonable default for 1080p.
• Upgrade from 2D tiles to clustered Forward+ (depth slicing and/or logarithmic Z).
• Add a small light “budget” UI and debug visualizations (tile heatmap) behind a development
build flag.
• Add shadowing (start with a single directional light shadow map) and extend the tile data to
include shadowed light indices.
Animations bring life to 3D scenes — spinning ceiling fans, rotating gears, walking characters. This
page explains how this engine loads and plays animations from glTF files, with a strong focus on
transform composition (the thing that keeps objects animating in place instead of “drifting
away”).
• Samplers: Provide keyframe data and interpolation methods (step, linear, or cubic spline)
When you export an animated model from Blender, Maya, or other 3D tools, the animation data
captures how nodes move over time relative to their initial transforms. This is crucial:
animations describe deltas (changes), not absolute world positions.
520
35. Understanding transform composition
Transform composition is the foundation of how animations work in 3D engines. Understanding
this concept is essential for implementing any animation system.
The core principle: Animation data in GLTF describes changes (deltas), not absolute positions.
When an artist animates a ceiling fan spinning in Blender, they’re defining how much it rotates
over time, not where it should be in world space.
Consider a ceiling fan at position (10, 5, 8) with an animation that rotates it. The animation
keyframes might specify: * Translation: (0, 0, 0) — no movement * Rotation: 0° → 360° around the Y
axis — spinning * Scale: (1, 1, 1) — no scaling
To display this correctly, we must compose the animation delta with the object’s base transform:
• Base transform: The object’s initial position/rotation/scale from the scene hierarchy
The composition rules are: * Translation: final = base + animDelta (addition) * Rotation: final =
base * animDelta (quaternion multiplication) * Scale: final = base * animDelta (component-wise
multiplication)
With proper composition, our ceiling fan remains at (10, 5, 8) and spins in place.
• Node transforms: Each GLTF node has a local transform matrix stored in
animatedNodeTransforms map
• Node-to-mesh mapping: Links node indices to mesh indices for entity matching
// In model_loader.cpp (conceptual)
std::unordered_map<int, glm::mat4> animatedNodeTransforms; // nodeIndex -> base
transform
std::unordered_map<int, int> animatedNodeMeshes; // nodeIndex -> meshIndex
std::vector<Animation> animations; // animation clips
521
36.2. 2. Scene setup: Create entities and apply base
transforms
In scene_loading.cpp, for each animated node:
• Create separate entities: If multiple nodes share the same mesh (like two ceiling fans), create
individual entities so each can animate independently
• Apply base transforms: Decompose the node’s transform matrix into position/rotation/scale
and set the entity’s TransformComponent
• Build nodeToEntity map: Links GLTF node indices to entity pointers for animation targeting
Critical insight: animated nodes that share geometry must have separate entities. GPU instancing
(one entity, multiple transforms) doesn’t work for individual animation control.
◦ model_loader.cpp
◦ model_loader.h
◦ scene_loading.cpp
◦ animation_component.cpp
◦ animation_component.h
◦ transform_component.cpp
◦ transform_component.h
522
38. Future work ideas
If you want to grow the animation system:
• Add an animation debug UI that shows the active clip/time per entity (development-only).
• Add “bake transforms” options (useful for static meshes that only need a single animated pose).
• Rendering_Pipeline_Overview.adoc
• Push_Constants_Per_Object.adoc
1. Capture base transforms on first frame: Store each entity’s initial position/rotation/scale
when animation starts
523
Translation: Additive
Rotations must be composed using quaternion multiplication to avoid gimbal lock and correctly
preserve rotation order. Always work in quaternion space during composition, then convert to
Euler angles only when setting the transform.
Scale: Multiplicative
Animation scale of (1, 1, 1) means "no change", (2, 1, 1) means "double X axis".
Why separate entities? * GPU instancing is designed for many identical, non-animated objects
(trees, rocks, grass) * Instance transforms are set once per frame; you cannot animate each instance
independently * Animation requires per-entity TransformComponents that update every frame
Each entity gets its own TransformComponent and can animate independently.
524
42. Keyframe interpolation
GLTF supports three interpolation modes:
Memory: Each animated entity needs: * Cloned mesh data (vertices, indices): ~100KB for a ceiling
fan * Transform storage: 3×vec3 = 36 bytes per node
Optimization tip: If you have hundreds of identical animated objects (e.g., grass blades), consider
GPU-side animation with compute shaders instead of per-entity CPU updates.
525
• GLTF supports weights channel for morph targets
Procedural animation
• Generate animation data at runtime (e.g., wind sway, noise-based motion)
• Transform Component: See transform_component.h for how we store and compute model
matrices
The key takeaway: Always compose animation transforms with base transforms. This
fundamental principle is what makes objects animate in their correct world positions while the
animation data itself describes relative changes. Understanding this composition is essential for
any animation system.
Now you have the foundation to implement GLTF animations in your own projects. Happy
animating! Ὠ = Mipmaps and Level of Detail (LOD)
Mipmaps reduce aliasing and bandwidth by sampling pre‑filtered versions of a texture. LOD is
simply “which mip do we use right now?”.
In this sample the key idea is: we want stable, good-looking texture sampling while assets are
streaming in, without turning texture management into a giant subsystem.
• For raw RGBA uploads, we cap auto‑generated mips to a small number to avoid large VRAM
spikes.
• Enable sampler anisotropy with a UI slider so you can see the trade‑offs quickly.
526
◦ renderer_resources.cpp
48. Tips
• Prefer compressed formats (BC/ASTC/ETC) with mips for big scenes.
• Add a per-material “mip bias” control (great for stylized looks and debugging shimmering).
• Add texture streaming by mip level (load low mips first, then refine).
You’ve probably noticed shiny floors and windows in real‑world scenes. In games, we often fake
that look. In this engine we chose a practical, reliable technique: planar reflections. This page
explains what they are, why we use them, how they’re implemented, and when you might want
something else.
• Scenes where you need stable, high‑quality reflections without heavy noise or temporal
instability.
527
• Arbitrary reflection directions (e.g., metals with complex micro‑geometry).
Planar reflections deliver all three. They also scale well across GPUs without requiring ray tracing
hardware.
◦ Compute a mirrored view matrix by reflecting the camera across a plane (e.g., Y=0 for a
ground plane).
◦ Render the opaque scene with face culling disabled (or adjusted) into a reflection
color+depth target.
◦ When drawing glass, sample the reflection texture and blend it with glass shading using
Fresnel + roughness + a user‑controlled “reflection intensity”.
That’s it. No special render graph magic, no ray queries, no temporal accumulation.
◦ renderer_rendering.cpp
◦ renderer_pipelines.cpp
◦ shaders/[Link]
◦ shaders/pbr_utils.slang
528
• Reflection binding and per-frame safe-point updates:
From that plane you build a reflection matrix R. Apply R to the regular camera view to get the
mirrored view. In practice you’ll also flip culling or set cullMode = none for the mirrored pass
because the winding order changes under reflection.
• A simple dot(product) with world position lets us discard fragments “behind” the plane in the
mirror pass.
• Create a reflection color image (format matches your main pass needs; we pick a color format
that the composite/glass pass can sample easily) and a reflection depth image.
• Begin dynamic rendering, bind the PBR pipeline for opaque objects, and disable culling (or flip
front faces).
• Render opaque meshes. You can add a clip test against the plane if needed.
• End rendering. Transition the reflection color image to SHADER_READ_ONLY_OPTIMAL for sampling
in the main pass.
Main pass:
• Render opaque as usual (we use an off‑screen buffer to do tone‑mapped composite later).
• Transparent pass: when drawing glass, sample the reflection texture and blend:
529
• Do not change image layouts inside an active dynamic render pass. End it first.
• Use vkCmdPipelineBarrier2 with: correct source/destination stage masks, access masks, and
old/new layouts.
• At the per‑frame “safe point” (when previous frame’s work is done), we refresh binding 10 for
the current frame to point to the reflection image from the previous frame.
• The glass shader checks a UBO flag (reflectionEnabled) and samples only when a valid
reflection image exists.
• Roughness factor: more roughness → weaker, blurrier reflections (we keep it simple here and
just dim the strength).
• Reflection intensity slider: exposed in the UI so you can tune visibility in seconds.
This is not a full physical spectral model, and that’s fine. It’s readable and produces convincing
results.
• Great for puddles and local effects, but can miss off‑screen objects and suffers from temporal
instability.
• Choose SSR if you want quick reflections everywhere and can accept occasional artifacts.
• Not view‑accurate for nearby objects; best for distant glossy reflections.
• Choose probes for general ambient reflections or when the surface isn’t a perfect mirror.
530
Ray tracing (hardware) / hybrid approaches
• Very accurate; supports complex reflections.
• Requires hardware and advanced denoising; more code and performance cost.
• Choose RT if you target high‑end GPUs and want “it just looks right” reflections everywhere.
• Perfect for one or a few large planar reflectors (floor, windows, calm water).
• Choose this when you want high‑quality mirrors for specific surfaces without adopting ray
tracing.
• Cull aggressively (our CPU frustum culling works for both camera and mirrored camera).
• Disable the mirror pass when the reflective surface isn’t visible.
• Consider blurring the reflection sample for rough surfaces if you want softer looks.
62. Troubleshooting
“Reflections appear too weak”
• Increase the Reflection intensity slider and/or reduce roughness.
• Add a roughness-aware blur of the reflection texture (mip chain or separable blur).
• Add a screen-space fallback (SSR) and blend with planar where valid.
• Add selective ray query reflections for non-planar surfaces (hybrid approach).
• Forward+ Rendering
531
Enjoy experimenting. This approach is intentionally straightforward so you can focus on learning
Vulkan’s moving parts without getting lost in a maze of techniques. = Push Constants — per‑object
material properties
Push constants are tiny pieces of data you can send to shaders without creating or updating buffers.
They’re perfect for per‑draw material knobs.
In this engine we use push constants for values that change per draw call (material factors and “is
there a texture?” flags). Anything that changes less frequently (camera data, light lists, big arrays)
stays in UBOs/SSBOs.
◦ renderer.h (MaterialProperties)
◦ shaders/[Link] ( block)
◦ shaders/pbr_utils.slang
◦ shaders/lighting_utils.slang
67. Guidelines
• Keep the block small (Vulkan guarantees at least 128 bytes). This sample fits comfortably.
• Use push constants for values that change every draw call. Use UBO/SSBO for larger,
less‑frequent data.
• Split “rarely changing per-material data” into a GPU material buffer and use push constants
only for the per-draw index.
• Add a second push constant block for per-draw debug visualizations (development-only) to keep
it out of hot UBO paths.
532
• Add a material override system (force roughness/metallic for entire scene) by layering a global
UBO on top.
Ray queries make it straightforward to add reflection and refraction to a renderer without adopting
a full ray tracing pipeline. In this engine, the Ray Query mode compute shader already computes
primary visibility; we extend that shader with secondary rays to handle reflective and
transmissive materials.
This page explains the design in a way you can reuse in your own projects.
• Max secondary bounces: 0 disables secondary rays entirely; 1 enables a single bounce.
The point of the bounce cap is to keep performance predictable while still demonstrating how ray
queries can be layered into a physically-based shading model.
R = reflect(-V, N)
In the compute shader we trace a new ray from a small offset along the normal to avoid self-
intersections:
• origin: P + N * eps
• direction: R
533
If the reflection ray hits something, we shade that hit using the same PBR path as the primary ray. If
it misses, we use a stable sky/background function.
We compute refraction using Snell’s law with a simple total internal reflection fallback.
• direction: refrDir
The transmitted result is blended with reflection using Fresnel, and then mixed into the base
surface color using the material’s transmission factor.
In a traditional ray tracing pipeline, alpha masking is often implemented in an any-hit shader. With
ray queries, we can implement the same idea by controlling which candidate intersections get
committed.
534
◦ accept the candidate only when alpha >= alphaCutoff
This produces correct visibility for masked geometry in primary rays, and it also keeps
reflections/refractions consistent because they use the same traversal routine.
◦ shaders/ray_query.slang
This engine includes a ray-traced rendering mode built on Vulkan’s ray queries. Instead of building
a full ray tracing pipeline (raygen / miss / hit shaders), ray queries let you perform intersection tests
directly from regular shaders.
In this sample we use ray queries from a compute shader to render the whole frame:
◦ Shades the hit using the same PBR utilities as the raster path.
• Write the result into a storage image, then composite to the swapchain.
• You can call them from compute, fragment, or other shader stages.
• They keep control flow in your shader code: you decide how to traverse, when to accept hits,
and how to shade.
• Acceleration structures: built from the scene’s vertex and index buffers.
• Descriptors: bind the TLAS, the output storage image, and the scene data needed for shading.
The important idea is that the ray query shader does not “own” the scene. It reads the same scene
assets as rasterization (meshes, materials, textures), but through a separate descriptor set designed
535
for the compute path.
• Each TLAS instance encodes a custom instance index so the shader can index into a matching
GeometryInfo table.
The Ray Query shader uses that per-instance index to look up:
At runtime:
• Materials store texture indices into the table (baseColor, normal, metallic-roughness, occlusion,
emissive).
• The renderer refreshes the table using the current texture handles.
• Slots 0..4 are reserved for shared default textures (so sampling always has a valid fallback).
536
This approach keeps shading simple in the shader: sampling uses NonUniformResourceIndex() and
SampleLevel(…, 0.0) (explicit LOD is important for compute).
After dispatch:
• A barrier transitions the Ray Query output image from GENERAL (write) to
SHADER_READ_ONLY_OPTIMAL (read).
• A fullscreen composite pass samples the output image and writes to the swapchain.
This lets the engine reuse the same post-processing controls (exposure/gamma) for both raster and
ray query paths.
◦ shaders/ray_query.slang
◦ renderer_ray_query.cpp
◦ renderer_rendering.cpp
This engine uses Vulkan Dynamic Rendering with a small, readable sequence of passes. The order is
deliberate: it keeps tone mapping explicit, keeps transparency sane, and makes it easy to plug in
optional features like planar reflections or the Ray Query compute path.
537
3. Composite to swapchain:
◦ Begin a new rendering instance targeting the swapchain image and draw a full‑screen pass
that samples the off‑screen color (tone mapping included).
4. Transparent on top:
◦ Keep the swapchain image as the color attachment and bind the scene depth.
5. UI:
◦ renderer_rendering.cpp
◦ renderer_pipelines.cpp
◦ shaders/[Link]
◦ shaders/[Link]
◦ shaders/pbr_utils.slang
◦ shaders/lighting_utils.slang
• Transparent ordering stays simple because the swapchain is the current color attachment in
that pass.
• Add a depth pre-pass for heavy scenes (helps early-z and enables more accurate Forward+
538
clustering).
• Add a lightweight bloom chain that runs between the off-screen opaque pass and the composite.
• Add a dedicated transparent resolve path (weighted blended OIT) if you need lots of
overlapping glass.
• Demonstrate hybrid rendering by calling ray queries from a raster shader (reflection probe,
shadow test, or glass-only reflections). = VK_EXT_robustness2 — safer defaults for real‑world
engines
Vulkan lets you run fast and close to the metal. That also means a bad index or out‑of‑range access
can produce undefined results. VK_EXT_robustness2 tightens that up so mistakes fail predictably
instead of corrupting memory or producing flicker.
• Robust image access 2 — out‑of‑range image coordinates clamp or return zero per the spec.
• Null descriptors — a descriptor can be left “null” and the shader sees a defined zero value
instead of UB.
These behaviors make the engine more forgiving while students iterate and while textures stream
in.
• Shaders are written assuming legal ranges, but if a streaming texture or optional binding is
temporarily missing, sampling a null descriptor is defined and safe.
• The Forward+/reflection paths avoid mid‑frame descriptor edits; robustness2 then acts as an
extra safety net.
◦ renderer_core.cpp
◦ vulkan_device.cpp
539
◦ shaders/ray_query.slang (bounds checks for geometryInfoCount / materialCount)
• Safe descriptor update patterns (so you don’t rely on robustness for correctness):
◦ Descriptor_Indexing_UpdateAfterBind.adoc
78. Takeaways
• Robustness doesn’t replace good synchronization and lifetime rules; it complements them.
• Null descriptors and “safe zero” reads make streaming and feature toggles less fragile.
• Add a development-only “fault injection” toggle that intentionally feeds out-of-range indices in a
controlled shader path.
• Add a small runtime report that prints whether VK_EXT_robustness2 is enabled on the current
device.
• Add a unit-style GPU test scene that exercises missing textures / missing buffers while keeping
VVL clean.
• Ray Query Rendering = Separate Image and Sampler Descriptors — when and why
Vulkan lets you bind an image view and a sampler either together (combined image sampler) or
separately. Combined bindings are simpler to teach and maintain. Separate bindings give you
flexibility (e.g., reuse one sampler across many images; change only the sampler states).
In this sample we default to combined image samplers because they keep the descriptor model
simple while we’re focused on bigger engine concepts (streaming, synchronization, pass structure).
540
updating every descriptor.
• If you introduce split bindings, document lifetime rules clearly: images and samplers can now
change independently.
◦ renderer_resources.cpp
◦ renderer_pipelines.cpp
◦ renderer_rendering.cpp
• Create a small sampler “library” (point/linear/aniso, wrap/clamp) and switch sampler indices
from the UI.
• Use shared samplers with a large texture table to reduce descriptor update volume during
streaming.
• Add per-material sampler selection (e.g., nearest for pixel art signage).
Some GPUs expose “tile” or “subpass” data paths that let shaders read from on‑chip color/depth
without a round trip to memory. VK_EXT_shader_tile_image is a portable way to tap into that.
In this sample we treat it as an optional optimization: the pipeline remains correct without it, and
we only take a “fast path” when the device advertises support.
541
87. What problem it solves
Post‑lighting texture reads from the just‑written color can be expensive. With tile image access,
certain patterns become cheaper and more deterministic on supported hardware.
• The renderer still uses clean Synchronization 2 barriers and ends dynamic rendering before
formal layout transitions. That keeps the code understandable on devices that don’t support tile
reads.
89. Guidance
Use it as an optimization. Write code that’s correct everywhere, then add tile‑image fast paths when
available.
◦ renderer_core.cpp
• Dynamic rendering setup and attachment transitions (kept explicit for clarity):
◦ renderer_rendering.cpp
◦ renderer_pipelines.cpp
• Add a small “local read” post effect that reads from the current color attachment and compares
with a regular sampled path.
• Add a device capability print (development-only) so students can see when the fast path is
active.
• Frame Pacing
542
Vulkan Synchronization 2 makes barriers and submissions easier to read. This sample uses it to
keep uploads and rendering in step without stalls.
• the CPU only mutates per-frame resources when it knows the GPU is done with them
• Graphics submit waits on the latest uploads value — by the time we draw, textures are ready to
sample.
• Frame fences — each frame‑in‑flight has a fence we wait on at the start of the next frame’s CPU
work.
Render path:
96. Takeaways
• Keep transitions outside active beginRendering/endRendering scopes.
• Pair timeline semaphores with fences: timelines coordinate queues; fences bound the CPU turn.
543
97. Where to look in the code
• Upload submission and timeline semaphore signaling:
◦ renderer_resources.cpp
◦ renderer_utils.cpp
◦ renderer_rendering.cpp
◦ renderer_rendering.cpp
◦ swap_chain.h
◦ renderer_rendering.cpp
• Add a UI toggle for the frames-in-flight count and measure input latency vs throughput.
• Add a “fixed camera path” mode (development-only) to produce repeatable GPU timing
comparisons.
• Add GPU timestamp queries around the big passes to visualize where time goes.
• Add async compute experiments (if your device supports it) for things like Forward+ light list
building.
Modern Vulkan gives us powerful tools to keep the GPU busy while assets stream in. This engine
uses a background uploader, a dedicated transfer queue, and Synchronization 2 to avoid stalls and
flicker. Let’s walk through the moving parts and how they fit together.
• GPU copies and layout transitions run on a transfer queue, not the graphics queue.
• A timeline semaphore lets graphics wait for “the latest finished upload” without
544
micro‑managing per‑resource fences.
• We only update descriptors at a safe point (right after waiting for the in‑flight frame’s fence) so
we never write into sets that the GPU is still using.
This keeps the frame loop simple and responsive—even while large textures stream in.
The render submit includes a wait on the latest uploads timeline value, so textures are available by
the time we draw.
• At the start of each frame, we wait for the fence associated with that frame‑in‑flight.
• Now it’s safe to update this frame’s descriptor sets (they aren’t in use).
• We refresh image bindings with the uploaded texture’s view/sampler at this point.
As a result there’s no texture “flip‑flop” or flicker: once a real texture replaces a placeholder, it
stays.
• After the final copy: transition to SHADER_READ_ONLY_OPTIMAL (src stage = eTransfer, dst stage =
eFragmentShader).
• Ownership transfers only if the transfer and graphics queues use different families (most
desktop drivers share families).
On the graphics side, we keep attachment layout transitions outside of any dynamic render pass
instance and also use vkCmdPipelineBarrier2 for readability.
545
104. A typical texture’s journey
1. Job enqueued with a file path.
5. Descriptor for this frame updates to point at the uploaded image (safe point).
• Use the transfer queue for bulk copies; keep the graphics queue focused on drawing.
• Prefer Synchronization 2 for clarity (stage/access pairs are explicit, transitions stand out).
• Batch uploads: the fewer submits, the lower the CPU overhead.
That’s all it takes to make streaming feel “invisible” to the player—and tidy to maintain.
◦ scene_loading.cpp
◦ resource_manager.cpp
◦ renderer_resources.cpp
◦ renderer_utils.cpp
◦ vulkan_device.cpp
◦ renderer_rendering.cpp
◦ Descriptor_Indexing_UpdateAfterBind.adoc
• Stream by mip level (low mips first), then refine in the background.
546
• Add a small streaming HUD (bytes queued, bytes uploaded, textures ready) behind a
development build flag.
• Add per-resource priorities (camera distance, importance tags) so the most noticeable assets
arrive first.
• Add “hot reload” for textures to validate descriptor lifetime rules under rapid churn.
• Descriptor_Indexing_UpdateAfterBind.adoc
• Ray_Query_Rendering.adoc :pp: ++
Appendix:
1. Detailed Architectural Patterns
This appendix provides in-depth information about common architectural patterns used in modern
rendering and game engines. These patterns are referenced in the main Engine Architecture
section, with a focus on Component-Based Architecture in the main tutorial.
3. Rendering Layer - Handles the rendering pipeline, shaders, and graphics API interaction.
4. Scene Management Layer - Manages the scene graph, spatial partitioning, and culling.
5. Application Layer - Handles user input, game logic, and high-level application flow.
547
1.1.3. Implementation Example
// Rendering Layer
class Renderer {
public:
virtual void Initialize(Platform* platform) = 0;
virtual void RenderScene(Scene* scene) = 0;
// ...
};
// Application Layer
class Application {
private:
Platform* platform;
ResourceManager* resourceManager;
Renderer* renderer;
SceneManager* sceneManager;
public:
void Run() {
platform->Initialize();
renderer->Initialize(platform);
// Main loop
while (running) {
548
platform->ProcessEvents();
sceneManager->UpdateScene(deltaTime);
renderer->RenderScene(sceneManager->GetActiveScene());
}
}
};
• Easier to parallelize
struct RenderData {
std::vector<Mesh*> meshes;
std::vector<Material*> materials;
};
class TransformSystem {
private:
TransformData& transformData;
public:
549
TransformSystem(TransformData& data) : transformData(data) {}
class RenderSystem {
private:
RenderData& renderData;
TransformData& transformData;
public:
RenderSystem(RenderData& rData, TransformData& tData)
: renderData(rData), transformData(tData) {}
void Render() {
// Render all entities in bulk
for (size_t i = 0; i < [Link](); ++i) {
// Render mesh with transform
}
}
};
550
1.3.3. Implementation Example
// Service locator
class ServiceLocator {
private:
static IAudioService* audioService;
static IAudioService nullAudioService; // Default null service
public:
static void Initialize() {
audioService = &nullAudioService;
}
// Usage example
void PlayGameSound() {
ServiceLocator::GetAudioService().PlaySound("explosion");
}
551
1.4. Comparative Analysis of Architectural Patterns
Below is a comparative analysis of the architectural patterns discussed in this appendix:
552
• Physically Based Rendering: From Theory to Implementation - [Link]
1. Geometry Pass - Render scene geometry to G-buffer textures (position, normal, albedo, etc.).
1. Light Culling Pass - Divide the screen into tiles and determine which lights affect each tile.
2. Forward Rendering Pass - Render scene geometry with only the lights that affect each tile.
• Camera Collision: Implement a collision volume for the camera to prevent it from passing
through walls
• Context-Aware Positioning: Adjust camera position based on the environment (e.g., zoom out
in large open areas, zoom in in tight spaces)
• Intelligent Framing: Adjust the camera to keep both the character and important objects in
frame
• Camera Obstruction Transparency: Make objects that obstruct the view partially transparent
553
• Dynamic Field of View: Adjust the FOV based on movement speed or environmental context
3. Conclusion
These architectural patterns and rendering techniques provide a foundation for designing your
rendering engine. In practice, most engines use a combination of these patterns to address different
aspects of the system.
2. Flexibility Needs - How much flexibility do you need for future extensions?
3. Team Size and Experience - More complex architectures may be harder to work with for
smaller teams.
4. Project Scope - A small project may not need the complexity of a full ECS.
554