Skip to content

How It Works

Karn Kaul edited this page Oct 31, 2022 · 1 revision

Introduction

Wondering how facade works? This page provides a high level overview of exactly that!

Library vs Application

The general idea is to have the library manage as much as possible without compromising having a stateless per-frame API. In other words, beyond the fundamental states of rendering, windowing, and input, the library remains stateless from the user's point of view. It's the application's responsibility to load shaders, run the event loop, and track state between frames.

Dependencies

facade uses the following third-party dependencies:

Built from source:

Loaded dynamically:

  • Vulkan loader (libvulkan1.so / vulkan1.dll)

Bootstrap

facade needs to perform some work to initialize, which are, in short:

  1. Initialize GLFW (and check for Vulkan support)
  2. Create a GLFW window
  3. Load Vulkan function pointers
  4. Engine: Create a Vulkan Instance, Device, and Vma instance
  5. Renderer: Create a Vulkan Swapchain, RenderPass, Pipes, double buffer of RenderFrames, and initialize Dear ImGui

Vma

Small RAII wrapper over the VMA library, provides APIs to make Vulkan Images / Buffers and transition image layouts. facade uses one "universal" Vulkan Queue for all command submissions.

RenderFrame

Contains the synchronization resources, command buffers, and framebuffers to perform a render pass.

Pipes

Pipeline factory and cache. facade uses SPIR-V Cross to inspect shaders and build Vulkan Descriptor Set Layouts for them. The "spec" for a pipeline is hashed and all pipeline variants (one per Vulkan Render Pass) stored within it. Shaders are abstracted to a std::string id and pre-compiled SPIR-V code at this layer, it is the application's responsibility to ensure all shaders referenced by various Materials have been successfully pre-loaded into the Renderer.

Each Pipeline thus owns its descriptor set layout, and compatible Vulkan Descriptor Set Pools to generate Vulkan Descriptor Sets for each object to draw using that pipeline. This implies that a single descriptor set cannot be shared across multiple pipelines.

Each DescriptorSet abstraction owns double buffered Buffers that can be written to during draws (UBOs / SSBOs). An externally owned Buffer can also be bound to a set without having to write to its internal buffers, but the user must manage rotation in such cases; this can be useful for updating scene data / that common to all objects being drawn, instead of writing the same data to all their internal buffers.

Renderer

Owns and manages all the resources mentioned above, specifically the Vulkan Swapchain and Render Pass, and provides a configurable number of secondary Vulkan Command Buffers per frame. Provides APIs to add shaders, start the next frame (acquire a swapchain image), build and bind pipelines via their specs, execute a render pass and submit the recorded command buffers. Also enables changing certain characteristics of the swapchain, eg Vulkan Present Mode / Vulkan Image Format (ColourSpace).

facade uses two-stage command buffer recording: a number of secondary command buffers are returned to the user for individual draws (one per thread per frame) upon acquiring an image. These are then executed inside a render pass by the primary command buffer for the frame before queue submission. All image layout transitions are explicitly carried out (instead of relying on the driver / render pass) using granular sync. Synchronization for arbitrary commands (eg transfers) is intentionally not granular, and a Cmd's destructor will block until its queue submission has completed execution. The ability to batch multiple groups into a single submit and / or moving out the submit fence instead of blocking on destruction may be integrated in future versions.

Engine

Central user-facing API, initializes and owns everything above, as well as the active Scene. Does not own the event loop, but provides an API to poll the window and obtain its latest Glfw::State. Offers an API to load a GLTF scene from a given path asynchronously, notify when done, and obtain the load progress at any time.

The engine uses one command buffer per frame (no multi-threaded rendering), and executes the render pass in one shot (not split across multiple functions).

Rendering a Scene

Refer to Scene Structure for more info on how a scene is laid out. The logic to render a Scene is abstracted into SceneRenderer, an instance of which is owned by Engine. It itself owns resources like a 1x1 white Texture, UBOs and SSBOs for the scene view and lights, etc. The process of rendering a scene is:

  1. Write scene view and lights to buffers
  2. For each root node, call render, passing a unit parent transformation matrix:
    1. If the node has a Mesh attached, draw it
    2. For each child of the node, call render, passing parent * node.transform.matrix() as the parent transformation matrix

Drawing a Mesh:

  1. For each Primitive:
    1. Obtain its material (or use a default LitMaterial)
    2. Bind the corresponding Pipeline
    3. Update set 0 to point to the scene view buffers
    4. Dispatch writing other sets to the material
      1. Write textures to set 1
      2. Write material data to set 2
    5. Obtain its StaticMesh
    6. Create the instance matrices
    7. Update render frame stats
    8. Execute Draw Call

Draw Call:

  1. Write instance matrices to a vertex buffer
  2. Bind StaticMesh vertices, indices (if not empty), and instance matrices to pipeline vertex buffers
  3. Issue draw call (indexed or otherwise)

Multi-Threading

All Vulkan resources (Textures, Buffers, etc) can be created fully asynchronously: this is what enables async loading of entire Scenes. Access to individual resources is not synchronized. facade uses an internal device level mutex to lock queue submissions and other shared accesses, and uses dedicated Vulkan Command Pools per ad-hoc Cmd command buffers so they can be used on any thread.

GLFW is categorically not thread safe, all calls to the library (direct or indirect) must occur exactly on the thread that created the window (on MacOSX only the main thread). This includes calling Engine::poll(). facade assumes usage of the same thread for polling and rendering, which is why it's able to offer a unified "virtual frame" across both domains.