blog:2022:1109_nervland_vertexbuffers

NervLand: Vertex buffers, bindings and attributes

In the last post, we introduced suppport for the Vulkan Memory Allocator to take care of our memory allocations. With this in place, we can now try to extend a bit on our display system and try to vertex buffers to provided the data we want to draw instead of hardcoding the value in our shader. And in this process we can start the design a subsystem to create various simple shapes like a plane, a cube, a sphere, etc.

  • As recommended in vulkan, we should create a single large buffer object, and place our vertex data in there to create the different bindings we may need.
  • Then, instead of providing separated bindings for the vertex attributes (like position, normals, uvs, etc) we should by default try to pack then together into a single binding (except if some of those values are changing dynamically for instance): this should not change much in terms of performances, but I believe this is the recommended path by default too.
  • Once we have some Vertex buffers and “some content to draw”, logically, we should bind a graphics pipeline, then bind our vertex buffers, and then perform a draw call. In this process, the VertexInputState in the pipeline should contain the info on how to access the data from those input buffers. But then we could consider drawing multiple objects with a single pipeline attached, and then multiple draw calls…
  • Except that I think it's generally not recommanded to have too many draw calls (in opengl at least, and to some extent this may still apply in bit in Vulkan too), so I think it make more sense to see those vertex buffers as a “collection of data” for similar objects that should be drawn with the same kind of pipeline 🤔. But I might be pushing the thinking too far for the now, and I should start with a simple implementation any way, so let's see how we can set this up with an evolutive design.
  • So now I have this initial VulkanVertexBuffer class:
    class NVVULKAN_EXPORT VulkanVertexBuffer : public VulkanObject {
        NV_DECLARE_NO_COPY(VulkanVertexBuffer)
        NV_DECLARE_NO_MOVE(VulkanVertexBuffer)
      public:
        struct AttributeDesc {
            U32 location;
            VkFormat format;
            U32 offset;
        };
    
        using AttributeDescList = nv::Vector<AttributeDesc>;
    
        /** Constructor used to create a dedicated buffer for this Vertex buffer. */
        VulkanVertexBuffer(
            VulkanDevice* dev, U64 size,
            VkBufferUsageFlags usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT);
    
        /** Constructor used to use an existing Buffer from a given start offset
         * position. */
        VulkanVertexBuffer(VulkanBuffer* buffer, U64 size, U64 startOffset);
    
        ~VulkanVertexBuffer() override;
    
        /** Add a vertex attribute */
        void add_attribute(U32 location, VkFormat format);
    
        /** Retrieve the vertex size*/
        auto get_vertex_size() const -> U32 { return _vertexSize; }
    
      protected:
        /** The actual Buffer object associated to this vertx buffer.*/
        nv::RefPtr<VulkanBuffer> _buffer;
    
        /** Offset start position inside the underlying Buffer object. */
        U64 _bufferStartOffset{0};
    
        /** Total size in byte of this VertexBuffer */
        U64 _size{0};
    
        /** Size of a single vertex in bytes (as computed from the attributes.) */
        U32 _vertexSize{0};
    
        /** Attribute descriptions */
        AttributeDescList _attributeDescs;
    };
  • I already added a lot of helper functions in this class to setup the attributes, like:
        /** Add an rgba32f attribute **/
        void add_rgba32f(U32 location) {
            add_attribute(location, VK_FORMAT_R32G32B32A32_SFLOAT);
        }
  • So with this we could build a VertexBuffer with a given total size, and then describe some attributes in it. For instance if we have vec3 position, and vec2 uv coords for each vertex we could do: something like this in lua:
    vertSize = 5*4  -- 5 floats per vertex.
    vbuf = nvk.VulkanVertexBuffer(vertSize*1000)
    vbuf:add_rgb32f(0) -- location 0, vec3 position
    vbuf:add_rg32f(1) -- location 1, vec2 uv
I must add those attributes in the proper order to compute the “offset” correctly, but I think that's an acceptable constraint
  • Next I need a way to provide a binding description for this VertexBuffer, this should be provided in the VulkanPipelineVertexInputStateCreateInfo class. The binding number should not mean much I think 🤔 as long as I setup the attribute location <-> bindings correctly afterwards, so it should be OK to just “add” a VertexBuffer, and select whatever is the next binding index (depending on the already available binding list)
  • So here is the function I create for that:
    auto VulkanPipelineVertexInputStateCreateInfo::addVertexBuffer(
        const VulkanVertexBuffer& vbuf, I32 bindingIndex)
        -> VulkanPipelineVertexInputStateCreateInfo& {
        // Check if we should use an automatic binding index:
        if (bindingIndex == -1) {
            bindingIndex = (I32)getCurrentBindingDescList().size();
        }
    
        // Add the binding:
        addBindingDesc(bindingIndex, vbuf.get_vertex_size(), vbuf.get_input_rate());
    
        // Next we also add all the attributes provided by this vertex buffer:
        const auto& attribs = vbuf.get_attributes();
        for (const auto& attrib : attribs) {
            addAttributeDesc(attrib.location, attrib.format, attrib.offset,
                             bindingIndex);
        }
    
        return *this;
    }
  • Important note: In the code above, we assume that when we bind a given VertexBuffer, then we will want to use the same location indices for all the shaders relying on the data provided in this vertex buffer. This could be an hard constraint in some cases 🤔 ? But I won't take this as a serious concern for the moment.
  • ⇒ So now we can bind of VertexBuffer to a given pipeline, good. Next question is: how do we actually add content to this VertexBuffer ? We need a mechanism to write data starting from a given position, and writting a given number of bytes:
        /** write data into this vertex buffer */
        void set_data(const void* data, U64 dataSize,
                      U64 startPosition = nv::U64_MAX);
  • But with the possibility to write data in the underlying buffer now comes the question of the “host mappability” of that buffer. Checking the VMA doc, this means I should provide either the flag VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT or VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT when allocating the buffer: let's first add support for that.
  • Okay, so now, to create a VulkanBuffer (either from the Engine or the Device object) we can pass an additional buffer create flag:
        auto create_buffer(U64 size, VkBufferUsageFlags usage,
                           BufferCreateFlags flags = BUFFER_CREATE_FLAG_NONE,
                           U32 devIdx = 0) -> U64;
  • If we use the flag BUFFER_CREATE_FLAG_SEQUENTIAL_HOST_WRITE we will get a sequential_writable buffer, and if we use BUFFER_CREATE_FLAG_RANDOM_HOST_ACCESS we get a full random access mappable buffer. Let's also add a function to check that a given buffer is “host visible”:
        /** Check if this buffer is mappable on host **/
        auto is_host_visible() const -> bool;
  • Next we have the question of the host memory coherency: according to the VMA doc on windows this should not be a problem (all host visible memory have the HOST_COHERENT flag) but on other systems we need to check that I guess: let's plan for this already, adding an additional _hostCoherent flag in our buffer:
        // Use the VMA allocator to create this resource:
        VmaAllocationCreateInfo allocInfo = {};
        allocInfo.usage = VMA_MEMORY_USAGE_AUTO;
        switch (flags) {
        case BUFFER_CREATE_FLAG_SEQUENTIAL_HOST_WRITE:
            allocInfo.flags =
                VMA_ALLOCATION_CREATE_HOST_ACCESS_SEQUENTIAL_WRITE_BIT |
                VMA_ALLOCATION_CREATE_MAPPED_BIT;
            _hostVisible = true;
            break;
        case BUFFER_CREATE_FLAG_RANDOM_HOST_ACCESS:
            allocInfo.flags = VMA_ALLOCATION_CREATE_HOST_ACCESS_RANDOM_BIT |
                              VMA_ALLOCATION_CREATE_MAPPED_BIT;
            _hostVisible = true;
            break;
        default:
            break;
        }
    
        auto* allocator = _device->get_vma_allocator();
        CHECK_VK_MSG(vmaCreateBuffer(allocator, &buffer_create_info, &allocInfo,
                                     &_buffer, &_allocation, nullptr),
                     "Cannot create buffer");
    
        CHECK(_buffer != VK_NULL_HANDLE, "Invalid VkBuffer object.");
    
        if (_hostVisible) {
            // Check the memory properties:
            VkMemoryPropertyFlags props{};
            vmaGetAllocationMemoryProperties(allocator, _allocation, &props);
    
            // Check if we have the coherent bit:
            _hostCoherent = (props & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0;
            logDEBUG("Host visible memory is {}",
                     _hostCoherent ? "coherent" : "non-coherent");
        }
  • Note: in the above we are also mapping our host memory permanently, as according to the VMA doc this should not hurt (?)
  • Next we need the actual method to write data into our buffer, so here is the method I created, taking care of flushing the data if needed:
    void VulkanBuffer::write_data(const void* data, U64 dataSize,
                                  U64 startPosition) {
        // Check that this buffer is host visible:
        CHECK(_hostVisible, "Cannot write data in non-host visible buffer.");
    
        // get the start position where to write data:
        nv::U8* ptr = _mappedData + startPosition;
    
        // Write the data:
        memcpy((void*)ptr, data, dataSize);
    
        // Flush the data if needed:
        if (!_hostCoherent) {
            _device->flush_allocation(_allocation, startPosition, dataSize);
        }
    }
  • OK, now that we can write data into our base vulkan buffer objects (at least when we make them host visible) we need to propagate the support for this in the VertexBuffer class itself. So I have now implemented a similar write_data method in the VulkanVertexBuffer class:
    void VulkanVertexBuffer::write_data(const void* data, U64 dataSize,
                                        U64 startPosition) {
        // So we need to write the given data, at the given start position in our
        // buffer.
        // CHeck that we can write data into the underlying buffer:
        CHECK(_buffer->is_host_visible(),
              "Cannot write data into non host visible vertex buffer.");
    
        if (startPosition == nv::U64_MAX) {
            // Use the current position in this Vertex position as start position:
            startPosition = _currentPosition;
        }
    
        // Otherwise we write that data taking into account our own offset from the
        // start of the underlying buffer:
        U64 offset = _bufferStartOffset + startPosition;
    
        // Write the data from the given offset position in the underlying buffer:
        _buffer->write_data(data, dataSize, offset);
    
        // Save our new current position:
        _currentPosition = startPosition + dataSize;
    }
  • With all the previous elements in place, I think we should now be able to draw our “first triangle” using a VertexBuffer 🤞!
  • Ohhh… no, there is something I'm missing: we can create our VertxBuffer from lua, but then we need a mechanism to write an array of floats in it!
  • ⇒ Let's implement support for that.
  • We should create the FloatArray as a vector of floats ⇒ in fact we already have the FloatList class for that, let's just add a different name (arrf, no: not needed), and while i'm at it, I'm also adding support for the reserve() method which might be of interest here.
  • Next we should also extend the VertexBuffer to support writting a FloatArray directly:
    auto VulkanVertexBuffer::write_float_array(const nv::FloatList& data,
                                               U64 startPosition, U64 inputOffset,
                                               U64 inputSize) -> U64 {
        if (inputSize == nv::U64_MAX) {
            // Use the remaining size from the input:
            inputSize = data.size() - inputOffset;
        }
    
        // Check that we are getting out of the input range:
        CHECK((inputOffset + inputSize) <= data.size(), "Out of input data range.");
    
        // Get our data pointer from the input:
        const float* ptr = data.data();
    
        // Apply the niput offset (in number of floats):
        ptr += inputOffset;
    
        // Then request the copy, and return the current position:
        return write_data((const void*)ptr, inputSize * sizeof(nv::Float),
                          startPosition);
    }
We should also remember here that we can create a FloatList directly from a lua table provided as constructor argument 😉!
  • Then I added support to create a vertex buffer from the VulkanDevice:
        auto create_vertex_buffer(
            U64 size, BufferCreateFlags flags = BUFFER_CREATE_FLAG_NONE,
            VkVertexInputRate inputRate = VK_VERTEX_INPUT_RATE_VERTEX,
            VkBufferUsageFlags usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT)
            -> nv::RefPtr<VulkanVertexBuffer>;
  • But we can also create a vertex buffer from an existing buffer in fact, so adding this version too in VulkanBuffer:
    auto VulkanBuffer::create_vertex_buffer(U64 size, U64 startOffset,
                                            VkVertexInputRate inputRate)
        -> nv::RefPtr<VulkanVertexBuffer> {
        return nv::create_ref_object<VulkanVertexBuffer>(this, size, startOffset,
                                                         inputRate);
    }
  • And now we move to the lua part: we start with creating the vertex buffer:
    local vbuf = self.vkeng:create_vertex_buffer(4*6, nvk.BufferCreateFlags.SEQUENTIAL_HOST_WRITE)
  • And we specify the attribute (vec2 position):
    vbuf:add_rg32f(0)
  • Then we add the actual values, which we can provide directly as a lua table in fact:
    vbuf:write_float_array { 0.0, -0.5, 0.5, 0.5, -0.5, 0.5 }
  • Next we assign the VertexBuffer when creating the pipeline config:
        -- Assign the vertex buffer:
        local is = cfg:getCurrentVertexInputState()
        is:addVertexBuffer(vbuf)
  • And of course we need to use different shaders to provide the position from the vertex bindings:
    -- Create the ShaderModules from our test GLSL source file
    function Class:createGLSLShaderModules()
        -- Here we should be able to load a GLSL source file:
        -- self.vert_shader = self.vkeng:create_vertex_shader("tests/test_simple.glsl")
        -- self.frag_shader = self.vkeng:create_fragment_shader("tests/test_simple.glsl")
    
        -- Read the position fro vertexbuffer:
        self.vert_shader = self.vkeng:create_vertex_shader("tests/test_vertexbuffer.glsl")
        self.frag_shader = self.vkeng:create_fragment_shader("tests/test_vertexbuffer.glsl")
        logDEBUG("Created shader modules.")
    end
  • And finally, we need to “bind” the vertex buffer when creating the command buffers, how do we do that again ? 😂 ⇒ OK we need to call vkCmdBindVertexBuffers. So now, we can bind a vertex buffer inside a command buffer with this:
            -- Bind the vertex buffer:
            cbuf:bind_vertex_buffer(vbuf, 0)
  • 😲 That's incredible: it's just working out of the box 😍. Is that really true ?!
  • Let's try to change a bit the position data to be sure:
        -- write the data:
        vbuf:write_float_array { 0.0, -0.8, 0.5, 0.5, -0.5, 0.5 }
  • ⇒ and yep! it's indeed using the updated position from that buffer:

  • ⇒ Well done Manu! 👏👏 lol
  • Actually let's push the previous experiment a bit further and add colors to our vertices now.
Normally it would make more sense to add the colors as uint8 values, but for this first try let's simple use a larger float32 array.
  • So here is the updated vertex buffer creation:
        -- Prepare our vertex buffer here, with support to write to it:
        local vbufIdx = self.vkeng:create_vertex_buffer(4 * 3 * 5, nvk.BufferCreateFlags.SEQUENTIAL_HOST_WRITE)
        local vbuf = self.vkeng:get_vertex_buffer(vbufIdx)
    
        -- Specify the vertex attributes:
        vbuf:add_rg32f(0) -- vec2 position at location=0
        vbuf:add_rgb32f(1) -- vec3 color at location=2
    
        -- write the data:
        vbuf:write_float_array {
            0.0, -0.5, 1.0, 0.0, 0.0,
            0.5, 0.5, 0.0, 1.0, 0.0,
            -0.5, 0.5, 0.0, 0.0, 1.0
        }
  • And the updated glsl shader code:
    #ifdef _VERTEX_
    
    layout(location = 0) in vec2 position;
    layout(location = 1) in vec3 color;
    
    layout(location=0) out vec3 vertColor;
    
    void main() {
        gl_Position = vec4(position, 0.0, 1.0);
        vertColor = color;
    }
    
    #endif
    
    #ifdef _FRAGMENT_
    
    layout(location=0) in vec3 fragColor;
    layout(location=0) out vec4 outColor;
    
    void main() {
        outColor = vec4(fragColor, 1.0);
    }
    
    #endif
  • And here is our display, just as expected, that's amazing 👍:

  • As mentioned above I simply used float32 values for the color components in the test above, but that's not the most memory friendly option: ideally it would be better to write the color component as 3 uint8 values.
  • But then how to efficiently construct our data array for that ? One option could be to use a dedicated vertex buffer bindings for the colors. But I think there is also another interesting path to take: we could combine 4 uint8 values to produce a float value ;-), let's add a couple of helper functions to do that convertion:
  • So here we go:
    auto rgba_to_u32(U8 r, U8 g, U8 b, U8 a) -> U32 {
        // Note: we don't need to take into account the byte order below:
        // for RGBA we always want to have the final order R,G,B,A in memory.
        U32 val = 0;
        U8* ptr = (U8*)&val;
        *ptr++ = r;
        *ptr++ = g;
        *ptr++ = b;
        *ptr++ = a;
        return val;
    }
    
    auto rgba_to_f32(U8 r, U8 g, U8 b, U8 a) -> Float {
        Float val = 0;
        U8* ptr = (U8*)&val;
        *ptr++ = r;
        *ptr++ = g;
        *ptr++ = b;
        *ptr++ = a;
        return val;
    }
  • When using this we need to specify 4 component colors, but that should not be a real issue anyway, and we are still saving 2 floats per vertex. And anyway, I don't think it's usually really used to have the color specified per vertex.
  • Here is the updated lua code to use this convertion:
        -- Prepare our vertex buffer here, with support to write to it:
        local vbufIdx = self.vkeng:create_vertex_buffer(4 * 3 * 3, nvk.BufferCreateFlags.SEQUENTIAL_HOST_WRITE)
        local vbuf = self.vkeng:get_vertex_buffer(vbufIdx)
    
        -- Specify the vertex attributes:
        vbuf:add_rg32f(0) -- vec2 position at location=0
        vbuf:add_rgba8(1) -- vec4 color at location=2
    
        -- write the data:
        vbuf:write_float_array {
            0.0, -0.5, nv.rgba_to_f32(255, 0, 0, 255),
            0.5, 0.5, nv.rgba_to_f32(0, 255, 0, 255),
            -0.5, 0.5, nv.rgba_to_f32(0, 0, 255, 255),
        }
  • ⇒ And the resulting triangle display is still the same (just changed the input color to a vec4)

And now I think this is good enough for this session ;-) See you next time!

  • blog/2022/1109_nervland_vertexbuffers.txt
  • Last modified: 2022/11/09 21:02
  • by 127.0.0.1