fireEngine 0.7 architecture
Release 0.7 is the first version of fireEngine with an architecture worth describing separately from its code. Earlier releases were a single Vulkan program that grew; 0.7 is the release where that program was split into layers with a rule about which way dependencies point.
This document describes the shape of the system at tag 0.7: its layers, the boundary they are organised around, the contracts between them, and the seams the design had not yet closed. It is deliberately not an implementation reference — the pinned 0.7 source tree is the authority on types and functions at this version, and the release walkthrough posts cover the reasoning behind individual changes. The generated documentation tracks the current tutorial revision rather than 0.7, so it will drift from this page.
What 0.7 actually draws is one coloured triangle. That is the point: the subject is trivial so that the structure carrying it can be judged on its own terms.
The organising decision
Every other choice in 0.7 follows from one: Vulkan resource ownership is contained within the renderer subsystem, and the application reaches it through exactly one façade — Renderer.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Vulkan-free Vulkan-owning
───────────────────────────── boundary ─────────────────────────────
math/ Vec3 Vec4 Mat4 │ render/ Renderer (facade)
│ ├── Device
graphics/ Vertex Color4 │ ├── MemoryAllocator
Mesh Material │ ├── Swapchain
RenderObject │ ├── Pipeline
RenderAssets │ └── FrameInFlight
RenderPreparation │
│
scene/ Scene SceneNode │
SceneDrawList │
│
runs and is tested with no │ requires a real device
window, device, or driver │
Several types own Vulkan resources — Device, Swapchain, Pipeline, FrameInFlight, MemoryAllocator, and AllocatedBuffer — but an application holds none of them. They sit behind Renderer’s implementation pointer, and renderer.hpp declares no Vulkan type at all. A caller describes what exists and where it is using plain C++ values, hands those descriptions to the renderer, and receives an enum back.
This buys three things that matter more than the triangle does:
- Testability. Geometry validation, dependency selection, transform resolution, and cache invalidation are all decidable without a GPU, so they are covered by ordinary unit tests rather than by looking at the screen.
- Substitutability. The description types are not Vulkan types wearing a hat.
TextureFilterwill later be an engine enum rather than an alias forVkFilter, and the same reasoning already applies to meshes and materials. - A place to put importers. A future glTF loader can build descriptions without acquiring a device, because nothing in the description layer needs one.
Layer map
Dependencies point one way: no layer in the main stack includes a header from a layer above it. The one break in that rule is noted below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
application src/main.cpp — builds descriptions, owns the event loop
│
v
render/ Renderer facade and the Vulkan tree behind it
│ where the Vulkan tree and every frame lives
v
scene/ Scene, SceneNode, SceneDrawList
│ hierarchy, world transforms, draw order
v
graphics/ RenderAssets, RenderPreparation, Mesh, Material,
│ RenderObject, typed IDs — what exists and what is needed
v
math/ Vec3, Vec4, Mat4 — shader-compatible storage
platform/ Glfw, Window — used by the application and by render/
core/ log, hash, debug — used by every layer above
| Layer | Responsibility | Knows about |
|---|---|---|
math/ | Column-major 16-byte-aligned matrices and packed vectors, shared with the shader | nothing |
graphics/ | What can be drawn, and which subset a scene actually needs | math/; scene/ in the preparation implementation |
scene/ | Where instances are, and in what order they are drawn | math/, graphics IDs |
platform/ | Window and process-level GLFW lifetime | GLFW, Vulkan surface |
render/ | Turning descriptions into device resources and frames | everything |
core/ | Logging, hashing, validation-layer support | Vulkan, via debug.hpp |
The application layer is thin on purpose. main.cpp builds descriptions, owns the event loop, and calls four renderer methods. It contains no Vulkan and no rendering policy.
The three-phase contract
The system’s central contract is that description, preparation, and drawing are separate phases with separate costs, and the caller controls when each one happens.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
RenderAssets Scene
what can be drawn where the instances are
│ │
│ updateWorldTransforms()
│ │
└──────────────────┬──────────────────┘
│
┌────────────────────────┼──────────────────────────────────────┐
│ Renderer::prepare(assets, scene) │
│ v │
│ buildDrawItems() │
│ ordered draws + dependency hash │
│ │ │
│ v │
│ RenderPreparation::build() │
│ validate → select → cache → generation │
│ │ │
│ v │
│ RenderPreparationPlan last Vulkan-free │
│ ═══════════════════════════════════════════════════════════ │
│ │ Vulkan boundary │
│ v │
│ compile changed resources into device buffers │
└───────────────────────────────────────────────────────────────┘
Renderer::drawFrame(scene) once per frame, on current transforms
buildDrawItems() → record → submit → present
The split exists because the three phases change at different rates. Assets change rarely, the set of things on screen changes occasionally, and transforms change every frame. Collapsing them would make the cheapest change pay the cost of the most expensive one.
prepare() is public and explicit rather than implicit inside drawFrame(). That is a deliberate trade: it puts one more call in the caller’s hands in exchange for a per-frame path that performs no hidden allocation and no hidden upload. When a frame is slow, the reason is in the frame.
Note where the phase boundary actually falls. In the normal application path the renderer owns and drives RenderPreparation inside prepare(); what the caller controls is when that whole phase happens, not its internal steps. The type stays public and independently usable, which is how the unit tests exercise plan selection and cache invalidation without a device at all. Both prepare() and drawFrame() build their own draw list from the scene: the first to decide what must exist on the device, the second to record what to draw with the transforms current at that instant.
Ownership and identity
Three ownership patterns run through the design.
Descriptions are owned by value in one collection, referenced by typed ID. RenderAssets holds meshes, materials, and render objects in dense vectors. Scene nodes hold a RenderObjectId, not a pointer or a shared reference. MeshId, MaterialId, and RenderObjectId are distinct types over the same std::size_t, so the compiler rejects one used where another belongs. An ID is meaningful only against the collection that issued it — a local handle, not a global asset identifier.
Hierarchy is owned by unique pointer. A Scene owns a forest of roots; each SceneNode owns its children. Several roots are supported from the start because glTF has several roots, and inventing an artificial identity node to avoid that would be a lie about the source data.
Device resources are owned by RAII, in declaration order. Vulkan objects use vk::raii wrappers, VMA allocations use small owning types, and members are declared so that reverse-order destruction releases them in dependency order. Every Vulkan-owning class is non-copyable and non-movable: their addresses and destruction order are load-bearing, so the type system is used to say so.
The consequence worth stating plainly: there is no shared ownership and no garbage collection in 0.7. Every resource has exactly one owner, and the lifetime rules are expressed in the type system rather than in comments.
Preparation and its cache key
RenderPreparation sits between the description layer and the renderer. It does not decide whether GPU work happens — it decides whether the Vulkan-free plan changed, and reports that as a generation counter. Renderer::prepare() compares the generation it last compiled against the current one and replaces compiled resources only when they differ. Deciding what changed and deciding what to do about it are kept in separate types.
It answers one question — which subset of the catalogue does this scene actually need? — and caches the answer against a key:
1
2
3
4
5
RenderAssets identity (collection address)
+
asset revision (incremented by every insertion)
+
ordered RenderObjectId dependencies
The key deliberately excludes world transforms. Moving a node changes the matrices recorded into a command buffer; it does not change which mesh must exist on the device. A frame that only moves things therefore reuses every prepared resource, which is what makes the phase split pay for itself.
A dependency hash accompanies the draw list as a fast rejection test, but the exact ordered sequence is retained as the authority, so a hash collision cannot silently reuse the wrong plan.
Validation is placed with preparation rather than with insertion. Adding an asset is cheap and unchecked; the complete catalogue is validated once, whenever it changes, before any mesh buffer is allocated for it. The device, swapchain, pipeline, and per-frame resources already exist by then — they are built by the renderer’s constructor — so this is the boundary in front of scene resources, not in front of all Vulkan allocation. That ordering means malformed input fails deterministically on the CPU with a description of the problem, instead of surfacing later as a device error or a corrupted frame.
The frame protocol
drawFrame() runs a fixed sequence per frame. The ordering is not incidental — each step is placed to make failure recoverable.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
drawFrame(scene)
│
├─ build and validate the draw list before any Vulkan call, so
│ a bad scene cannot strand
│ an acquired image
├─ wait on the frame fence
├─ acquire the next swapchain image ──> eNotPresented if out of date
├─ reset the command pool
├─ record:
│ transition undefined → colour attachment
│ begin dynamic rendering no render-pass or framebuffer
│ bind pipeline, push frame data set 0, push descriptor
│ per draw: bind buffers,
│ push DrawConstants,
│ drawIndexed
│ end rendering
│ transition colour attachment → present source
├─ reset the fence nothing abandons the frame
├─ submit after this point
└─ present ─────────────────────────> ePresented
ePresentedSuboptimal
Two structural choices are visible here. Dynamic rendering removes render pass and framebuffer objects entirely, so the pipeline depends on a colour format rather than on a render-pass object — one fewer thing to rebuild when the swapchain changes. And per-frame data is pushed, not pooled: the frame uniform arrives through a push descriptor and per-draw data through push constants, so 0.7 needs no descriptor pool and no descriptor set lifetime management at all.
The third choice is less visible and matters more. Synchronisation objects are split by what indexes them, and the split runs along an ownership boundary:
1
2
3
4
5
6
7
FrameInFlight owns what belongs to a frame
image-available semaphore orders acquisition → rendering
frame-finished fence tells the CPU the work is done
Swapchain owns what belongs to an image
render-finished semaphore one per swapchain image,
waited on by presentation
The reason they cannot live together is that presentation runs after the submission the fence tracks. A frame-local render-finished semaphore could still be pending in a presentation request when the next frame signalled it again, and the frame fence would not catch it, because the fence is already satisfied by then.
This is the most forward-looking boundary in 0.7. Multiple frames in flight needs per-frame objects to multiply; swapchain recreation needs per-image objects to be rebuilt with the images they belong to. Because those two sets are already owned by different types, neither change has to first untangle them.
There is exactly one frame in flight. That is a simplification rather than a design position — but it is not why waitIdle() is public. The public operation exists for an application to synchronise at shutdown. The renderer performs the same device wait internally, through its own private path, before replacing prepared resources that earlier submissions may still be reading.
The CPU-to-shader data contract
The C++ and Slang sides describe the same memory twice. The architecture keeps them honest with matching layouts pinned by static assertions, rather than with a serialisation step.
1
2
3
4
5
6
7
8
9
10
11
12
C++ Slang frequency
─── ───── ─────────
FrameUniforms ──> ConstantBuffer once per frame
Mat4 viewProjection float4x4 viewProjection set 0, binding 0
DrawConstants ──> [[vk::push_constant]] once per draw
Mat4 model float4x4 model
Color4 baseColor float4 baseColor
Vertex ──> VertexInput once per vertex
Vec3 position location 0 POSITION
Color4 color location 1 COLOR
Mat4 is column-major and 16-byte aligned specifically so it can cross this boundary without repacking, and Color4 exists as a distinct type from Vec4 so that colour and geometry cannot be substituted for one another by accident.
The frame-uniform path is fully active but has no visible effect yet. The matrix is bound, and the vertex shader multiplies by it on every vertex — it simply contains identity, because Mat4 has no rotation, look-at, or perspective operation at 0.7. The path is proven; the camera that will travel down it arrives in 0.8.
Failure and outcome model
The design separates bad news by cause, and gives each cause its own mechanism.
| Kind | Mechanism | Example |
|---|---|---|
| Caller supplied invalid data | std::invalid_argument | a mesh index outside its vertex array |
| Caller used the API out of order | std::logic_error | drawFrame() before prepare() |
| The environment failed | std::runtime_error, Vulkan errors | no suitable device, allocation failure |
| The frame did not present | RenderResult return value | the swapchain went out of date |
The last row is the interesting one. A swapchain becoming out of date during a window resize is a normal event, not an error, so it is a value the caller inspects rather than an exception the caller catches. Reserving exceptions for genuinely exceptional outcomes keeps the event loop readable.
Build and test architecture
The build is arranged so that most of the engine can be tested without a device.
1
2
3
4
5
6
7
8
fireEngineTutorialEngine static library, all engine code
│
├──> fireEngineTutorial thin application, src/main.cpp
│ └──> smoke test --frames 1, one real presented frame
│
└──> fireEngineTutorialTests Catch2, no window or device
shaders/triangle.slang ──slangc──> SPIR-V, at build time
Twenty-four Catch2 cases cover maths, scene traversal, asset validation, preparation caching, SPIR-V loading, and swapchain selection policy. They need no GPU. The one test that does — a bounded application run that presents a single frame and exits — is separated from them precisely because it needs hardware and cannot run everywhere.
Swapchain selection being unit-testable is a small but representative example of the architecture paying off: format, present-mode, extent, image count, and composite-alpha preferences are pure functions over reported capabilities, so they were separated from the Vulkan calls that consume them and are tested against synthetic capability structures.
Seams the design has not closed
Stated plainly, because they are the honest reading of 0.7 rather than afterthoughts.
The public/internal boundary is a documentation convention, not a structural one. Device, Swapchain, Pipeline, FrameInFlight, MemoryAllocator, and AllocatedBuffer all live in the public include tree with Vulkan types in their signatures, even though Renderer is the only intended public surface. The split is expressed by @cond INTERNAL markers and two Doxygen configurations, and among headers only the detail/ ones carry those markers — implementation files use them freely. The include tree does not yet say which of its neighbours are supported API.
Three layers include Vulkan, not one. render/ is the intended home, but platform/window.hpp includes the Vulkan headers and returns a surface — the object owning the native handle is the natural place to create one — and core/debug.hpp includes them for validation-layer support. Both are defensible locally; together they mean “Vulkan lives in render/” describes the intent of the layering rather than the include tree.
Cache identity is an address. RenderPreparation treats a collection’s address plus revision as its identity. Destroying a collection and constructing another at the same address with the same revision would fool it. The type documents this; it does not prevent it.
Swapchain-dependent state is grouped but not separable. The renderer declares its long-lived, presentation-dependent, and per-frame state in distinct blocks, which is what makes recreation tractable later — but at 0.7 the whole Vulkan tree is still built in one constructor and torn down in one destructor, and the renderer is neither copyable nor movable.
core/ holds implementation helpers in public headers. Hashing constants and validation-layer support sit at the top of the core/ tree beside the genuinely public log().
graphics/ and scene/ are mutually dependent. scene/ includes graphics/ for DrawItem and the typed IDs, and graphics/ includes scene/ for SceneDrawList, which RenderPreparation consumes. The public headers stay acyclic — render_preparation.hpp forward-declares SceneDrawList rather than including it — so this is a source-level layer dependency rather than a public-header include cycle, and the build is unaffected. But it is the one place where the layer stack above is not the literal truth, and it comes from SceneDrawList living in scene/ while being defined almost entirely by graphics/ types.
Deliberate omissions at 0.7
Not gaps — decisions to stay narrow until a concrete use case arrived.
- No swapchain recreation; a resize ends the run.
- One frame in flight.
- No camera.
Mat4provides identity, translation, scale, and multiplication only, and the view-projection matrix is identity. - Local transforms are stored as matrices, not decomposed translation, rotation, and scale.
- Scene nodes have no stable identity and cannot be looked up.
- No images, textures, samplers, or descriptor pools.
- No depth buffer, no back-face culling, no lighting.
- No animation of any kind.
- No asset file format; all content is built procedurally in
main.cpp. - Single-threaded throughout.
Where 0.8 takes this
Release 0.8 keeps this architecture and tests it against real imported content — a textured, animated glTF cube — rather than replacing it. The boundary established here is what makes that possible: a loader can be added on the Vulkan-free side, and a texture upload path on the Vulkan-owning side, without the two learning about each other.
The seams above are also where 0.8 does its structural work: the public/internal boundary becomes systematic, swapchain recreation arrives, and the scene gains the identity and decomposed-transform vocabulary that imported content requires. The completed result is described in the fireEngine 0.8 architecture. The 0.8 planning post sets out the release’s vertical slices and links each completed section to its detailed post.