Low-level renderer for communicating with the OpenGL and Vulkan APIs.
The abstract interface can be vaguely categorized into 4 important components:
- Abstract interface
- OpenGL backend
- Vulkan backend
- Resources
The abstract interface of the level 1 renderer provides a unified interface to the low-level rendering capabilities of the GPU. It is independent of the specific backends, i.e. usage is the same no matter which backend is selected at runtime. The level 2 renderer should always use the abstract interface and should rarely, if ever, have to interact with the OpenGL/Vulkan backends themselves. At runtime, the interface instantiates either an OpenGL or a Vulkan backend depending on what graphics API is available.
Both the OpenGL and Vulkan backends are, for the most part, direct implementations of the abstract interface in their domain-specific APIs. We won't go into much detail about their features in this document, especially since there are much better explanations of the concepts behind both OpenGL and Vulkan available.
The resources
namespace provides classes for initializing and loading meshes, textures, shaders, etc.
These classes are independent from the specific OpenGL/Vulkan backends and can thus be passed to the
abstract interface of the renderer renderer to make them usable with graphics hardware.
Code examples can be found in the renderer demos. See the testing docs on how to try them out.
To create the renderer, we first have to create a window that the renderer can draw into. This should be done
with the static method renderer::Window::create(..)
which also automatically determines whether the
OpenGL or Vulkan backend should be used.
std::shared_ptr<Window> window = Window::create("title", 1024, 768);
By default, OpenGL is chosen, so the created window object for the above example should have type
renderer::opengl::GlWindow
. The specific type should never be relevant for usage, however, since
all interaction can be done via the abstract interface.
The created window can be used to add and initialize a renderer, returning a pointer to the
abstract renderer::Renderer
interface.
std::shared_ptr<Renderer> renderer = window->make_renderer();
Behind the scene, this again is initialized as a specific type determined by the type of the
window implementation behind the scenes. I.e., a GlWindow
will make a GlRenderer
when
calling this method.
Shaders can be added in two steps. First, we have to load the shader source code from a
string or a file. To do this, we can use the renderer::resources::ShaderSource
class as
seen below.
resources::ShaderSource vshader_src = resources::ShaderSource(
resources::shader_lang_t::glsl,
resources::shader_stage_t::vertex,
"#version 330\nvoid main() {}"
);
util::Path shader_path = root_dir / "assets" / "shaders";
resources::ShaderSource fshader_src = resources::ShaderSource(
resources::shader_lang_t::glsl,
resources::shader_stage_t::fragment,
shader_path / "source.frag"
);
Afterwards, we can create the shader program from the shader sources by passing them to
the add_shader()
method of our renderer instantiation.
std::shared_ptr<ShaderProgram> shader_prog = renderer->add_shader( { vshader_src, fshader_src } );
openage's ShaderProgram
encapsulates all shader units that should be run in one iteration
of the OpenGL/Vulkan graphics pipeline. Therefore, we have to supply at least a vertex shader
and a fragment shader source, since these stages are mandatory in both the OpenGL and the
Vulkan graphics pipeline.
To use our shader program, we have to define something that it can operate on. These "somethings"
are called renderables in openage and basically represent an object that we want to draw on screen.
Creating a renderer::Renderable
object only requires the definition of two parameters:
- Vertex inputs for the vertex shader stage
- Uniform inputs for any defined uniforms in the shader stages of the shader program
The renderer provides a method to create a simple 4-vertex quad that spans the entire
viewport. Doing this creates a renderer::Geometry
that also manages the underlying buffers
on the GPU and the associated vertex data:
std::shared_ptr<Geometry> geom = renderer->add_bufferless_quad();
Bufferless quads are usually enough to draw anything rectangular, e.g. sprites. However, the renderer is also able to handle more complex geometry with changing vertex data which is described in this section.
Uniform inputs are created from the shader program that they are defined in by calling
renderer::ShaderProgram::new_uniform_input(..)
on it. This creates a new UniformInput
object
that will store the unique uniform input values for the renderable that we want to display.
Usually, a new UniformInput
object should be created for each renderable.
std::shared_ptr<UniformInput> input = shader_prog->new_uniform_input(
"color", Eigen::Vector3f{ 0.0f, 1.0f, 0.0f },
"time", 0.0f,
"num", 1337
);
Note that the definition order doesn't matter and the method doesn't differentiate between different shader stages, so uniform inputs for vertex and fragment shaders can be freely mixed.
Input values are passed to the method in pairs consisting of the uniform ID and the input value. Uniform IDs can either be the uniform name from the shader source (as shown above) or a numeric ID that is determined at load time by the shader program. Numeric ID usage is explained in this section.
Uniform input values are automatically converted to the correct types expected by the uniform
definition, e.g. a uint8_t
for a uniform with type uint
will be transformed to the
correct type.
After creating a renderer::UniformInput
, it can be updated at any time, e.g. when preparing
the next frame:
input->update(
"condition", false
);
From the geometry and the uniform input objects, we can finally create the renderer::Renderable
object that we want to display.
Renderable obj {
input,
geom
};
Graphics operations using a shader program are executed by organizing renderables in a render pass. Render passes render multiple objects into a single display target, e.g. the application window.
Creating a render pass from the renderer only requires passing a list of renderables and the
display target that should be used. The window the renderer was created from is the default
display target and can be acquired by calling renderer::Renderer::get_display_target()
.
std::shared_ptr<RenderPass> pass = renderer->add_render_pass({ obj }, renderer->get_display_target())
Render passes can also be updated with new renderables:
pass->add_renderables({ obj });
Finally, we can execute the rendering pipeline for all objects in the render pass:
renderer->render(pass);
After rendering is finished, the window has to be updated to display the rendered result.
window->update();
These are some of the more advanced features of the renderer.
Numeric uniform IDs are unique identifiers for a uniform in a shader program. They are
assigned at load time and can be used to address uniforms instead of their string names.
The type used for numeric IDs is renderer::uniform_id_t
. The numeric ID of a uniform
can be fetched from the shader program using the uniform name by calling the
renderer::ShaderProgram::get_uniform_id(..)
method.
uniform_id_t color_id = shader_prog->get_uniform_id("color");
uniform_id_t time_id = shader_prog->get_uniform_id("time");
uniform_id_t num_id = shader_prog->get_uniform_id("num");
std::shared_ptr<UniformInput> input = shader_prog->new_uniform_input(
color_id, Eigen::Vector3f{ 0.0f, 1.0f, 0.0f },
time_id, 0.0f,
num_id, 1337
);
Setting uniform values via numeric IDs can be much faster than using strings as string lookups are avoided. This is especially useful for uniforms which are updated very frequently, e.g. every frame. However, this requires that the IDs are fetched at runtime and have to be stored somewhere.
Sometimes it is useful to render the scene in multiple passes, e.g. for post-processing of rendered objects or simply for organizing different rendering stages. To do this, a render pass can be instructed to render into an intermediary texture attached to a framebuffer.
std::shared_ptr<Window> window = Window::create("title", 1024, 768);
std::shared_ptr<Renderer> renderer = window->make_renderer();
... // shader program initialization
std::shared_ptr<Geometry> geom = renderer->add_bufferless_quad();
std::shared_ptr<UniformInput> input1 = shader_prog->new_uniform_input();
Renderable obj1{input1, geom};
std::shared_ptr<Texture2d> color_texture = renderer->add_texture(
resources::Texture2dInfo(
1024,
768,
resources::pixel_format::rgba8
)
);
std::shared_ptr<RenderTarget> target = renderer->create_texture_target({ color_texture });
std::shared_ptr<RenderPass> pass1 = renderer->add_render_pass({ obj1 }, target);
The color texture assigned as the display target for the render pass can be assigned as a uniform input value in subsequent render passes.
std::shared_ptr<UniformInput> input2 = shader_prog->new_uniform_input(
"tex", texture
);
Renderable obj2{input2, geom};
std::shared_ptr<RenderPass> pass2 = renderer->add_render_pass({ obj2 }, renderer->get_display_target());
A color texture is not the only type of texture that can be assigned to a texture target. We can also add depth textures or additional (color) textures that the shader can write arbritrary values into.
std::shared_ptr<Texture2d> depth_texture = renderer->add_texture(
resources::Texture2dInfo(
1024,
768,
resources::pixel_format::depth24 // 24 Bit depth values
)
);
std::shared_ptr<Texture2d> id_texture = renderer->add_texture(
resources::Texture2dInfo(
1024,
768,
resources::pixel_format::r32ui // unsigned integer
)
);
std::shared_ptr<RenderTarget> target = renderer->create_texture_target({color_texture, depth_texture, id_texture});
std::shared_ptr<RenderPass> pass = renderer->add_render_pass({ obj }, target);
Attaching a depth texture is required for enabling optional depth testing for a renderable. Depth testing can be activated per renderable:
Renderable obj {
input,
geom
};
obj.depth_test = true;
Layers give more fine-grained control over the draw order of renderables in a render pass. Every layer has a priority that determines when associated renderables are drawn. Lower priority renderables are drawn earlier, higher priority renderables are drawn later.
In comparison to using multiple render passes, layers do not require the (expensive) switching of framebuffers between passes. The tradeoff is a slight overhead when inserting new renderables into the render pass.
To assign renderables to a layer, we have to specify the priority in the RenderPass::add_renderables(..)
function call.
Renderable obj {
input,
geom
};
pass->add_renderables({ obj }, 42);
For existing layers, new renderables are always appended to the end of the layer. Renderables are sorted into the correct position automatically when they are added:
pass->add_renderables({ obj1, obj2, obj3 }, 42);
pass->add_renderables({ obj4 }, 0);
pass->add_renderables({ obj5, obj6 }, 1337);
pass->add_renderables({ obj7 }, 0);
// draw order: obj4, obj7, obj1, obj2, obj3, obj5, obj6
// layers: prio 0 | prio 42 | prio 1337
When no priority is specified when calling RenderPass::add_renderables(..)
, the highest
priority is assumed (which is std::numeric_limits<int64_t>::max()
). Therefore,
objects added like this are always drawn last. It also means that these two calls are equal:
pass->add_renderables({ obj });
pass->add_renderables({ obj }, std::numeric_limits<int64_t>::max());
Layers are created lazily during insertion if no layer with the specified priority exists yet. We can also create layers explicitly for a specific priority:
pass->add_layer(42);
When executing the rendering pipeline for a specific pass, renderables are drawn layer by layer. By default, the renderer clears the depth buffer when switching to a new layer. This is done under the assumption that layers with higher priority should always draw over layers with lower priority, even when depth tests are active. This behavior can be deactivated when explicitly creating a layer:
// keep depth testing
pass->add_layer(42, false);
For displaying complex geometry like 3D objects or non-rectangular surfaces, the renderer allows the definition meshes that can be configured down to the individual vertex information.
Consider the vertices used for the creation of a bufferless quad which essentially is a textured rectangle. Every vertex has to store its position as a 2D coordinate as well as its associated texture coordinates (also 2D). Therefore, we need 4 coordinates for each vertex, so 4 vertices result in 16 coordinates in total.
std::array<float, 16> verts = {
{
-1.0f, 1.0f, 0.0f, 1.0f, // top left
-1.0f, -1.0f, 0.0f, 0.0f, // bottom left
1.0f, 1.0f, 1.0f, 1.0f, // top right
1.0f, -1.0f, 1.0f, 0.0f // bottom right
}
};
We also need to define the layout in a resources::VertexInputInfo
struct so that the
renderer can properly initialize the underlying vertex buffer.
resources::VertexInputInfo info{
{ resources::vertex_input_t::V2F32, resources::vertex_input_t::V2F32 },
resources::vertex_layout_t::AOS,
resources::vertex_primitive_t::TRIANGLE_STRIP
};
As seen above, we have to define 3 parameters.
- Vertex Input Layout: Defines how the vertex data of each vertex is split up in the vertex shader. In this case, a vertex consists of a
vec2
for the position and avec2
for the texture coordinates. - Vertex Buffer Layout: Defines how the vertex data of all vertices is layed out in the whole buffer.
AOS
is array of structs which means that vertex data is interleaved. - Vertex Primitive: Type of primitive used for drawing the vertices.
Afterwards, vertices can be copied into a byte array which is then passed
alongside the vertex info to the resources::MeshData
constructor.
auto const vert_data_size = verts.size() * sizeof(float);
std::vector<uint8_t> vert_data(vert_data_size);
std::memcpy(vert_data.data(), reinterpret_cast<const uint8_t *>(verts.data()), vert_data_size);
resources::MeshData mesh{ std::move(vert_data), info };
The resulting mesh can then be used to create a renderer::Geometry
object from the
renderer. This will also create the vertex buffer on the GPU.
std::shared_ptr<Geometry> geom = renderer->add_mesh_geometry(mesh);
In addition to regular vertex meshes, the renderer also supports indexed rendering with an index buffer. This is useful in scenarios where vertices get revisited very often, e.g. in dense 3D meshes.
std::array<uint16_t, 16> idxs { 0, 1, 2, 1, 3, 4, 1 };
When indexed rendering should be used, we have to pass one additional parameter to the
resources::VertexInputInfo
struct that specifies the layout of the index buffer:
resources::VertexInputInfo info{
{ resources::vertex_input_t::V2F32, resources::vertex_input_t::V2F32 },
resources::vertex_layout_t::AOS,
resources::vertex_primitive_t::TRIANGLES,
resources::index_t::U16 // index size -> 16 Bit unsigned integer
};
Furthermore, we have to copy the indices into a byte array and pass it to the mesh constructor.
auto const idx_data_size = idxs.size() * sizeof(uint16_t);
std::vector<uint8_t> idx_data(idx_data_size);
std::memcpy(idx_data.data(), reinterpret_cast<const uint8_t *>(idxs.data()), idx_data_size);
resources::MeshData mesh{ std::move(vert_data), std::move(idx_data), info };
Uniform buffers provide a storage- and performance-efficient way to pass uniform input values to multiple shader programs. Uniform inputs inside a uniform buffer are de-facto global variables that can be accessed by any shader. They are best used for uniform values that change infrequently or are the same across many shader iterations. A good use case example are camera matrices which are at most updated once per frame and may be used in different shader programs.
There are two ways the openage renderer can create uniform buffers. Option 1 is to
create the uniform buffer from a named uniform block in an already loaded shader program, which is
discussed below. Option 2 creates the uniform buffer from a renderer::resources::UniformBufferInfo
object which manually specifies the uniforms and layout of the buffer.
std::shared_ptr<ShaderProgram> shader_prog = renderer->add_shader( { vshader_src, fshader_src } );
std::shared_ptr<UniformBuffer> buffer = renderer->add_uniform_buffer(shader_prog, "unif_block");
To make the shader actually use the buffer, the shader's uniform block has to be bound to the buffer first. This tells the GPU to fetch data for the uniform block from the uniform buffer during a shader iteration.
shader_prog->bind_uniform_buffer("unif_block", buffer);
Setting uniform input values in the buffer works very similar to regular uniform inputs for shaders. Instead of fetching a new uniform input object from a shader, we fetch it from the buffer object we created.
std::shared_ptr<UniformBufferInput> buff_input = buffer->new_uniform_input(
"color", Eigen::Vector3f{ 0.0f, 1.0f, 0.0f },
"time", 0.0f,
"num", 1337
);
This creates a renderer::UniformBufferInput
that can be updated like their counterparts for regular
uniform inputs.
buff_input->update(
"condition", false
);
An additional step is required to upload the input values to the GPU. For regular uniform inputs, this is done automatically for each renderable in a render pass. Since uniform updates are usually updated much less frequently or irregularly, the buffer has to be manually requested to transfer the input values to the buffer on the GPU:
buffer->update_uniforms(buff_input);
Instead of creating the uniform buffer from an existing shader program, in some cases it can be beneficial to manually define the buffer. This is particularly useful in scenarios where a shader program is not loaded yet or if the uniform buffer is bound to multiple different shader programs.
Manually defining the buffer requires you to specify the uniform block layout as well as the name and input
type of each uniform in the buffer. From these definitions, a renderer::resources::UniformBufferInfo
object can be initialized, which can then be passed to the renderer to create the buffer.
resources::UBOInput view_input{ "view", resources::ubo_input_t::M4F32 };
resources::UBOInput proj_input{ "proj", resources::ubo_input_t::M4F32 };
resources::UniformBufferInfo ubo_info{
resources::ubo_layout_t::STD140,
{ view_input, proj_input }
};
std::shared_ptr<UniformBuffer> buffer = renderer->add_uniform_buffer(ubo_info);
This level might or might not be threadsafe depending on the concrete backend. The OpenGL version is, in typical GL fashion, so not-threadsafe it's almost anti-threadsafe. All code must be executed sequentially on a dedicated window thread, the same one on which the window and renderer were initially created. The plan for the Vulkan version is to make it at least independent of thread-local storage and hopefully completely threadsafe.