Skip to content

Framework v2.0

Tom Atkinson edited this page Feb 27, 2023 · 7 revisions

Overview

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)

Table of Contents

Development Process

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.

Outdated PRs

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

Closed PRs

  • #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

Open PRs

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

Road map

  • 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

Component

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.

File Structure

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

Register the Component

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

Register a Test

# 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
)

Components

Common

A component representing common functionality used across most (if not all) components. Hashing, String Manipulation, Logging and Errors

Events

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).

Channels

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

Event Bus

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();
}

Event Pipelines

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

Summary

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.

Platform

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;
}

Scene Graph

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

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

Virtual Filesystem (VFS)

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

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.

Shader Compilation

TODO: Add shader compilation example

Vulkan Framework

Vulkan Context

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

Vulkan Pools

Vulkan Swapchain

Vulkan Pipelines

Render Graph

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

Building a Sample

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
}