fireEngine 0.8 architecture
Release 0.8 is the first version of fireEngine that carries imported content all the way from files to a moving, textured, depth-tested frame. The normal application loads Khronos Group’s AnimatedCube glTF sample, plays its rotation animation, and continues drawing while presentation resources are replaced.
This document describes the complete system at tag 0.8. It stands on its own: understanding it does not require the architecture document for an earlier release. Some principles therefore appear again where they remain load-bearing.
The pinned 0.8 source tree is the authority on types and functions, and the 0.8 release notes are the concise record of its supported outcome. The generated public documentation and internal documentation track the current tutorial revision rather than this tag, so both will drift from this page.
The organising decision
Every important boundary in 0.8 follows from one decision: neither the source format nor Vulkan owns fireEngine’s lasting scene model.
GltfLoader translates external files into SceneContent and then lets the source representation go. Renderer consumes that engine vocabulary through a Vulkan-free façade and owns every device representation behind it. Between those two edges, assets, hierarchy, animation, preparation, and per-frame updates remain ordinary C++ data and operations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
format-specific format-neutral engine
.gltf .bin .png
|
v
GltfLoader ----------------------> SceneContent
fastgltf + stb stay internal ├── RenderAssets
├── Scene
└── Animation[]
CPU operations: validate + animate + resolve
Vulkan-free facade: Renderer::prepare + Renderer::drawFrame
=============================== Vulkan boundary =====================
Vulkan owners: render/detail/
Device + allocator + CompiledResources + PresentationState + FrameInFlight
This buys four things.
- Format independence. A procedural builder, another importer, or a future cached format can produce the same
SceneContent; render and animation code do not need to learn glTF. - Device independence. Content can be loaded, validated, traversed, animated, and prepared without a window or GPU.
- Different replacement rates. Imported descriptions, compiled scene resources, current transforms, and presentation resources can change independently.
- Testable boundaries. Most contracts are proved with deterministic Catch2 cases, leaving a small set of bounded scenarios to cross the real Vulkan boundary.
The visible textured cube matters because it exercises every boundary at once. It is evidence that the layers compose, not the organising principle itself.
System map
The architecture is a graph rather than one strict stack. The application uses three main paths: import content, update it, and render it.
1
2
3
4
5
6
7
8
9
10
11
src/main.cpp
├── load ──────> gltf/ ──────> content/
├── update ────> animation/ ─> scene/
└── draw ──────> render/ ─────> render/detail/
content/ ──────> graphics/, scene/, animation/
animation/ ────> scene/, math/
render/ ───────> graphics/, scene/, math/, platform/
platform/ supplies the GLFW window and Vulkan surface
core/ supplies logging and internal support
| Area | Responsibility | Knows about |
|---|---|---|
math/ | Vectors, quaternions, decomposed transforms, matrices, camera projection | no engine layer |
graphics/ | Images, textures, meshes, materials, render objects, typed IDs, preparation | math/; scene draw lists in preparation implementation |
scene/ | Forest ownership, stable node identity, local/world transforms, component roles, draw traversal | math/, graphics IDs, animator binding |
animation/ | Reusable rotation samples, validation, and playback into local transforms | math/, scene during validation and playback |
content/ | One format-neutral composition of assets, hierarchy, and animations | graphics, scene, animation |
gltf/ | Translation of the supported glTF slice into SceneContent | content, plus graphics and scene types in the implementation; fastgltf and stb internally |
render/ | Public renderer façade and conversion of prepared descriptions into frames | graphics, scene, math, platform |
render/detail/ | Vulkan ownership, compilation, uploads, pipeline, frame, and presentation | Vulkan, VMA, Slang output |
platform/ | Process-level GLFW lifetime, native window, events, and surface creation | GLFW and Vulkan surface types |
core/ | Public logging and internal Vulkan diagnostics or hashing support | standard library; Vulkan in core/detail |
core/ is cross-cutting and is omitted from the other rows’ dependency lists.
The arrows are intentionally not a claim that every source directory is acyclic. graphics/ preparation consumes a scene draw list, while scene/ draw items contain graphics IDs. That source-level seam is recorded below.
Four rates of change
The central runtime contract is easier to understand by asking how often each kind of work should happen.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. load once 2. prepare when dependencies change
external files SceneContent
│ assets + scene draws
v │
GltfLoader v
│ RenderPreparationPlan
v │
SceneContent v
compiled GPU scene resources
3. update every frame 4. replace when the surface changes
elapsed time + Scene Window framebuffer
│ │
v v
animation + world transforms PresentationState
│ swapchain + depth + pipeline
v
current draw matrices
Loading resolves file-format questions. Preparation resolves which stable resources the current scene needs and compiles them. Updating changes CPU-side pose; drawing records a fresh frame without uploading assets. Presentation replacement responds to the window system without recompiling the scene.
These rates meet through explicit operations:
| Operation | Input | Persistent result |
|---|---|---|
GltfLoader::load() | one .gltf path and its external dependencies | validated SceneContent |
Renderer::prepare() | render assets and current scene dependencies | cached plan and CompiledResources |
advanceAnimations() + Scene::updateWorldTransforms() | elapsed time and current hierarchy | current local and world transforms |
Renderer::drawFrame() | prepared scene with current transforms | one presentation outcome |
Renderer::recreatePresentation() | current window framebuffer | replacement presentation group |
prepare() remains public and explicit. The renderer drives RenderPreparation inside that operation, but the caller chooses when the whole phase happens. drawFrame() never hides asset loading or compilation.
The content contract
SceneContent is a composition rather than a new universal asset database:
1
2
3
4
5
6
7
8
9
10
11
SceneContent
├── RenderAssets
│ ├── ImageData[] decoded RGBA8 pixels
│ ├── Texture[] image ID + filtering + wrapping
│ ├── Mesh[] vertices + indices
│ ├── Material[] colour factor + optional texture ID
│ └── RenderObject[] mesh ID + material ID
├── Scene
│ └── SceneNode forest transforms + one component role per node
└── Animation[]
└── channels timestamps + reusable quaternion samples
The glTF loader owns translation only for the duration of load(). It builds engine values in dependency order, reconstructs the selected hierarchy, updates world transforms, validates the complete composition, and returns it by value. No fastgltf object, accessor, source index, or stb allocation becomes part of the lasting result.
The supported input slice is deliberately narrow: JSON glTF, external buffers, external PNG images, TRS nodes, indexed triangle primitives, positions, first texture coordinates, base-colour materials, sampler state, and linear rotation channels. Unsupported required extensions, primitive modes, accessor layouts, or animation paths fail explicitly rather than producing a partial scene whose meaning is uncertain.
Validation happens at two related boundaries. The loader checks whether source data can be translated into the selected engine vocabulary. Composition validation then checks the finished asset graph and animation-to-scene bindings. Renderer preparation separately validates scene render-object references and repeats asset validation when a collection or its revision changes, because procedurally constructed content can reach prepare() without passing through GltfLoader.
Ownership and identity
The architecture uses direct ownership and local typed identity throughout.
For content and rendering, the application owns two top-level values. SceneContent owns the CPU representation. Renderer owns the device representation. Neither owns the other: preparation consumes descriptions to build a plan and device resources, while drawing consumes the scene’s current transforms.
Dense owners hold values and relationships use typed IDs. RenderAssets issues image, texture, mesh, material, and render-object IDs. AnimationId indexes the animation vector in one SceneContent, AnimationChannelId indexes one animation, and SceneNodeId indexes one Scene. Their types prevent accidental substitution; their meaning still depends on that local owner. They are handles within one composition, not global identifiers.
The scene owns a forest by unique pointer. Each node is immovable and owns its children. Scene additionally maintains a dense non-owning lookup indexed by SceneNodeId. Adding descendants does not move existing nodes, so registered pointers remain stable. New descendants enter that lookup when world transforms are next updated.
A node has one explicit component role. SceneComponent is a variant of empty, Animator, or RenderObjectId. An imported source node can therefore own animation behaviour while one child per mesh primitive owns the renderable role and inherits the source transform.
Vulkan resources have one RAII owner. The renderer implementation holds the long-lived device and allocator, one replaceable presentation group, one frame slot, one preparation cache, and one compiled-resource graph:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Renderer::Impl
├── Device
├── MemoryAllocator
├── PresentationState
│ ├── Swapchain + image views + per-image semaphores
│ ├── DepthBuffer
│ ├── compatible Pipeline
│ └── per-image presentation fences
├── FrameInFlight
├── RenderPreparation
└── CompiledResources
├── compiled images + views
├── compiled textures + samplers
├── fallback image + texture
├── compiled meshes
└── render-object lookup
There is no reference-counted ownership in this graph. Where one compiled object points into another, it is an explicitly ordered borrower. Candidate compiled graphs are constructed separately, then the render-object borrowers are replaced before the mesh, texture, and image owners they reference. This named build–swap–release protocol keeps repeated preparation and exception safety reviewable.
Preparation, transitive selection, and compilation
RenderPreparation answers which subset of the asset catalogue the current scene reaches. Its cache key remains:
1
2
3
4
5
RenderAssets identity (collection address)
+
asset revision (changed by every insertion or move replacement)
+
exact ordered RenderObjectId dependencies
The dependency hash carried by SceneDrawList is only a fast comparison. The exact ordered ID sequence remains the authority, so a collision cannot reuse an incorrect plan.
When the key changes, preparation validates the catalogue and follows the full resource closure:
1
2
3
4
5
6
7
8
9
10
11
12
SceneDrawList
|
v
RenderObjectId[]
|
+--> MeshId
|
+--> MaterialId
|
+--> optional TextureId
|
+--> ImageId
Each selected category is deduplicated and emitted in stable dense-ID order. Unused catalogue entries retain CPU identity but receive no device allocation. For AnimatedCube, the base-colour relationship is selected; imported data not reachable through the 0.8 material model remains CPU-side.
Renderer::prepare() compares the plan generation with the generation already compiled. An unchanged generation returns without waiting, allocating, or uploading. A changed generation first retires any earlier work, then CompiledResources constructs a complete candidate graph and commits it only after compilation and uploads succeed.
World transforms and animation playback are deliberately absent from the key. They change DrawItem.world, not the ordered render-object sequence or asset revision. A rotating instance therefore records a new model matrix while reusing the same plan, vertex and index buffers, sampled image, sampler, and render-object lookup.
The update and frame protocol
The application owns timing and event policy. One ordinary iteration is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
poll window events
|
v
measure elapsed time
|
v
advance Animator components into local rotations
|
v
resolve world transforms from scene roots
|
v
handle a pending framebuffer resize
|
v
Renderer::drawFrame(scene)
Animation data and playback state remain separate. AnimationChannel stores reusable samples; each Animator stores its own channel binding, playback time, and looping policy. Playback wraps or clamps time, chooses surrounding samples, and writes only the owning node’s local rotation. World-transform resolution then propagates that change to renderable descendants.
Inside drawFrame(), ordering protects both failure recovery and resource reuse:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
build and check current draw list before acquiring an image
|
wait for sole frame-submission fence
|
acquire swapchain image ----------------> eNotPresented if out of date
|
wait/reset that image's earlier presentation fence, when submitted
|
reset command pool and record current draws
| transition and clear colour + depth
| bind compatible pipeline and frame uniform
| bind mesh and sampled texture per draw
| push model matrix + material colour
| draw indexed geometry
| transition colour for presentation
v
reset frame fence, submit, and mark work pending
|
present with the image's render-finished semaphore and presentation fence
|
+------------------------------------> ePresented
ePresentedSuboptimal
eNotPresented
There is one frame in flight. Its command pool, command buffer, acquisition semaphore, submission fence, and uniform buffer are reused only after the frame fence signals. Render-finished semaphores and presentation fences instead follow acquired swapchain images, because presentation outlives the graphics submission tracked by the frame fence.
Presentation is a separate replacement lifetime
PresentationState groups everything whose compatibility or count can change with the surface:
1
2
3
4
5
6
7
replace together preserve across replacement
──────────────── ───────────────────────────
swapchain + image views device + allocator
render-finished semaphores frame slot
depth image + view preparation cache
colour/depth-compatible pipeline compiled meshes and textures
presentation fences scene and animation state
Framebuffer callbacks, suboptimal acquisition or presentation, and out-of-date results all converge on Renderer::recreatePresentation(). A zero-sized minimised framebuffer is a transient value, not an exception: the application waits for window events and retries after the framebuffer becomes drawable.
Recreation first finishes submitted device work and every tracked presentation operation. It then passes the old swapchain to Vulkan, constructs a complete replacement PresentationState, rewrites the preserved frame uniform with a projection for the new extent, and exchanges the owning pointer. The compiled scene graph is untouched.
Two completion domains are explicit:
1
2
3
4
5
6
7
8
9
10
graphics submission
|
+--> frame fence --------------------> reuse frame resources
|
+--> render-finished semaphore
|
v
presentation engine
|
+--> presentation fence --> retire swapchain resources
Device idle covers submitted device work but is not, by itself, the proof that the presentation engine released an old swapchain and its binary wait semaphores. Version 0.8 therefore requires VK_KHR_swapchain_maintenance1, or the equivalent EXT path, and associates one per-image fence with each presentation request. A submitted bit prevents waiting on a new unsignalled fence; both successful and out-of-date presentation paths record the fence as pending because both enqueue presentation work.
This is deliberately a correctness-first replacement protocol. It waits coarsely rather than overlapping construction or deferring destruction.
The CPU-to-shader contract
Three fixed-layout structures and one sampled image cross into Slang:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
C++ Slang update rate
─── ───── ───────────
FrameUniforms --> set 0, binding 0 on extent change
Mat4 viewProjection float4x4 viewProjection
DrawConstants --> push constants every draw
Mat4 model float4x4 model
Color4 baseColor float4 baseColor
Vertex --> vertex locations per vertex
Vec3 position 0: float3 POSITION
Color4 color 1: float4 COLOR
Vec2 textureCoordinate 2: float2 TEXCOORD
CompiledTexture --> set 0, binding 1 every draw
image view + sampler Sampler2D<float4>
Matrices are column-major. Among the maths value types, only Mat4 carries 16-byte alignment; vectors remain packed. FrameUniforms and DrawConstants also carry 16-byte alignment at the shader boundary. Static assertions pin the representations passed directly to buffers and push constants. The shader applies model, then shared view-projection, and multiplies sampled base colour by the material and vertex factors.
Selected decoded colour pixels become one-mip R8G8B8A8Srgb device images. Sampling therefore converts stored sRGB colour before multiplication in the shader. Untextured materials bind a persistent one-pixel white fallback through the same descriptor path, avoiding a second pipeline or a condition in the shader.
Failure and outcome model
The version uses different mechanisms for different kinds of failure.
| Kind | Mechanism | Example |
|---|---|---|
| A value cannot be normalised | std::expected | a zero-length camera basis or quaternion |
| Engine descriptions are invalid | std::invalid_argument | a texture refers to a missing image |
| The API contract was broken | std::logic_error | drawing before preparation or a validated animator becoming dangling |
| Imported data or the environment is unsupported | std::runtime_error or Vulkan error | a missing file, unsupported glTF mode, or unsuitable device |
| Presentation produced no fatal error but needs policy | RenderResult | presented, suboptimal, or not presented |
| The framebuffer is temporarily unusable | false from recreation | a minimised zero-area window |
The loader rejects unsupported required content rather than silently dropping it. The renderer validates draw references before acquiring an image, so a bad scene cannot strand a signalled acquisition semaphore. Expected window-system events remain values handled by the application loop, keeping exceptions for conditions that cannot be recovered by ordinary recreation policy.
Public façade and internal implementation
The include tree now expresses the supported boundary systematically. Public types live directly beneath their area, while implementation helpers live in a detail/ directory and are excluded from the public Doxygen view. The internal view enables those headers and implementation documentation separately.
Renderer remains the only application-facing Vulkan owner. Its public header uses an implementation pointer and declares no Vulkan type. GltfLoader returns SceneContent without exposing fastgltf or stb. CompiledResources, DepthBuffer, allocation owners, pipeline, device, swapchain, and upload machinery are all internal.
Not every public header is Vulkan-free. Window still includes Vulkan-Hpp and returns a Vulkan surface and extent because it is the bridge from the native window to presentation. The architectural claim is therefore narrower and accurate: content and the renderer façade are Vulkan-free; platform surface creation is not.
RenderPreparation remains public and independently usable. That is how tests exercise reachability, cache keys, and generation changes without going through a renderer or creating a device.
Build and test architecture
The build keeps device-free checks and real Vulkan scenarios at different levels:
1
2
3
4
5
6
7
8
9
10
11
fireEngineTutorialEngine static library
|
+--> fireEngineTutorialTests 47 Catch2 cases, no device
|
+--> fireEngineTutorial thin application and event loop
|
+--> 4 bounded device scenarios
+--> 2 Debug synchronization-validation variants
scene.slang --slangc--> scene.spv build-time shader compilation
AnimatedCube files --> build/assets deterministic runtime copy
The 47 Catch2 cases cover maths, transforms, scene identity and traversal, asset and composition validation, preparation, loader policy, animation, SPIR-V loading, and swapchain selection. Four named scenarios exercise normal AnimatedCube playback, repeated preparation, the untextured fallback, and presentation recreation. Debug adds synchronization-validation variants for the two replacement paths, giving 53 CTest registrations in that configuration.
Device scenarios fail on validation errors while retaining warnings for diagnosis, share one CTest resource lock, and carry a 30-second timeout. The basic scenario runs from an isolated working directory, proving that compiled asset and shader paths do not depend on where the process starts.
CI separates formatting, static analysis, terminology, and documentation, including the public/internal boundary check, from the platform builds. Ubuntu runs the complete Debug suite inside Xvfb with Lavapipe. Hosted macOS and Windows jobs verify the AppleClang and MSVC builds but do not claim device execution on the target drivers.
Seams the design has not closed
These are the honest constraints of the 0.8 architecture rather than failures to deliver its selected vertical slice.
Cache identity is still an address. RenderPreparation combines the collection address with its revision. Destroying a collection and constructing another at the same address with the same revision requires a fresh cache.
Any asset revision recompiles the selected graph. Preparation selects only reachable resources, but an insertion advances the collection revision even if the new item is unused. A changed generation rebuilds the complete selected compiled graph, while retaining the already-created white fallback, rather than preserving unchanged selected buffers or images incrementally.
Setup uploads borrow the sole frame slot. Texture staging uses the frame command pool and fence after earlier work has been fully retired. That keeps the single-frame renderer small, but resource compilation cannot overlap drawing and does not yet have an independent scheduling lifetime.
Presentation replacement waits broadly. Device work and all submitted presentation fences finish before new state is committed. The ownership boundary is sound; deferred retirement and overlapping replacement are absent.
A node has only one component role. The loader creates primitive children so an animated mesh source can hold an Animator while its descendants hold render-object IDs. One source node cannot have several animation bindings in the selected importer.
Scene and graphics retain a source-level mutual dependency. Scene draw lists carry graphics identities; preparation consumes those lists. Public headers stay acyclic through forward declarations, but the directory map is not a literal one-way stack.
Platform is not Vulkan-free. Window exposes surface construction and vk::Extent2D. The renderer façade hides device ownership, but the public include tree still contains this platform bridge.
Some window-system checks remain manual. Forced recreation proves ownership replacement and retirement but does not synthesize a real resize callback, minimise/restore cycle, out-of-date coalescing event, or display move.
Deliberate omissions at 0.8
The release supports one complete path and declines to generalise ahead of a demonstrated need.
- One frame in flight; no asynchronous upload context.
- One fixed camera; no camera component or input controls.
- No lighting, normals, tangents, metallic-roughness evaluation, or normal maps.
- No alpha modes, double-sided-material policy, texture transforms, or multiple texture-coordinate sets.
- One base-colour texture per material and no mipmap generation or anisotropic filtering.
- No binary GLB, data URI, embedded image-buffer-view, or runtime selection among several glTF scenes.
- No sparse accessors, non-indexed geometry, or non-triangle primitive modes.
- No imported vertex colours.
- Rotation-only animation with shortest-arc normalised-linear interpolation; no translation, scale, step, or cubic-spline channels.
- No animation blending, events, playback graph, skinning, or morph targets.
- No arbitrary removal or reparenting API for scene nodes.
- No incremental GPU-resource reuse after an asset mutation.
- No general pipeline cache, pass graph, or resource graph.
- Single-threaded loading, preparation, update, and rendering.
Where 0.9 takes this
Version 0.8 proves the serial ownership graph end to end. Version 0.9 applies scheduling pressure to it: uploads move out of the frame slot, resource compilation and frame recording gain independent command lifetimes, two frame slots can remain outstanding, and immutable recording input can be divided between the coordinator and one helper.
The important inheritance is the set of boundaries, not the current waiting strategy. A later concurrent renderer can change when work happens while still accepting format-neutral content, preparing explicit dependency closures, recording current transforms separately from resource compilation, and replacing presentation as its own lifetime.
The completed result is described in the fireEngine 0.9 architecture.