Repository navigation
Engine Architecture
Authoritative reference for ZEngine's overall architecture — threads, communication, and how all subsystems fit together. Update it when a significant architectural change lands in develop.
See also: Rendering Domain · Memory Management
- Big Picture
- Thread Model
- Main Thread — Per-Frame Work
- Render Thread — Per-Frame Work
- Cross-Thread Communication
- ECS and Simulation
- Asset Pipeline
- Virtual File System (VFS)
- Initialization Order
- Shutdown Order
┌───────────────────────────────────────────────────────────────────┐
│ Process │
│ │
│ ┌──────────────────────┐ mailbox ┌─────────────────────────┐ │
│ │ Main Thread │ ────────► │ Render Thread │ │
│ │ │ │ │ │
│ │ ECS simulation │ │ RRM::BeginFrame │ │
│ │ Input / VFS tick │ │ RenderGraph::Execute │ │
│ │ ImportCoordinator │ │ Swapchain present │ │
│ │ App::Update / UI │ │ RRM::EndFrame │ │
│ └──────────┬───────────┘ └─────────────────────────┘ │
│ │ Post() │
│ ▼ │
│ MainThreadScheduler ◄── ThreadPool workers (import, audio…) │
│ │
│ ThreadPool workers ─────────────────────────────────────────────► │
│ GltfImporter, AssimpImporter, ImportCoordinator jobs │
└───────────────────────────────────────────────────────────────────┘
Key design rules:
- Main thread owns all ECS state, input, and UI logic.
- Render thread owns all Vulkan command recording and submission.
- Neither thread blocks on the other within a frame.
- Background workers never touch Vulkan — GPU work is enqueued to
RenderResourceManagerand drained on the render thread.
| Thread | Count | Launched by | Joins at |
|---|---|---|---|
| Main thread | 1 | OS entrypoint (main()) |
Process exit |
| Render thread | 1 | Engine::Run() |
Engine::Deinitialize() |
| ThreadPool workers | hw_concurrency - 1 |
ThreadPoolHelper::Initialize() |
ThreadPoolHelper::Shutdown() |
The render thread is launched before MainThreadRun() begins and joined first in teardown — before any GPU resource is touched.
Engine::MainThreadRun() — ZEngine/ZEngine/Engine.cpp
loop (until s_close_requested):
1. PollEvent() GLFW platform event pump
2. VFSContext::Tick() Drain FileWatcher debounce queue
3. skip frame if minimized
4. frame_timer.End() Measure raw delta (clamped 250 ms)
5. accumulator.Accumulate(dt)
6. Fixed simulation (60 Hz, max 5 catch-up steps):
WorldTick::Tick(scene, fixed_dt, cmds) ECS systems in DAG order
WorldCommands::Flush(scene) Deferred structural mutations
ActorManager::Tick(fixed_dt) Actor OnTick() callbacks
Scene::SnapshotTransforms() PreviousPosition ← Position
accumulator.ConsumeStep()
7. alpha = accumulator.Alpha() Interpolation factor for renderer
8. ImportCoordinator::Tick() Dispatch pending import jobs to ThreadPool
9. MainThreadScheduler::Drain() Execute callbacks posted by background threads
10. g_app->Update(raw_dt) Editor/game non-ECS update, layer updates
11. Build RenderPayload PrepareScene + OnRenderUI (ImGui)
12. Mailbox write (lock-free) Push payload to render thread
13. FrameRateCap::Wait() vsync-off only: cap at 300 FPS
Engine::RenderThreadRun() — ZEngine/ZEngine/Engine.cpp
loop (until s_request_terminate):
1. Mailbox read (lock-free) Wait for payload; re-use last if none
2. Swapchain::AcquireNextImage Block on fence for in-flight slot
3. RRM::BeginFrame(frame_index)
FlushPendingUploads:
├─ ResetGeometryBuffersInternal (if scene-reload flag is set)
├─ BeginBatchUpload
├─ DoUploadMesh × N All mesh uploads in ONE GPU submission
├─ EndBatchUpload
└─ DoUploadTexture × M Per-texture timeline upload
4. AppRenderPipeline::Tick (if InstancesDirty)
GetMeshOffsets → SubMeshAllocation per submesh
Upload TransformSB, DrawDataSB, VkDrawIndirectCommand[]
5. RenderGraph::Execute
DepthPrePass → DrawIndirect (all scene meshes, depth only)
SkyboxPass → DrawIndexed (builtin cube — disabled if no env map)
GridPass → DrawIndexed (builtin quad + push constants)
GbufferPass → DrawIndirect (all scene meshes, full G-buffer)
LightingPass → Draw(3) (full-screen deferred lighting triangle)
6. ImGuiRenderer::Render Upload vertex/index, record ImGui draw commands
7. Swapchain::Present Submit + present, advance timeline semaphore
8. RRM::EndFrame(frame_index) Drain DeferredFreeQueue for this slot
The render thread never writes ECS state. It reads only from the RenderPayload and RenderScene::MeshInstance[] (seqlock snapshot).
Three distinct channels, each with a different mechanism.
Main thread Render thread
───────────────────────────────────── ───────────────────────────────────
PrepareScene(r_payload)
OnRenderUI(r_payload)
MailBoxBufferHead.store(next, release) ─► tail = MailBoxBufferHead.load(acquire)
if head != tail: consume payload
MailBoxBufferHead is a PaddedAtomic<uint32_t> index into a 3-slot ring buffer. Main writes with store(release); render reads with load(acquire). If head == tail the render thread re-uses the last payload (drops frame). No mutex, no blocking.
ThreadPool worker (any thread): Main thread (step 9 each frame):
MainThreadScheduler::Post(ctx, fn) MainThreadScheduler::Drain()
──── lock-free MPSC slot-claim ────► execute all committed tasks sequentially
Lock-free MPSC: PaddedAtomic<uint32_t> write cursor claims a slot via fetch_add; data is written; per-slot PaddedAtomic<bool> ready published with release ordering. Drain atomically swaps the cursor to 0 and spins (acquire) on each slot's ready flag — terminates immediately in practice since producers complete before the next frame boundary.
512 arena-allocated slots, no heap, no std::mutex, C-style Post(void* ctx, void (*fn)(void*)).
Current users:
-
AssetImporterUIComponent— postsTriggerScan()after import completes or fails
Pattern for new callers:
MainThreadScheduler::Post(this, [](void* ctx) {
static_cast<MyComponent*>(ctx)->OnWorkDone();
});Asset thread: Render thread (BeginFrame):
AssetManager::IngestMesh(mesh)
AssetRegistry::SetState(Loaded)
→ RRM::OnAssetReady callback
→ m_pending.push (m_pending_mutex) ─► FlushPendingUploads drains m_pending[]
→ DoUploadMesh → GPU copy via m_upload_cmd
m_pending[MAX_PENDING=256] is a fixed array protected by m_pending_mutex. Asset threads write; the render thread drains each BeginFrame. All Vulkan work stays on the render thread.
Files: ZEngine/ZEngine/ECS/
Two-tier object model — see actor-ecs-architecture.md for full detail:
| Tier | Type | Used for |
|---|---|---|
| 1 |
Actor — C++ object with vtable, owns an EntityID
|
Player, camera, lights |
| 2 | Raw EntityID — data only, no object |
Foliage, particles, projectiles |
Both tiers share the same ComponentStorage<T> sparse-set arrays. Scene::ForEach<Ts...>() visits all alive entities regardless of tier.
Simulation order each fixed step (step 6 of main loop):
WorldTick::Tick — ECS systems in DAG order; parallel wave if no write conflicts
WorldCommands::Flush — deferred spawn/destroy/add-component applied atomically
ActorManager::Tick — Actor::OnTick() on all live actors
Scene::SnapshotTransforms — PreviousPosition ← Position (for interpolation)
Not yet wired (issue #604):
Scene::FillRenderableTransforms() is implemented but never called — ECS transforms do not flow to the GPU TransformSB. The renderer currently reads transforms from RenderScene::MeshInstance[].
Source file (.glb / .fbx / .png / .hdr)
│
▼ ImportCoordinator::Tick() → ThreadPool worker
GltfImporter / AssimpImporter / EnvironmentMapImporter
Cook → .zemesh + .zematerial (JSON) + Assets/Textures/…
No Vulkan, pure CPU / file I/O
│
▼ AssetManager::IngestMesh / IngestTextures
CPU-side: copy to arena arrays, register in AssetRegistry
SetState(Loaded) → RRM::OnAssetReady → m_pending.push
│
▼ (next RRM::BeginFrame on render thread)
FlushPendingUploads → BatchUpload → GPU global VB/IB
│
▼ AppRenderPipeline::Tick (when InstancesDirty)
GetMeshOffsets → SubMeshAllocation → DrawIndirect
On scene reload: EditorScene::ExtractAsync calls RRM::ResetGeometryBuffers() (atomic flag) before ingestion; the render thread resets global buffer cursors to 0 so the new scene's geometry starts fresh. See Rendering Domain — Global Geometry Buffers.
Files: ZEngine/ZEngine/Core/VFS/
All engine and project assets are accessed through VFS paths regardless of physical location.
VFSContext
├── Mount("/ZodiacEngine", VFSDiskBackend → cwd/ZodiacEngine/) priority=-1
└── Mount("/", VFSDiskBackend → project root) priority=0
| Component | Purpose |
|---|---|
VFSPath |
Normalized, immutable, no-alloc path value type (/-separated) |
VFSScanner |
Async directory walker; populates content browser cache |
VFSFileWatcher |
Debounced platform watcher (FSEvents / inotify / RDCW); fired via VFSContext::Tick() on the main thread |
MetaFileIO |
Reads/writes .meta sidecars (stable UUID, source path, artifact path) |
AssetRegistry |
UUID-indexed store; triggers RRM::OnAssetReady on Loaded state |
FileWatcher → hot-reload flow:
VFSFileWatcher → debounce → VFSContext::Tick() (main thread)
→ AssetRegistry::SetStale(uuid)
→ ImportCoordinator::Enqueue(path, Immediate)
→ ImportCoordinator::Tick() dispatches reimport job to ThreadPool
Engine::Initialize() — ZEngine/ZEngine/Engine.cpp
1. Validate preconditions (MemoryManager, Logger, ThreadPool — caller responsibility)
2. Arena-allocate EngineContext
3. GameWindow::Initialize (GLFW + Vulkan surface + VkInstance + VkDevice)
4. VulkanDevice::Initialize (queues, command pools, VMA allocator, bindless descriptor sets)
5. VFSContext::Initialize (mount table, VFSDiskBackend for engine assets)
6. AssetManager::Initialize (UUID map, registry, 78 MB sub-arena)
7. InputManager::Initialize
8. ECS: Scene, ActorManager, WorldCommands, WorldTick::Initialize
9. ImportCoordinator::Initialize + register importers (Gltf, Assimp, EnvMap)
10. RenderResourceManager::Initialize (global VB/IB, texture timelines, upload pool)
11. AssetManager::InitFallbackTexture (hot-pink 4×4 PNG — requires RRM live)
12. VFSContext::InitWatcher (FSEvents/inotify/RDCW)
13. GLFW scroll callback
14. MainThreadScheduler::Initialize (512 MPSC slots from MainArena)
15. g_app->CurrentWindow = window
Hard dependencies: Device before VFS (surface), RRM before fallback texture, watcher after working directory.
1. s_request_terminate = true Signal render loop to exit
2. g_render_thread.join() Wait for render thread — NO GPU work after this
3. MainThreadScheduler::Shutdown() Discard any pending main-thread tasks
4. ECS::ActorManager::Shutdown()
5. ECS::Scene::Shutdown()
6. RRM::Shutdown() QueueWaitAll, destroy upload pools, free buffers
7. AssetManager::Shutdown()
8. AppRenderPipeline::Shutdown() RenderGraph::Dispose (pipelines, framebuffers)
9. VFS::Shutdown()
10. VulkanDevice::Deinitialize() Drain deferred-free queue, destroy Vulkan objects
11. Window::Deinitialize()
12. VulkanDevice::Dispose() Final PendingFree drain, GpuMem shutdown, vkDestroyDevice
See Rendering Domain — Shutdown for the Vulkan object destruction rules.