-
Notifications
You must be signed in to change notification settings - Fork 642
Framework v2.0
Over time the Vulkan Samples framework has sharded into multiple frameworks with lots of interdependencies. This has led to slow compile times and hard-to-extend code. Due to the lack of tests, many are reluctant to touch or extend the framework. When a new style of a sample comes about it has become common to add a new type of framework into the repository.
Framework v2.0 aims to:
- Break the framework into multiple components which have a single responsibility
- Allow samples to be compiled independently or together (currently all must be compiled)
- Allow samples to be loaded at runtime (currently launchers can not resolve samples locations and must be packaged with the samples themselves)
- Remove coupling between components (currently everything is coupled and hard to test)
- Not effect current samples development (development of v2.0 should not directly impact any current samples development)
Framework v2.0 will be developed in tandem with the main repository to reduce the risk of incompletion or its effect on current sample development. Once the framework v2.0 has transitioned into a completed state it can be merged into the main branch as a single commit. This will preserve the history of previous development and copyright/credits to authors where the original code was taken.
These PRs must be updated before a request for review can be made
#493 Adds a dynamic sample loader and a port of Vulkan Hello Triangle - This PR was to prove early on that the framework could fulfill its goals
- #469 Event Channels - Allows the handling of events in a uniform way
- #462 VFS - Adds a filesystem wrapper that allows for mounting different filesystem mechanisms to virtual paths
- #470 Input Manager - Combines the input management logic in the old framework into a self-contained input manager
- #482 Warnings as Errors - Treat all warnings as errors to ensure the new framework is developed to the repositories standards
- #485 Event Bus - Acts as a central system where components can subscribe to a central event bus and react to any events which are processed by the bus
- #495 Visual Studio Folders - Adds a macro to track source for Visual Studio
-
#495 Std Filesystem - Adds a
std::filesystem
implementation to the VFS and removes platform independent FS's - #487 Event Pipelines - Built builds on-top of the event system to allow samples to define individual stages to its execution
- #506 exceptions - Use exceptions by default over returning errors
-
#490 Dynamic Runtime Samples - Provide a mechanism to dynamically load and execute samples (adds
sample_main
) - #491 Headless and GLFW window - Adds basic windows for further development
- #574 Logging - Adds logging back to the framework
- #575 Vulkan Generators - Adds python script to generate Vulkan code
PRs are listed in the order of review and merging. If you are able to review the framework contributions please follow the order that these PRs are listed
- #598 Image Assets - Adds image loaders as a component
- #619 Scene Graph + Gltf Loader - Adds an ECS-backed Scene Graph and a GLTF Loader to load a simple model
- Add a dynamic event system
- Add a VFS
- Clean up CMake build system and macros
- Add basic windows
- Add Image Loaders (pending review)
- Add GLTF Loader (pending review)
- Add Scene Graph (pending review)
- Add dynamic sample loader (pending review / stale)
- Add a Vulkan Context Builder (pending review)
- Add compile time shader variant generation
- Add compile time shader reflection and type safe shader usage
- Add compile time descriptor set layout generation
- Add Vulkan Queue management
- Add Vulkan Pools (Memory, Image)
- Add Swapchain (Headless + KHR rendering)
- Add dynamic render graph with memory aliasing (design complete but not implemented)
- Plan for a transition from the old framework to the new framework on a per sample basis
A Component represents an individual static or shared library. Each component must only link against components that it directly requires and should have the minimum amount of interdependencies possible. Doing this allows CMake to efficiently compile and link each module reducing compile times from hours to minutes.
A component uses the following file structure
components/<component_name>
- /include/components/<component_name>/some_public_header.h
- /src
- /some_private_source.cpp
- /some_private_header.h
- /tests
- /some_test.test.cpp
vkb__register_component(
NAME vulkan
LINK_LIBS
vkb__common
volk
INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}/include
${CMAKE_CURRENT_SOURCE_DIR}/src
SRC
src/context/context_builder_funcs.cpp
src/context/context_builder.cpp
src/context/instance_builder.cpp
src/context/physical_device_builder.cpp
src/context/device_builder.cpp
)
# this generates the vkb__vulkan target
# only ran if VKB_BUILD_TESTS=ON
# uses Catch2 Main
vkb__register_tests(
NAME "virtual_file_system_tests"
SRC
tests/basic.test.cpp
tests/helpers.test.cpp
LIBS
vkb__vfs
)
# requires a complete custom main
vkb__register_tests_no_catch2(
NAME "sample_test"
SRC
tests/sample.test.cpp
LIBS
vkb__platform
vkb__dummy_sample
vkb__vfs
)
# only ran if VKB_BUILD_GPU_TESTS=ON
vkb__register_gpu_tests(
NAME "vulkan_context_test"
LIBS
vkb__vulkan
vkb__windows
SRC
gpu/context.test.cpp
)
A component representing common functionality used across most (if not all) components. Hashing, String Manipulation, Logging and Errors
There are lots of components which emit and consume events. Windows, Scene Scripts, Cameras etc. We can use a central event bus to allow components to subscribe to events and react to them. This will allow us to remove the coupling between components and allow for a more modular design. If we did not use a central event bus, components would need to know about each other and their dependencies. This would make it difficult to add new components and would require a lot of boilerplate code to be written. It would also couple the components at compile time which would mean every thing must compile together (this is slowwww).
We can define an Channel<Type>
to emit and consume events. A ChannelSender<Type>
pushed events and a ChannelReceiver<Type>
consumes events. Note that each ChannelReceiver<Type>
stores its own queue of events meaning it can consume events at its own pace. This allows us to have multiple consumers of the same event type and components which can react to events at different times - not all components always need to react to events.
struct Event {
int value;
};
// Create a channel
Channel<Event> channel;
auto sender = channel.create_sender();
auto receiver = channel.create_receiver();
// push an event
sender->push({1});
// consume an event
auto event = receiver->next(); // return the next event in the channel
auto last = receiver->drain(); // drain the channel and return the last event
An EventBus
acts as a container for Channels. Components can subscribe to the event bus and register to any amount of channels for different types. If a component emitted an event it would then be processed by the event bus and propagated to all the components which are subscribed to the channel.
struct Event {
int value;
};
struct Event2 {
int value;
};
class SomeComponent : public EventObserver {
public:
SomeComponent(EventBus &bus) {
bus.subscribe(this, {&bus.get_channel<Event>()});
}
virtual void update() {
// do some processing - emit some events
}
virtual void attach(EventBus &bus) {
bus.each([](const Event & event) {
// do something with every event
});
bus.last([](const Event2 & last_event2) {
// do something with the last event
});
mySender = bus.request_sender<Event2>();
}
};
auto component = std::make_shared<SomeComponent>();
// Create an event bus
EventBus bus;
bus.attach({component});
while(true){
bus.process();
}
Pipelines are similar to buses but define the overall behavior of a sample. Later a sample_helpers component will be created which contains a bunch of helpful pipeline stages for loading and configuring the initial state of a sample. Stages are currently synchronous and there is no mechanism to define dependencies between stages - like a graph. This will be added in the future.
// TODO: Add Event Pipeline usage example
Events allow for the communication between components with components knowing of each others concrete existence. Event Buses enable this and own the channels lifetime. Event pipelines enable samples to orchestrate entire stages of their lifetime in a per sample way. A default set of event pipeline stages and event bus observers can be made for samples to use if they deem necessary.
This allows components and samples to only include the functionality they need and not have to worry about the rest. This also allows for a more modular design and allows for new components to be added without having to modify existing components or samples.
Samples v2.0 platform aims to provide an easy mechanism to load and execute samples. We do this by providing a sample_main
function and pass a PlatformContext
. A main
implementation is then defined for each supported platform (Android, MacOS, Linux and Windows) which calls sample_main
with the appropriate PlatformContext
. Note that the main implementations are to allow for single sample execution but does not represent the final implementation of a samples functionality. sample_main
can be linked against by launchers allowing us to create launchers which dynamically load samples.
We may also be able to create a Web Assembly compile path with allows the samples to run in a browser.
// a simple sample definition
EXPORT_CLIB int sample_main(const components::PlatformContext *context) {
LOGI("Hello World");
return 0;
}
v1.0 samples used a bespoke scene graph implementation. This required a lot of boilerplate for samples to add new types to the graph and also required the scene graph to know about the existence of Vulkan and other components. v2.0 approaches this by defining a scene graph structure with an ECS as its storage mechanism. This allows us to maintain a scene hierarchy whilst allowing components to add new types to the graph without having to define them in the scene graph component itself.
We are using EnTT as the ECS.
[ ] TODO: Add scene graph hierarchy example and usage
// demonstrate how to add a custom component to a node
sg::Registry registry = sg::create_registry();
sg::NodePtr node = sg::Node::create(registry, "my_node", sg::Transform{});
sg::Mesh mesh;
// .. set up a mesh
node->set_component(mesh);
struct VulkanMesh {
// ... some Vulkan use-case for a mesh
};
// iterate over all nodes that have sg::Mesh but do not have a VulkanMesh component
auto view = registry->view<sg::Mesh>(entt::exclude<VulkanMesh>);
for(auto entity: view) {
auto &mesh = view.get<sg::Mesh>(entity);
// .. convert to VulkanMesh
VulkanMesh vulkan_mesh = vulkan::create_mesh(mesh);
// add the mesh to the registry
registry->emplace<VulkanMesh>(entity, vulkan_mesh);
}
// now we can access the VulkanMesh for every node that has a sg::Mesh
auto vulkan_mesh = node->get_component<VulkanMesh>();
Asset loading is broken down into different loaders. We have ModelLoader
and ImageLoader
which load raw assets from asset files to memory. The intermediate representation must not contain any vulkan specific code (other than enums or types). This allows loaders to be independent from the rendering API.
We then have ImageEncoder
and ImageDecoder
(or ImageCodec
if combined) which allow for the transformation from one image format to another. This allows us to load images in a format which is not supported by the rendering API and convert them to a format which is supported. We could also run preflight executables that do this conversion once per asset per platform so that multiple runs of the application are faster. Another approach would be to store RAW easy to maintain assets and to use the Encoders to create optimized assets for each platform at compile time using a preflight executable
We need to be able to load files from the disk and into memory. Each platform may decide to store assets slightly differently and therefore we need to provide a mechanism which can be adjusted on a per platform basis.
We achieve this through mounting different filesystem components to different paths. For desktop this is relatively simple:
RootFileSystem &_default(const PlatformContext * /* context */)
{
static vfs::RootFileSystem fs;
static bool first_time = true;
if (first_time)
{
first_time = false;
auto cwd = std::filesystem::current_path();
fs.mount("/", std::make_shared<vfs::StdFSFileSystem>(cwd));
fs.mount("/scenes/", std::make_shared<vfs::StdFSFileSystem>(cwd / "assets/scenes"));
fs.mount("/textures/", std::make_shared<vfs::StdFSFileSystem>(cwd / "assets/textures"));
fs.mount("/fonts/", std::make_shared<vfs::StdFSFileSystem>(cwd / "assets/fonts"));
fs.mount("/temp/", std::make_shared<vfs::StdFSTempFileSystem>());
}
return fs;
}
_default
will be depreciated later in v2.0 development for a more refined mechanism. This is just a temporary solution to get us started.
A sample then interacts with the VFS as follows:
auto& fs = vfs::_default(context);
try {
if(fs.file_exists("/some/path/to/file.txt"))
{
auto contents = fs.read_file("/some/path/to/file.txt");
// ...do something with contents
}
} catch(const std::exception &e) {
LOGE("Failed to process file /some/path/to/file.txt: {}", e.what());
}
Windows are implemented on a per platform basis and will be exposed to the sample through the PlatformContext
. Windows can be attached to the event bus to automatically propagate events to other components. A Windows surface can be used to select a Vulkan Physical Device and create a Vulkan Logical Device.
TODO: Add shader compilation example
The Vulkan Context represents the core Vulkan state in any application. The Instance, Physical Device, Logical Device and Queues are all managed by the Vulkan Context. The Vulkan Context can be created by using the ContextBuilder.
vulkan::ContextBuilder builder;
builder
.configure_instance()
.application_info(vulkan::default_application_info(VK_API_VERSION_1_2))
.enable_validation_layers()
.enable_debug_logger()
.done();
builder
.select_gpu()
.score_device(
vulkan::scores::combined_scoring({
vulkan::scores::device_preference({VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU, VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU, VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU}),
vulkan::scores::has_queue(VK_QUEUE_GRAPHICS_BIT, 1),
}))
.done();
builder
.configure_device()
.enable_queue(VK_QUEUE_GRAPHICS_BIT, 1)
.done();
vulkan::ContextPtr context = builder.build();
The ContextBuilder is composed of nested builders. The InstanceBuilder
configures the Vulkan Instance, layers and extensions. The PhysicalDeviceBuilder
selects a Physical Device from the available devices using scoring mechanisms defined by the user. The DeviceBuilder
configures the Logical Device and Queues.
In the future it may make sense to add a QueueBuilder
which allows the user to configure the Queues and their priorities and abstracts both the Physical Device queue selection and the Logical Device queue creation.
If a Context can not be created then an exception will be thrown
The render graph is built on-top of the Vulkan Framework. This is the core interface used to define and queue work on the GPU... TODO: Add more details and examples here
EXPORT_CLIB int sample_main(PlatformContext *platform_context)
{
// configure context for sample
vulkan::ContextBuilder builder;
builder
.configure_instance()
.application_info(vulkan::default_application_info(VK_API_VERSION_1_2))
.enable_validation_layers()
.enable_debug_logger()
.done();
builder
.select_gpu()
.score_device(
vulkan::scores::combined_scoring({
vulkan::scores::device_preference({VK_PHYSICAL_DEVICE_TYPE_DISCRETE_GPU, VK_PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU, VK_PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU}),
vulkan::scores::has_queue(VK_QUEUE_GRAPHICS_BIT, 1),
}))
.done();
builder
.configure_device()
.enable_queue(VK_QUEUE_GRAPHICS_BIT, 1)
.done();
vulkan::ContextPtr context = builder.build();
// get filesystem
// the naming of this function is poor and we could always allocate the filesystem in main... for now this is the pattern
auto fs = vfs::_default(platform_context);
// possibly this in the future?
// auto fs = platform_context->fs();
// load scene
auto registry = sg::create_registry();
sg::NodePtr model_root;
GltfLoader loader{registry};
loader.load_from_file("Model Name", fs, "assets/models/scene.gltf", &model_root);
// creating vulkan meshes
// a system that finds all meshes in the scene and allocates a vulkan::Mesh for them
vulkan::systems::allocate_vulkan_meshes(context, registry);
// event loop
EventPipeline pipeline;
// ... define sample event pipeline and add systems (we can also add vulkan systems)
// ... the default samples may decide to use default pipeline configurations
}