Vertex buffers
Introduction
Change directory into linuxviewer/src/examples and change git branch to ‘hello_vertex_buffer’ by typing:
git switch hello_vertex_buffer
The directory hello_triangle/ disappeared and a new directory hello_vertex_buffer/ has appeared.
This new directory contains an example, based on hello_triangle, but with added support for a vertex buffer.
In this chapter we will describe the changes that were made.
You can view all those changes as a diff with the command:
git diff hello_triangle..hello_vertex_buffer
while inside the example/ directory. You might want to do that before reading this chapter.
While inside the example directory, type
git branch
to see an overview of all examples.
hello_vertex_buffer
VertexData.h
Our vertex buffer contains per-vertex data, and will replace the hardcoded values in the vertex shader: the position and color of each vertex.
The way linuxviewer works is that you define a structure—that has to be filled on the CPU and used by the GPU—using provided macros, allowing linuxviewer to deduce the exact layout and make sure that all alignments and offsets are correct. The memory layout used by the GPU is a function of where the data is used (vertex buffers, or uniform buffers…) and is not the same as the normal C++ layout of structs.
#pragma once
#include <vulkan/shader_builder/VertexAttribute.h>
struct VertexData;
The struct must be forward declared. The pragma once
and include
speak for themselves.
LAYOUT_DECLARATION(VertexData, per_vertex_data)
{
static constexpr auto struct_layout = make_struct_layout(
LAYOUT(vec2, m_position),
LAYOUT(vec3, m_color)
);
};
This teaches the vulkan engine the layout of the struct VertexData
, which is defined below.
The types used, vec2
and vec3
, are equivalent to the types used in the STRUCT_DECLARATION
although defined by the base class which is hidden by the macros. In the case of LAYOUT_DECLARATION
they are types that specify size, alignment, stride, etc. type-traits as function of the used standard,
while in the case of STRUCT_DECLARATION
they are complex types derived from the actual C++
type that you’d normally use. Completely different types thus.
Never use a namespace in front of these types.
The possible names for the first argument of the LAYOUT
macro are defined in the struct(s) SHADERBUILDER_STANDARD::TypeEncodings
or it can be a type that was declared using LAYOUT_DECLARATION
itself!
Most notably, the “builtin” types start with an upper case: Float
, Double
, Bool
, Int
, etc. because, unfortunately, using lowercases won’t compile.
You can specify an array by adding square brackets with the array size (which obviously must be a constexpr) after the type. For example: LAYOUT(vec4[5], my_array)
defines a vec4 my_array[5];
member.
Note that we explicitly specify that this is per_vertex_data
,
which both, influences the memory layout and specifies the input rate;
the other option for vertex buffers is per_instance_data
.
// Struct describing data type and format of vertex attributes.
STRUCT_DECLARATION(VertexData)
{
MEMBER(0, vec2, m_position);
MEMBER(1, vec3, m_color);
};
This is the actual C++ struct with the per-vertex data.
We’re going to make a vertex buffer with three of these,
as if the vertex buffer were an array VertexData buffer[3];
.
The first argument of the first MEMBER
must be 0. Each subsequent line must increment the value by 1. The next line would be MEMBER(2, ...);
and then MEMBER(3, ...);
and so on.
The remaining arguments must be a verbatim copy of the arguments used for the corresponding LAYOUT
macros.
The position here is stored as a vec2
because, just
like it was hardcoded in the vertex shader; we only
store an x- and y-value.
The color is stored as a vec3
, a vector of three floats for the RGB values,
also just like as it was hardcoded in the shader.
Note that like this the data will be interleaved, alternating position and colors. It would totally be possible to make two vertex buffers, one with the position and one with the colors; in which case it more closely resembles the layout used in hello_triangle.
Triangle.h
The Triangle
class is derived from vulkan::shader_builder::VertexShaderInputSet
,
which in turn is derived from vulkan::DataFeeder
.
It is used to fill our array of VertexData
objects.
#pragma once
#include "VertexData.h"
#include "vulkan/shader_builder/VertexShaderInputSet.h"
class Triangle final : public vulkan::shader_builder::VertexShaderInputSet<VertexData>
{
// A batch exists of one triangle, or three vertices.
//
// A
// / \
// / \
// / \
// B-------C
//
static constexpr int number_of_vertices = 3;
// The positions of the triangle corners are stored as 2D vectors; the z and w value are set in the shader.
static Eigen::Vector2f const s_position[number_of_vertices];
// Colors are stored as a triplet of three float values for Red, Green and Blue respectively.
static Eigen::Vector3f const s_color[number_of_vertices];
The DataFeeder
works as follows: first the virtual function chunk_count()
is called
to obtain the total number of VertexData
objects in the vertex buffer.
That is, in the case of VertexShaderInputSet
, chunk_count()
returns a vertex count. Then next_batch()
and get_chunks()
are called in a loop until all data has been retrieved. Here next_batch()
returns the number of chunks (VertexData
objects in our case) that will
be filled at once by the next call to get_chunks()
.
private:
// Returns the total number of VertexData objects in the vertex buffer.
int chunk_count() const override
{
return number_of_vertices;
}
// Returns the number of VertexData objects that are initialized by a single call to create_entry.
int next_batch() override
{
return number_of_vertices;
}
In this case we have number_of_vertices
vertices, returned by chunk_count()
and
we’ll fill all data in one go, hence that next_batch()
returns the full amount.
// Initialize the next `number_of_vertices` VertexData objects.
void create_entry(VertexData* input_entry_ptr) override
{
for (int vertex = 0; vertex < number_of_vertices; ++vertex)
{
input_entry_ptr[vertex].m_position = s_position[vertex];
input_entry_ptr[vertex].m_color = s_color[vertex];
}
}
};
This last function copies the data from the static tables into the VertexData
objects
of the vertex buffer.
Even though the type of m_position
and m_color
are complex templates
the make sure the memory layout of each object matches what the GPU expects,
they are derived from the types glsl::vec2
and glsl::vec3
respectively and those types can also be assigned to them.
Triangle.cxx
#include "sys.h"
#include "Triangle.h"
Eigen::Vector2f const Triangle::s_position[number_of_vertices] = {
{ 0.0f, -0.5f }, // A
{ -0.5f, 0.5f }, // B
{ 0.5f, 0.5f }, // C
};
Eigen::Vector3f const Triangle::s_color[number_of_vertices] = {
{ 1.0f, 0.0f, 0.0f }, // A
{ 0.0f, 1.0f, 0.0f }, // B
{ 0.0f, 0.0f, 1.0f }, // C
};
These are the same values as we had hardcoded into the vertex shader at first.
Window.h
Add additional required headers:
#pragma once
+#include "Triangle.h"
+#include <vulkan/VertexBuffers.h>
#include <vulkan/SynchronousWindow.h>
Add to your Window
class the member vulkan::VertexBuffers m_vertex_buffers;
, which
will represent all vertex buffers. And add a member Triangle m_triangle;
, the class
that we added above, that we will use to fill our vertex buffer:
vulkan::shader_builder::ShaderIndex m_shader_vert;
vulkan::shader_builder::ShaderIndex m_shader_frag;
+ // Vertex buffers.
+ vulkan::VertexBuffers m_vertex_buffers;
+
+ // Vertex buffer generators.
+ Triangle m_triangle;
+
Now that we have a vertex buffer, we need to implement create_vertex_buffers()
instead of leaving it empty:
void create_render_graph() override;
+ void create_vertex_buffers() override;
void register_shader_templates() override;
void create_graphics_pipelines() override;
void render_frame() override;
- // We're not using textures or vertex buffers.
+ // We're not using textures.
void create_textures() override { }
- void create_vertex_buffers() override { }
Window.cxx
#include "Window.h"
#include "TrianglePipelineCharacteristic.h"
+
+// Required header to be included below all other headers (that might include vulkan headers).
+#include <vulkan/lv_inline_definitions.h>
Perhaps we should have done this before, because Window.cxx
is a .cxx file and it uses
linuxviewer objects. We could get away with omitting this header because nothing was
using inline functions, but after adding the vertex buffer this compilation unit stops
compiling without this header.
If you forget to include <vulkan/lv_inline_definitions.h>
then the first time you compile you might get a warning, after that the only thing left might be a linker error. In this case, when omitting the header, we get:
/usr/bin/ld: src/examples/hello_vertex_buffer/CMakeFiles/hello_vertex_buffer.dir/Window.cxx.o: in function `Window::create_vertex_buffers()':
src/examples/hello_vertex_buffer/Window.cxx:55: undefined reference to `void vulkan::VertexBuffers::create_vertex_buffer<VertexData>(vulkan::task::SynchronousWindow const*, vulkan::shader_builder::VertexShaderInputSet<VertexData>&)'
/usr/bin/ld: src/examples/hello_vertex_buffer/CMakeFiles/hello_vertex_buffer.dir/Window.cxx.o: in function `Window::draw_frame()':
src/examples/hello_vertex_buffer/Window.cxx:131: undefined reference to `vulkan::handle::CommandBuffer::bindVertexBuffers(vulkan::VertexBuffers const&)'
Make sure you recognize the error, so you know what you forgot when you run into it. Unfortunately, the missing functions might be different, so all you can go on is the undefined reference
here.
Therefore, pay attention to the warning, which is much clearer about what is missing:
src/vulkan/CommandBuffer.h:85:38: warning: inline function 'vulkan::handle::CommandBuffer::bindVertexBuffers' is not defined [-Wundefined-inline]
[[gnu::always_inline]] inline void bindVertexBuffers(VertexBuffers const& vertex_buffers);
^
/home/carlo/projects/aicxx/linuxviewer/linuxviewer/src/examples/hello_vertex_buffer/Window.cxx:131:20: note: used here
command_buffer.bindVertexBuffers(m_vertex_buffers);
^
We need to remove the #version
line from the shader code, otherwise we can’t
make use of the automatic generation of the vertex buffer declaration.
If a #version
line is present then linuxviewer won’t touch the shader code at all.
constexpr std::string_view triangle_vert_glsl = R"glsl(
-#version 450
-
Remove the hardcoded values from the vertex shader:
-vec2 positions[3] = vec2[](
- vec2(0.0, -0.5),
- vec2(-0.5, 0.5),
- vec2(0.5, 0.5)
-);
-
-vec3 colors[3] = vec3[](
- vec3(1.0, 0.0, 0.0),
- vec3(0.0, 1.0, 0.0),
- vec3(0.0, 0.0, 1.0)
-);
-
void main()
And then simply refer to the m_position
and m_color
members of our VertexData
structure as follows:
- gl_Position = vec4(positions[gl_VertexIndex], 0.0, 1.0);
- fragColor = colors[gl_VertexIndex];
+ gl_Position = vec4(VertexData::m_position, 0.0, 1.0);
+ fragColor = VertexData::m_color;
Note that the type VertexData
corresponds to an instance of a vertex buffer. If you have more than one vertex buffer, they all need to use a different type for the data that they contain.
Every time main()
of the vertex shader is entered, a different VertexData
element of the vertex buffer
will be accessed, iterating over those elements in the vertex buffer as specified by the
draw
call recorded in the command buffer.
If there is more than one instance, by providing a per_instance_data
vertex buffer (see above), then the
process starts over, once for each instance (as specified by the
draw
call recorded in the command buffer).
In other words, it is as if the vertex shader function is inside a loop that runs over all
per_vertex_data
elements of a vertex buffer, that itself is inside a loop that runs over all
per_instance_data
elements of a “per instance” vertex buffer. In reality this is not done sequentially
of course, but massively in parallel.
+void Window::create_vertex_buffers()
+{
+ DoutEntering(dc::vulkan, "Window::create_vertex_buffers() [" << this << "]");
+
+ m_vertex_buffers.create_vertex_buffer(this, m_triangle);
+}
+
void Window::register_shader_templates()
This is the implementation of the—now—overridden virtual function create_vertex_buffers()
.
All it does is call create_vertex_buffer
on the vulkan::VertexBuffers
member that we
added to our Window
class, passing the this
pointer to our window, and the vertex buffer
generator m_triangle
.
Finally, change Window::draw_frame
to bind the vertex buffer(s) to the pipeline:
command_buffer.beginRenderPass(main_pass.begin_info(), vk::SubpassContents::eInline);
command_buffer.setViewport(0, { viewport });
command_buffer.setScissor(0, { scissor });
+ command_buffer.bindVertexBuffers(m_vertex_buffers);
TrianglePipelineCharacteristic.h
TrianglePipelineCharacteristic(vulkan::task::SynchronousWindow const* owning_window COMMA_CWDEBUG_ONLY(bool debug)) :
vulkan::pipeline::Characteristic(owning_window COMMA_CWDEBUG_ONLY(debug))
{
- // Required when we don't have vertex buffers.
- m_use_vertex_buffers = false;
}
No longer set m_use_vertex_buffers
to false!
If you leave this in (but make all other changes) you’ll get the following ASSERT
:
ThreadPool01 COREDUMP : | linuxviewer/src/vulkan/pipeline/AddShaderStage.cxx:175: void vulkan::pipeline::AddShaderStage::preprocess1(const shader_builder::ShaderInfo &): Assertion `!m_shader_variables.empty()' failed.
Whenever you run into an assert, carefully read the comment above the assert. With a bit of luck it will explain what you did wrong.
In this case that comment contains,
// Perhaps you (still) have `m_use_vertex_buffers = false;` in the constructor of
// (one of) your pipeline Characteristic? If so, start with removing that line.
At the bottom of the initialize()
member function add:
add_shader(window->m_shader_frag);
+
+ // Register the vertex buffer.
+ add_vertex_input_bindings(window->m_vertex_buffers);
}
HelloTriangle.h
std::u8string application_name() const override
{
- return u8"Hello Triangle";
+ return u8"Hello Vertex Buffer";
}
That was the last change that we made. Added for completeness, as it is clearly not related to adding a vertex buffer.
hello_two_vertex_buffers
Instead of using one vertex buffer, VertexData
(remember: one type corresponds to one vertex buffer),
we could have used –say– VertexPosition
and VertexColor
, separating positions and colors into two vertex buffers:
VertexPosition.h
#pragma once
#include <vulkan/shader_builder/VertexAttribute.h>
struct VertexPosition;
LAYOUT_DECLARATION(VertexPosition, per_vertex_data)
{
static constexpr auto struct_layout = make_struct_layout(
LAYOUT(vec2, m_position)
);
};
// Struct describing data type and format of vertex attributes.
STRUCT_DECLARATION(VertexPosition)
{
MEMBER(0, vec2, m_position);
};
VertexColor.h
#pragma once
#include <vulkan/shader_builder/VertexAttribute.h>
struct VertexColor;
LAYOUT_DECLARATION(VertexColor, per_vertex_data)
{
static constexpr auto struct_layout = make_struct_layout(
LAYOUT(vec3, m_color)
);
};
// Struct describing data type and format of vertex attributes.
STRUCT_DECLARATION(VertexColor)
{
MEMBER(0, vec3, m_color);
};
And then have two vertex buffer generators, for example Position
and Color
, instead of Triangle
, that
only fill respectively m_position
and m_color
of the above structs.
Define both in your Window
class:
// Vertex buffer generators.
- Triangle m_triangle; // Vertex buffer.
+ Position m_position;
+ Color m_color;
and add both to m_vertex_buffers
:
- m_vertex_buffers.create_vertex_buffer(this, m_triangle);
+ m_vertex_buffers.create_vertex_buffer(this, m_position);
+ m_vertex_buffers.create_vertex_buffer(this, m_color);
And change the shader code to use VertexPosition::m_position
and VertexColor::m_color
instead of VertexData::
for both:
void main()
{
- gl_Position = vec4(VertexData::m_position, 0.0, 1.0);
- fragColor = VertexData::m_color;
+ gl_Position = vec4(VertexPosition::m_position, 0.0, 1.0);
+ fragColor = VertexColor::m_color;
}