Finishing the tutorial[1] left me with one hardcoded model on screen. I wanted to grow that into a small engine: a free-fly camera, an Application that owns the scene, model and material abstractions, an asset manager that dedups everything, and an ImGui editor to control everything at runtime.
Adding a camera
The first thing I wanted was the ability to move around the viking room model.
I split movement into two classes: Camera computes the math for the view and projection matrices that the renderer uses and CameraController reads keyboard/mouse input and decides how the camera should move, calling Camera to update these matrices. Keeping them separate means the camera does not know GLFW exists, and the controller does not know how a view matrix is built.
// camera.hpp
class Camera {
public:
Camera(enginemath::Vec3 _position, f32 _aspect,
f32 _yaw = enginemath::toRad(-90.0f),
f32 _pitch = 0.0f, f32 _fov = enginemath::toRad(45.0f),
f32 _nearPlane = 0.1f, f32 _farPlane = 100.0f);
enginemath::Vec3 getForward() const;
enginemath::Vec3 getRight() const;
enginemath::Mat4 getView() const;
enginemath::Mat4 getProj() const;
void rotate(f32 dYaw, f32 dPitch);
void move(enginemath::Vec3 delta);
};
The forward vector is derived from yaw and pitch, where right is found with the cross product of the forward and up vectors. The view matrix is a lookAt matrix built with the position and forward vectors. Projection is a normal perspective matrix that is flipped since Vulkan's clip space has Y pointing down.
WASD input gets accumulated into one vector per frame and normalized before CameraController handles it, so diagonal movement is the same speed as going straight. I also added mouse look.
It goes through an exponential smoothing filter (smoothFactor of 0.3) so the raw pixel deltas do not feel jittery. Since mouse look being active all the time was annoying, it only happens when your right mouse button is held.
Diffuse lighting
I then added a simple diffuse lighting model so that surfaces facing a light are brighter than surfaces facing away.[5]
Each light has a position and a color. I store them in a uniform buffer as a fixed size array plus a lightCount, capped at MAX_LIGHTS. The alignas(16) on each Vec3 is becasue the shader's std140 uniform block aligns to 16 bytes.[6]
// Light object struct
struct R_LightObject {
alignas(16) enginemath::Vec3 position = enginemath::Vec3(0.0f);
alignas(16) enginemath::Vec3 color = enginemath::Vec3(1.0f);
};
// The uniform object for light objects
struct R_LightUbo {
R_LightObject lights[MAX_LIGHTS];
int lightCount;
};
R_Light owns one of these uniform buffers per frame in flight and is bounded next to the camera UBO in the same per-frame descriptor set (set 0, binding 1). Every frame, updateLights rebuilds a fresh R_LightUbo from the scene's lights (clamped to MAX_LIGHTS) and memcpys it into the mapped buffer.
Rebuilding from scratch each frame allows deleted lights to get removed, so there is no stale data in the scene.
// Updating lightUbo with new positions based on the scene
void Rath::R_Light::updateLights(u32 currentImage,
const std::unordered_map<u32, R_SceneLight>& lights) {
R_LightUbo lightUbo{};
size i = 0;
for (const auto& [id, light] : lights) {
if (i >= MAX_LIGHTS) break;
lightUbo.lights[i].position = light.position;
lightUbo.lights[i].color = light.color;
i++;
}
lightUbo.lightCount = i;
memcpy(lightBuffersMapped[currentImage], &lightUbo, sizeof(lightUbo));
}
The vertex shader passes two extra parameters to the fragment shader: surface normal rotated into world space, and the fragment's world position.
The fragment shader loops over lightCount, and for each light it takes the dot of the light direction and normal. This value scales the light's color, and the accumulated diffuse is multiplied by the object's push constant color and its texture.
// basic.vert
fragNormal = mat3(PushConstants.model) * inNormal;
modelLocation = (PushConstants.model * vec4(inPosition, 1.0)).xyz;
// basic.frag
vec3 diffuse = vec3(0.0);
for (int i = 0; i < lightUbo.lightCount; i++) {
vec3 lightDir = normalize(lightUbo.lights[i].position - modelLocation);
vec3 norm = normalize(fragNormal);
float NDiff = max(dot(norm, lightDir), 0.0);
diffuse += NDiff * lightUbo.lights[i].color;
}
vec3 result = diffuse * PushConstants.color * texture(texSampler, fragTexCoord).rgb;
outColor = vec4(result, 1.0);
Application Refactor
After the tutorial, the renderer created the model, held it, and drew it. This meant the renderer owned the game state which should be avoided. So I refactored the ownership so Application owns everything, and the renderer only draws whatever scene it is given.
Application owns the window, input, camera, and the big Vulkan objects behind unique_ptr, plus the scene itself. Those Vulkan objects are forward declared in the header and only included in the cpp, which keeps vulkan.h out of application.hpp.
// application.hpp
class Application {
private:
Window window;
Input input;
Camera camera;
CameraController cameraController;
std::unique_ptr context;
std::unique_ptr device;
std::unique_ptr buffer;
std::unique_ptr renderer;
R_Scene rScene;
u32 objectIndex = 0;
u32 lightIndex = 0;
};
The application's main loop now controls the whole engine. It measures delta time, polls the window, updates the camera from input, handles spawn/removals (explained in a later section), updates the scene, and hands the scene to the renderer to draw.
// Application's main loop
void Rath::Application::mainLoop() {
auto lastTime = std::chrono::high_resolution_clock::now();
while (!window.shouldClose()) {
auto currentTime = std::chrono::high_resolution_clock::now();
f32 deltaTime = std::chrono::duration<float, std::chrono::seconds::period>
(currentTime - lastTime).count();
lastTime = currentTime;
camera.setDeltaTime(deltaTime);
window.pollEvents();
cameraController.checkCameraMovement();
cameraController.checkMouse();
cameraController.updateCamera();
if (!rScene.pendingSpawns.empty()) handlePendingSpawns();
if (!rScene.pendingRemovals.empty()) handlePendingRemovals();
updateScene();
renderer->drawFrame(rScene);
}
renderer->wait();
}
R_Scene
I wanted a way to easily load and unload models in my application. I decided to do this with scenes, where every R_Scene holds a specific scene object in a map (for ease of access) as well as containers for removing/adding to the scene.
// Scene struct
struct R_Scene {
std::unordered_map<u32, R_SceneObject> objects;
std::unordered_map<u32, R_SceneLight> lights;
std::vector<PendingSpawn> pendingSpawns;
std::vector<PendingRemoval> pendingRemovals;
};
// Scene object struct
struct R_SceneObject {
R_Model* model = nullptr;
enginemath::Mat4 baseTransform = enginemath::Mat4::identity();
enginemath::Mat4 transform = enginemath::Mat4::identity();
enginemath::Vec3 position = enginemath::Vec3(0.0f);
enginemath::Vec3 color = enginemath::Vec3(1.0f);
i32 id = -1;
};
// Light object struct
struct R_SceneLight {
R_Model* model = nullptr;
enginemath::Vec3 position = enginemath::Vec3(0.0f);
enginemath::Vec3 color = enginemath::Vec3(1.0f);
i32 id = -1;
};
I created structs to hold information for the scene objects (which are just currently R_SceneObject for objects and R_Light for lights). Each of these structs holds a position, id, and a pointer to a R_Model object, which will be discussed later on. You may notice that R_SceneObject has both a position, base transform, and a normal transform while R_SceneLight only has a position.
This is because I wanted R_SceneLight to be rotation invariant for now (like a floating glowing orb) and R_SceneObject's position/orientation in the scene to be reliant on baseTransform (which is the original orientation of the object), position (which is the location in a scene), and any code for movements/animations. All 3 of these components are then used to calculate the object's transform
which is then used as a push constant into my vert shader. In my code for example, my updateScene() function takes every
single object in my current scene and rotates them around the X axis:
// Used to update the orientation of every object per frame
void Rath::Application::updateScene() {
f32 t = camera.getElapsedTime();
for (auto& [id, obj] : rScene.objects) {
obj.transform = enginemath::Mat4::translationM(obj.position) *
obj.baseTransform *
enginemath::Mat4::rotateX(std::sin(t));
}
}
// Snippets of CmdBuffer Record + Shader showcasing push constant usage
// Push constant struct
struct MeshPushConstant {
enginemath::Mat4 model;
alignas(16) enginemath::Vec3 color;
};
// renderer.cpp (drawing every object in rScene, push constant usage)
for (auto& [id, obj] : rScene.objects) {
obj.model->bind(commandBuffer);
obj.model->bindPipeline(commandBuffer);
obj.model->bindDescriptors(commandBuffer);
MeshPushConstant modelPushConstant{
obj.transform, obj.color
};
vkCmdPushConstants(commandBuffer, pipeline.getPipelineLayout(),
VK_SHADER_STAGE_VERTEX_BIT |
VK_SHADER_STAGE_FRAGMENT_BIT, 0,
sizeof(MeshPushConstant), &modelPushConstant);
obj.model->draw(commandBuffer);
}
// basic.vert
layout (push_constant) uniform Push {
mat4 model;
vec3 color;
} PushConstants;
{...}
void main() {
gl_Position = ubo.proj * ubo.view * PushConstants.model * vec4(inPosition, 1.0);
{...}
R_Model, R_Material, and the asset manager
Each scene object holds an R_Model*, so the next question was what a model is and how it's made. I didn't want the scene to own geometry, and I did not want to load the same mesh twice.
R_Model and R_Material are both created with a similar workflow to Vulkan's wrappers: use the corresponding CreateInfo struct for the object to fill in necessary information and a static rCreate function that abstracts the model and material creation, reporting success through a bool, with the object filled in through an output pointer.
This is the same approach as Vulkan's API (vkCreateX(&info, ..., &handle)).
// Create info for a model
struct R_ModelCreateInfo {
VkPipeline pipeline = VK_NULL_HANDLE;
VkPipelineLayout pipelineLayout = VK_NULL_HANDLE;
std::string modelPath;
R_Material* material = nullptr;
};
// Create info for a material (which is used in R_ModelCreateInfo)
struct R_ModelMaterialCreateInfo {
std::string texturePath;
};
R_Model stores its vertex and index buffers and holds a pointer to R_Material and the pipeline it draws with. R_Material has the texture, its descriptor set, and descriptor pool. Separating them this way means the resources that change per texture live on the material, and the ones that change per frame live elsewhere.
The piece that connects these objects together with R_Scene is a simple R_AssetManager. It keeps track of every model and material through unique_ptr maps and dedups them by key. Models are keyed by modelPath | texturePath and materials by texturePath, so asking for the same asset twice hands back the existing pointer instead of loading it again.
// Function to set up and dedup a model
Rath::R_Model* Rath::R_AssetManager::setUpModel(const std::string modelPath,
const std::string texturePath) {
std::string key = modelPath + "|" + texturePath;
auto it = rModels.find(key);
if (it != rModels.end()) {
return it->second.get();
}
auto mIt = rMaterials.find(texturePath);
if (mIt == rMaterials.end()) {
std::unique_ptr<R_Material> material = std::make_unique<R_Material>();
R_ModelMaterialCreateInfo rMaterialCreateInfo{};
rMaterialCreateInfo.texturePath = texturePath;
R_Material::rCreateMaterial(device, buffer, descriptor, image,
rMaterialCreateInfo, material.get());
rMaterials[texturePath] = std::move(material);
}
std::unique_ptr<R_Model> model = std::make_unique<R_Model>();
R_ModelCreateInfo rModelInfo{};
rModelInfo.pipeline = pipeline.getGraphicsPipeline();
rModelInfo.pipelineLayout = pipeline.getPipelineLayout();
rModelInfo.material = rMaterials[texturePath].get();
rModelInfo.modelPath = modelPath;
R_Model::rCreateModel(device, buffer, rModelInfo, model.get());
rModels[key] = std::move(model);
return rModels[key].get();
}
The scene only ever has borrowed R_Model* pointers, where the asset manager owns the actual objects. Keying the dedup this way means loading the same model and texture combination twice is free.
The ImGui editor
My final addition was making the scene editable in real time, instead of just being hardcoded with setUpScene. I used the ImGui library[2] for UI and built a small editor on top of the scene.
The frame model makes three calls: startFrame begins the ImGui frame, drawUI builds the windows for this frame, and draw records ImGui's draw data into the command buffer. The editor has a panel to move and delete each light, a panel to move and delete each object, and an add menu that picks a location, color, and type (mesh or light) for a new object.
For object spawning and removal, I used the two pending vectors in R_Scene. On spawn/removal, the UI pushes the corresponding request into the matching vector, where Application handles and empties the vectors at the top of the next frame, before drawFrame. A spawn pushes a PendingSpawn, a delete pushes a PendingRemoval.
// How spawns and removals get handled (ui.cpp)
for (auto& [id, model] : rScene.objects) {
ImGui::Text(("Update Model " + std::to_string(id)).c_str());
ImGui::PushID(id);
ImGui::DragFloat3("Position", &model.position[0], 0.25, -5.0f, 5.0f);
ImGui::PopID();
if (ImGui::Button(("Delete Model " + std::to_string(id)).c_str())) {
PendingRemoval removal{};
removal.id = id;
removal.type = Rath::R_SCENE_TYPE::R_SCENE_TYPE_OBJECT;
rScene.pendingRemovals.push_back(removal);
}
}
// in application.cpp, which clears the spawn vector every frame
void Rath::Application::handlePendingSpawns() {
for (auto& spawn : rScene.pendingSpawns) {
if (spawn.type == Rath::R_SCENE_TYPE::R_SCENE_TYPE_OBJECT) {
R_SceneObject newObject{};
newObject.model = renderer->loadModel(MODEL_PATH, TEXTURE_PATH);
newObject.baseTransform = spawn.baseTransform;
newObject.transform = spawn.transform;
newObject.position = spawn.position;
newObject.color = spawn.color;
newObject.id = objectIndex;
rScene.objects[objectIndex++] = newObject;
}
else {
R_SceneLight sceneLight{};
sceneLight.model = renderer->loadModel(MODEL2_PATH, TEXTURE2_PATH);
sceneLight.position = spawn.position;
sceneLight.color = spawn.color;
sceneLight.id = lightIndex;
rScene.lights[lightIndex++] = sceneLight;
}
}
rScene.pendingSpawns.clear();
return;
}
// in application.cpp, which clears the remove vector every frame
void Rath::Application::handlePendingRemovals() {
for (auto& removal : rScene.pendingRemovals) {
if (removal.type == Rath::R_SCENE_TYPE::R_SCENE_TYPE_OBJECT) {
rScene.objects.erase(removal.id);
}
else if (removal.type == Rath::R_SCENE_TYPE::R_SCENE_TYPE_LIGHT) {
rScene.lights.erase(removal.id);
}
}
rScene.pendingRemovals.clear();
return;
}
An issue I came across while implementing the UI: ImGui identifies a widget by the hash of its label, so every object using the label "Position" collided into one shared id, which both fired an assert and routed the drag to the wrong object. This was fixed by using PushID(id) / PopID() which gives them distinct identities using the model/light ID.
Next steps
I want to replace my current model loading library (tinyobj)[3] with glTF[4], because OBJ can only store static meshes and has no support for animation. I am doing this now because in a couple weeks, I will be headed to Hack the North[7] and want to create something using Vulkan and animations, so I was thinking I would learn glTF now to get more prepared.