The purpose of this section is to introduce you to the internal operation of our 3D engine. We will first talk about the architecture of the renderer. Then we will describe the sequencing of the engine graphic’s loop, how each component of the Renderer::Target
is interacting with the Render::Window
composed of different Renderer::View
. Then, we will discuss the GPU & CPU’s side operation. We will explain how each buffer is loaded and used by our engine.
We decided to be as API independent as possible, i.e. we do not want to be too much dependent on Vulkan itself. This is why we created abstract classes for each type and their Vulkan-equivalent in a separate, API specific directory. This is especially visible in here. Hypothetically speaking, this allows us to be much less dependent on this technology and maybe one day, to derive the implementation for another low-level API, such as D3D12 for example.
The main object of the renderer is the Render::Target
. A Render::Target
is any surface on which we can render, e.g. a window or an offscreen image.
A Render::Target
can have multiple Render::View
s, each representing a fraction of the Render::Target
, defined by a Render::View::Viewport
and a Render::Scissor
defined as following:
class Viewport {
public:
struct {
float x;
float y;
} offset;
struct {
float width;
float height;
} extent;
float minDepth;
float maxDepth;
inline float getRatio() const;
};
struct Scissor {
struct {
float x;
float y;
} offset;
struct {
float width;
float height;
} extent;
};
Each of the components of Render::View::Viewport
and Render::View::Scissor
are defined as percentage values (i.e. a float between 0.0 an 1.0), so it has the same appearance on every size of the Render::Target
.
A unique Render::Camera
can be attached to a single Render::View
, i.e. we cannot have a Render::Camera
attached to two different Render::View
s.
Render::Camera
s contain a Render::Queue
and have pointer to a Scene::Scene
, which is created by the user, and can be attached to multiple cameras.
Every frame, the Render::Queue
is cleared, then filled by the Scene::Scene
with the objects visible by the Render::Camera
’s frustrum.
The Render::Queue
is finally sent to Vulkan::Render::Technique::Technique::render()
.
In the diagram here, we are representing the main classes of the renderer and their dependencies.
The diagram here shows an example of how classes interact with each other:
Render::Target
, which contains three Render::View
s:
In this section will be presented the rendering of a single frame with the help of two sequence diagrams, here and here. The second is a subset of the first, as they have been separated to ease readability.
Let us describe this sequence diagram, step by step:
First, UserApplication
is the user-defined class that inherits from lug::Core::Application
and defines the methods onEvent
and onFrame
. Application::run()
is called (and must be) by the user like in this example:
int main(int argc, char* argv[]) {
UserApplication app;
if (!app.init(argc, argv)) {
return EXIT_FAILURE;
}
if (!app.run()) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
The method Core::Application::run()
is the main loop of the engine which polls the events from the window and renders everything correctly. As expected, we can see that the Core::Application
is polling all the events from the Render::Window
and sending them to the UserApplication
through the method UserApplication::onEvent(const lug::Window::Event& event)
.
Then, Core::Application
is calling the method Renderer::beginFrame()
which call itself the method Render::Window::beginFrame()
to notify the Render::Window
that we are starting a new frame.
Finally, the user can update the logic of their application in the method UserApplication::onFrame(const lug::System::Time& elapsedTime)
.
At the end of the frame, the method Renderer::endFrame()
is called and will call the method Render::Target::render()
for all Render::Target
to draw and will finish the frame by calling the method Render::Window::endFrame()
to notify the Render::Window
that we are ending this frame.
In the method Render::Target::render()
, the Render::Target
is calling the method Render::View::render()
for each enabled Render::View
.
To be rendered, Render::View
needs to update its Render::Camera
which will fetch all the elements in its Render::Queue
from the scene with Scene::fetchVisibleObjects()
.
So the Render::Queue
will contain every elements needed to render the Scene::Scene
, meshes, models, lights, etc.
Then the Render::View
can call the render technique to draw the the elements in the Render::Queue
(e.g. for Vulkan a class inheriting from Vulkan::Render::Technique::Technique
).
The Vulkan::Render::Window
and the Vulkan::Render::View
s of Lugdunum are pretty straightforward. For simplicity’s sake we have split this process into five steps:
Each arrow represents a Vulkan semaphore for synchronization purpose.
VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL
Vulkan::Render::View
in parallelVK_IMAGE_LAYOUT_PRESENT_SRC_KHR
For steps 2 and 4 we are using one Vulkan command buffer per image in the swapchain. Each of the command buffers are built beforehand, therefore we don’t need to rebuild them each frame. Step 3 is dependent on the render technique used.
Since our semaphores are stored in a pool, we let each method (beginFrame()
, endFrame()
, …) select their own semaphore(s) to use.
The method Vulkan::Render::Window::beginFrame()
is used to accomplish steps 1 and 2. This method chooses one semaphore to be notified when the next image is available and chooses N semaphores to notify each Vulkan::Render::View
when the image has changed layout. (N being the number of Vulkan::Render::View
in the Vulkan::Render::Window
)
The method Vulkan::Render::Window::render()
is used to accomplish step 3. This method uses the N previous semaphores, one for each call to Vulkan::Render::View::render()
. Each Vulkan::Render::View
has a semaphore which is signaled when the view has finished rendering. We will explain how the render technique works in the next part.
The method Vulkan::Render::Window::endFrame()
is used to accomplish steps 4 and 5. This method retrieves all the semaphores from the Vulkan::Render::View
and chooses one semaphore to be notified when the image has changed layout.
The Vulkan::Render::Technique::Forward
has two different Vulkan::Render::Queue
, i.e. one transfer and one graphics.
The transfer Render::Queue
is responsible for updating the data of the Render::Camera
and Light::Light
s, each of which is contained in a uniform buffer Vulkan::API::Buffer
which is sent through different Vulkan::API::CommandBuffer
s (i.e. “Command buffer A” and “Command buffer B” in the above schema).
These Vulkan::API::CommandBuffer
s are then sent to the transfer Render::Queue
.
Here is the structure of the uniform buffers for the camera and the lights:
// Camera
layout(set = 0, binding = 0) uniform cameraUniform {
mat4 view;
mat4 proj;
};
// Directional light
layout(set = 1, binding = 0) uniform lightUniform {
vec3 lightAmbient;
vec3 lightDiffuse;
vec3 lightSpecular;
vec3 lightDirection;
};
// Point light
layout(set = 1, binding = 0) uniform lightUniform {
vec3 lightAmbient;
float lightConstant;
vec3 lightDiffuse;
float lightLinear;
vec3 lightSpecular;
float lightQuadric;
vec3 lightPos;
};
// Spot light
layout(set = 1, binding = 0) uniform lightUniform {
vec3 lightAmbient;
vec3 lightDiffuse;
vec3 lightSpecular;
float lightAngle;
vec3 lightPosition;
float lightOuterAngle;
vec3 lightDirection;
};
Each type of light has a different pipeline using different fragment shaders (That’s why all the light uniforms are using the same binding point in the above code sample).
To pass the transformation matrix of the objects we are using pushconstant:
layout (push_constant) uniform blockPushConstants {
mat4 modelTransform;
} pushConstants;
The graphics Render::Queue
is responsible for all the rendering.
The “Command buffer C” for the drawing depends on the two command buffers of transfer by means of semaphores at different stages of the pipeline, VK_PIPELINE_STAGE_VERTEX_INPUT_BIT
for the camera and VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT
for the lights.
The allocation of the uniform buffers is managed by a Vulkan::Render::BufferPool
, one for the camera and one for the lights.
As we do not want to perform lots of allocations, we mitigate this using the pool which will allocate a relatively large chunk of memory on the GPU, that will itself contain many Vulkan::Render::BufferPool::SubBuffer
s.
A Vulkan::Render::BufferPool::SubBuffer
is a portion of a bigger Vulkan::API::Buffer
that can be allocated and freed from the pool and bind with a command buffer without worrying about the rest of the Vulkan::API::Buffer
.
Because we are using triple buffering, we need a way to store data for a specific image. For that we have Vulkan::Render::Technique::Forward::FrameData
that contains all we need to render one specific frame (command buffers, depth buffer, etc.). To avoid using a command buffer already in use, we are synchronizing their access with a fence.
To share Vulkan::Render::BufferPool::SubBuffer
across frames, e.g. if the camera does not move, we have a way to reuse the same Vulkan::Render::BufferPool::SubBuffer
. We associate the Vulkan::Render::BufferPool::SubBuffer
with the object (camera or light), and test at the beginning of the frame if we can use a previous one (if the object has not changed from the update of this Vulkan::Render::BufferPool::SubBuffer
).
If it is not possible to use a previously allocated buffer we are allocating a new one from the Vulkan::Render::BufferPool
.
Here is the pseudo code that we are using to build the command buffer of drawing:
BeginCommandBuffer
# The viewport and scissor are provided by the render view
SetViewport
SetScissor
BeginRenderPass
# We can bind the uniform buffer of the camera early
# It is the same everywhere
BindDescriptorSet(Camera)
# All the lights influencing the rendering (visible to the screen)
Foreach Light
# Each type of Light has a different pipeline
BindPipeline(Light)
# We can bind the uniform buffer of the light
BindDescriptorSet(Light)
# All the objects influenced by the light
Foreach Object
# Push the transformation matrix of the Object
PushConstant(Object)
# We use indexed draw, so we need to bind
# the index and the vertex buffer of the object
BindVertexBuffer(Object)
BindIndexBuffer(Object)
DrawIndexed(Object)
EndForeach
EndForeach
EndRenderPass
EndCommandBuffer