beva (lowercase) is a thin wrapper over the Vulkan API. It abstracts away very little functionality from the original API and keeps the low-level design.
The beva.hpp header (which is the only one) uses the bv namespace and
contains 4 different regions.
-
dynamic_bitset: This regions contains a copy of the dynamic_bitset library by pinam45. Dynamic bitsets are used for memory management in beva.
-
Data-only structs and enums: This region provides tiny wrappers for Vulkan structs that use STL containers and types like
std::vector,std::array,std::string, andstd::optionalinstead of raw pointers and arrays. These structs also hide away useless fields like flags that are reserved for the future and the usually redundantsTypeandpNextfields. This region also containsVersion, a wrapper around theVK_MAKE_API_VERSIONandVK_API_VERSION_XXXXmacros used for encoding and decoding versions in integers. -
Error handling: beva throws exceptions of type
Errorfor error handling.Errorcan be constructed from a message and an optionalVkResultand provides ato_string()function with descriptions for everyVkResultbased on the Vulkan specification. -
Classes and object wrappers: Contains wrapper classes for Vulkan objects. This will be further explained below.
-
Helper functions: This is self explanatory.
-
Memory management: This region contains helper classes for managing device memory allocations and splitting them into several chunks for different images and buffers in a thread-safe manner. This will be further explained below.
In the header, you'll find comments containing links to the Khronos manual above wrapper structs, classes, and functions. I encourage you to read them to learn how to use them properly.
beva provides Context for instance management. A Device can then
be created with that context. Finally, normal objects like Image can be
created within that device. These classes include static and member functions
covering common usage of them, for example, Device::retrieve_queue() or
CommandBuffer::begin().
For convenience, some classes automatically fetch and store commonly used
information. For example, Swapchain::create() will fetch the associated images
and store them in a vector you can access by calling images(). Another example
is Image or Buffer fetching their memory requirements on creation.
Apart from these, there is also DebugMessenger which is a wrapper around
VkDebugUtilsMessengerEXT from the VK_EXT_debug_utils extension.
Finally, Allocator is an abstract class that lets you implement your own
memory allocator for the Vulkan driver to use. You can use
Context::set_allocator() to set an allocator for that context and every
Device based on it, and every object created and destroyed within that
Device.
Context provides fetch_physical_devices() which returns a vector of
PhysicalDevice objects already containing information such as device
properties, features, memory properties, and queue families. Additionally,
PhysicalDevice provides several member functions for fetching information
like swapchain support details for a surface (formats, present modes, etc.),
available device extensions, and image and buffer format properties, as well as
functions for finding queue families that meet the provided criteria.
beva uses RAII. Object wrappers have a static create() function that usually
takes in a config struct and returns a shared pointer of the associated type.
The destructor will try to delete the underlying Vulkan object if possible.
beva uses weak pointers in config structs and for member variables to avoid
circular or unwanted references preventing deletion of objects. This means, for
example, a Fence will only hold a weak pointer to its parent Device,
so you can delete the device before the fence. When the fence's destructor is
invoked, it will first check if the weak pointer to the device has expired and
do nothing if so. However, if you call a member function on the fence that needs
to use the device, an Error will be thrown complaining about the weak pointer
having expired.
| Term | Description |
|---|---|
| Block | A small portion of a device memory, by default 1024 bytes. |
| Region | A huge amount of allocated device memory which acts as a memory arena for virtually allocating chunks. A region also contains a bitset to represent which blocks are allocated. |
| Chunk | A virtually allocated range inside a memory region. |
| Bank | Manages a list of memory regions and provides logic for allocating new chunks. |
beva provides MemoryBank for device memory management. A MemoryBank manages
a list of MemoryRegions internally. A MemoryRegion simply contains a
DeviceMemory and a bitset to represent which blocks are allocated.
You can call MemoryBank::allocate() to get a MemoryChunk that you can bind
to your image or buffer using its bind() functions. MemoryBank::allocate()
will automatically handle finding a free range inside a compatible region, or
creating a new one if none were found. It will also take care of alignment
requirements. These operations are all thread safe and use a mutex under the
hood.
A MemoryChunk will mark its corresponding blocks as free upon destruction.
MemoryBank::allocate() will check for empty regions and delete them when
needed.
Additionally, you can call mapped() and flush() on a MemoryChunk if it was
allocated from a host visible region. Note that you can pass the required memory
properties (like host visible or device local) as an argument to
MemoryBank::allocate().
beva only implements a tiny section of the Vulkan API, mostly the parts needed
for traditional rasterized rendering and compute shaders. You can call
handle() on an object wrapper to get its raw handle and directly use the
Vulkan API to implement what beva doesn't cover.
beva will not try and catch invalid input. It's totally possible to get
undefined behavior and crashes with beva if used incorrectly. To avoid these
situations, read the Khronos manual pages linked above structs, classes, and
functions to see how to use them properly. For example, whether a
std::shared_ptr field can be nullptr or must have a value, or in what
conditions a std::optional field can actually be std::nullopt.
beva is not tested enough to be called stable or ready for production at all. It is just a fun side project at most.
I don't have time to write documentation but looking at the header, this page, the demos, and Khronos manual pages should give you enough information on how to use beva.
Make a new directory named beva somewhere in your include directories and copy
beva/src/lib/beva/beva.hpp and beva.cpp into it. Make sure your compiler is
recognizing and actually compiling beva.cpp. And of course, make sure to set
up and include the latest Vulkan SDK such that #include "vulkan/vulkan.h"
works. Here's a tutorial on that.
Note: beva requires C++20.
Check out beva/src/demos to see how to use beva. If you build and run the
project in Visual Studio, you'll get asked to choose a demo to run.
This demo implements the very basics needed to draw a triangle to the screen. Devices, swapchains, vertex buffers, that sort of stuff.
This demo builds on top of the first one and implements uniform buffers, textures, depth buffering, mipmaps, multisampling, instanced rendering, and push constants. It uses an external library to load an OBJ model. The model is from PolyHaven.com.
This demo uses a compute shader to drive a wave simulation. It uses shader storage images for the simulation and specialization constants to send local invocation sizes to the compute shader at runtime without recompiling it.
The wave simulation code is mostly based on this shadertoy.
Being the most complex of them all, this one uses what's called a G-Buffer to only render what's essential for lighting. This is different for every application, but in this case the G-Buffer contains two images with the following data:
- Diffuse-metallic: This image stores diffuse color data in the first three channels and metalness in the fourth one.
- Normal-roughness: This one stores the world-space normal in the first two channels using spherical coordinates and the roughness value in the third channel. The fourth channel is simply a toggle that defines whether the pixel should be lit or it is unlit (like the background, or an emissive surface).
The geometry pass is what renders to the G-Buffer. A lighting pass then draws a full-screen quad and samples the the G-Buffer images to render a properly lit scene. It uses a shader storage buffer object (SSBO) to read and use an array of lights updated from the CPU. Finally, a post processing pass samples the output from the lighting pass to apply FXAA-like antialiasing, some post processing, and flim, my filmic color transform.
Deferred rendering is most useful when you have a lot of lights, or a lot of overdraw such that the lighting calculations for a pixel get completely discarded as another one is drawn on top of it. None of these are a problem is the extremely simple "scene" in this demo, though.
The 3D model in this demo is from PolyHaven.com.
These demos don't necessarily follow the best practices for making larger applications. There is minimal separation of concerns (everything is forced into a single class), no sampler sharing (every image has its own sampler), and other flaws. These demos are just there to show you how beva itself can be used.



