Skip to content

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 · Asset Manager · ZUI System


Table of Contents


Big Picture

flowchart TD
    main["Main Thread\nECS simulation\nInput / VFS tick\nImportCoordinator\nApp::Update / UI"]
    render["Render Thread\nRRM::BeginFrame\nRenderGraph::Execute\nSwapchain present\nRRM::EndFrame"]
    scheduler["MainThreadScheduler\n512 MPSC slots"]
    pool["ThreadPool workers\nGltfImporter\nAssimpImporter\nImportCoordinator jobs"]

    main -->|"mailbox write (lock-free)"| render
    pool -->|"Post(ctx, fn) when UI/main-thread work is needed"| scheduler
    scheduler -->|"Drain() — step 9 each frame"| main
    pool -->|"ImportCoordinator task → AssetManager/RRM queue"| render

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 RenderResourceManager and drained on the render thread.


Thread Model

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.


Main Thread — Per-Frame Work

Engine::MainThreadRun() — ZEngine/ZEngine/Engine.cpp

loop (until s_close_requested):

  1. PollEvent()                    GLFW platform event pump; events feed ZUIContext
  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->OnUpdate(raw_dt)       Subclass update (Editor: viewport hover → camera gate)
     CameraController→Update(dt)   After OnUpdate so hover state is current

 11. Build independent UI payload when an overlay slot is available:
       BeginOverlayFrame → ZUIBeginFrame(ctx, dt)
       OnRenderUI() → ZUILayer::Render() — build ZUI box tree
       EndOverlayFrame → ZUIEndFrame (ZUILayoutSolve + ZUIInteractionPass)
       FillOverlayPayload → ZUIPass::PreparePayload (DFS → draw list)
       PublishOverlay(slot)
      Then SyncECSToRenderScene(alpha) + PrepareScene and publish RenderFrameState.
      The state mailbox chooses a free slot or replaces its oldest unread state; it is
      deliberately latest-state rather than FIFO.

 12. FrameRateCap::Wait()          Applied unconditionally — including when mailbox is
                                    full (buffer-full continue also calls Wait so the
                                    main loop never spins at 100k+ Hz)

Render Thread — Per-Frame Work

Engine::RenderThreadRun() — ZEngine/ZEngine/Engine.cpp

loop (until s_request_terminate):

  1. Latest-state read (lock-free)    Take newest RenderFrameState; retain 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::RenderScene
       Snapshot RenderScene instances and rebuild per-frame mesh input
       Resolve resident mesh offsets; upload TransformSB, DrawDataSB, culling input
       (this currently runs every rendered frame; InstancesDirty is cleared here)

  5. RenderGraph::Execute
       DepthPrePass  → DrawIndirect   (all scene meshes, depth only)
       GbufferPass   → DrawIndirect   (all scene meshes, full G-buffer)
       LightingPass  → Draw(3)        (full-screen deferred lighting triangle)
       Sky passes    → HDRI/analytic-sky bake, compose, or background as enabled
       ToneMapping + GridPass → final FrameColor (builtin compatibility quad)
       ZUIPass       → DrawIndexed    (overlay, targets swapchain)

  6. Swapchain::Present               Submit + present, advance timeline semaphore

  7. RRM::EndFrame(frame_index)       Drain DeferredFreeQueue for this slot

  8. render_timer.End()               Sample wall-clock delta (includes vsync wait)
     g_engine_ctx→SmoothedDeltaTime = render_timer.SmoothedDelta()
                                       Written here so FPS display reflects true GPU rate

The render thread never writes ECS state. It receives copied camera/sky/resize configuration through RenderFrameState, plus independently retained ZUI payloads. Its RenderScene* is still a borrowed pointer, not the renderer-owned immutable scene snapshot required for production editor mutations.


Cross-Thread Communication

Three distinct channels, each with a different mechanism.

1. Main → Render: Lock-free mailbox

sequenceDiagram
    participant Main as Main Thread
    participant State as RenderFrameState[3] slots
    participant UI as OverlayPayload[3] slots
    participant Render as Render Thread

    Main->>State: copy and publish newest RenderFrameState
    Main->>UI: publish completed ZUI payload when a slot is free
    Render->>State: claim newest Ready slot and release older unread slots
    alt new state exists
        Render->>Render: retain newest state
    else no new state
        Render->>Render: reuse retained state
    end

State and UI each use three ownership-state slots (Free, Writing, Ready, Reading) with atomic acquire/release transitions. They are separate channels: a missed UI replacement does not block simulation or state publication.

2. Background → Main: MainThreadScheduler

sequenceDiagram
    participant Worker as ThreadPool Worker
    participant MTS as MainThreadScheduler
    participant Main as Main Thread (step 9)

    Worker->>MTS: Post(ctx, fn) — fetch_add claims slot
    Worker->>MTS: write ctx + fn
    Worker->>MTS: ready[slot].store(true, release)
    Main->>MTS: Drain() — exchange write_cursor to 0
    loop for each claimed slot
        Main->>MTS: spin on ready[i].load(acquire)
        Main->>Main: fn(ctx)
        Main->>MTS: ready[i].store(false)
    end

512 arena-allocated slots, lock-free MPSC, no heap, C-style Post(void* ctx, void (*fn)(void*)).

Current users: editor panel/UI operations that must defer work out of the build/event callback, including asset-import and viewport drop flows.

3. Asset thread → Render thread: RRM pending queue

sequenceDiagram
    participant Asset as Asset Thread
    participant AM as AssetManager
    participant RRM as RenderResourceManager
    participant GPU as GPU

    Asset->>AM: IngestMesh(mesh, hier)
    AM->>AM: AssetRegistry::SetState(Loaded)
    AM->>RRM: OnAssetReady callback
    RRM->>RRM: m_pending.push (m_pending_mutex)
    Note over RRM: Next BeginFrame on render thread
    RRM->>RRM: FlushPendingUploads — drain m_pending[]
    RRM->>GPU: DoUploadMesh → vkCmdCopyBuffer

m_pending[MAX_PENDING=1024] is a fixed array protected by m_pending_mutex. All Vulkan work stays on the render thread.


ECS and Simulation

Files: ZEngine/ZEngine/ECS/

Two-tier object model

flowchart TD
    AM["ActorManager\nMAX_ACTORS = 1024\narena-backed HandleManager"]
    A["Actor (Tier 1)\nC++ object with vtable\nOwns EntityID\nOnCreate / OnTick / OnDestroy"]
    EID["EntityID\nindex (uint32) + generation (uint32)\nIsValid() = generation != 0"]
    SC["Scene\nEntityRegistry (MAX 65536)\nHash map from ComponentTypeID to IComponentStorage pointer"]
    CS["Component storage by component type\nsparse-set\nm_sparse[] + m_dense[] + m_dense_ids[]"]
    WT["WorldTick\nDAG scheduler\nMAX_SYSTEMS = 64"]
    WC["WorldCommands\ndeferred mutations\ninitial cap 256 commands"]

    AM -->|"Create by actor type allocates Actor\nassigns EntityID"| A
    A -->|"owns"| EID
    EID -->|"indexes"| SC
    SC -->|"stores components in"| CS
    WT -->|"drives"| SC
    WT -->|"writes deferred mutations to"| WC
    WC -->|"Flush() applies to"| SC
Tier Type Used for Limit
1 Actor — C++ object with vtable, owns an EntityID Player, camera, lights, scripted objects 1 024
2 Raw EntityID — data only, no vtable Foliage, particles, projectiles, bulk objects 65 536

Both tiers share the same ComponentStorage<T> sparse-set arrays. Scene::ForEach<Ts...>() and Query<Ts...> visit all alive entities regardless of tier.


Components

All components live in ECS/Components/, namespace ZEngine::ECS::Components. A ComponentTypeID (uint32_t) is assigned lazily at the first call to ComponentTypeOf<T>() via an atomic counter. Up to 64 component types are supported (one bit per type in the 64-bit ArchetypeMask).

Component Key fields Notes
TransformComponent local Position, Rotation, Scale, PreviousPosition, derived WorldTransform WorldTransform is propagated by HierarchySystem and is never directly authored. This component intentionally exceeds one cache line; do not claim a 64-byte limit.
MeshComponent uuids::uuid MeshUUID, runtime uint32_t RenderInstanceId RenderInstanceId = UINT32_MAX means no render instance is currently bound. TransformSyncSystem updates bound instances; load/create/delete binding reconstruction is an authoring lifecycle concern.
CameraComponent FovY, Near, Far, AspectRatio, bool IsMain Exactly one entity should have IsMain = true.
LightComponent Type (Directional/Point/Spot), Intensity, Range, SpotAngle, Color[3] Range and SpotAngle unused for Directional lights.
MaterialComponent uuids::uuid MaterialUUID Per-instance material override. Absent = use mesh's baked material UUIDs.
NameComponent char Value[128] Display name for Outliner and debug.
RigidBodyComponent MotionType (Static/Kinematic/Dynamic), Mass, Friction, Restitution, uint32_t BodyID BodyID = UINT32_MAX = inactive. Physics integration (Jolt) planned Sprint 6.
UUIDComponent uuids::uuid Value Stable cross-session identity for scene serialization.

Scene

Scene owns the entity registry and all component storages. All arena allocations go through the ArenaAllocator* provided at Initialize.

// Entity lifecycle
EntityID CreateEntity();
void     DestroyEntity(EntityID id);   // removes from all storages
bool     IsAlive(EntityID id) const;

// Component access (all template, O(1) via sparse-set)
template<T> void  AddComponent(EntityID id, T component);
template<T> T*    GetComponent(EntityID id);
template<T> bool  HasComponent(EntityID id) const;
template<T> void  RemoveComponent(EntityID id);

// Query — visits all alive entities that have all Ts
template<typename... Ts, typename Fn>
void ForEach(Fn&& fn);   // Fn signature: void(EntityID, Ts&...)

// Fixed-timestep interpolation support
void SnapshotTransforms();                              // PreviousPosition ← Position
void FillRenderableTransforms(float alpha,             // lerp Position by alpha
    Array<RenderableTransform>& out);

ForEach iteration model: iterates entity slots in the EntityRegistry, checks the ArchetypeMask against (ComponentTypeOf<T1> | ComponentTypeOf<T2> | ...), and skips non-matching entities. Does not iterate component dense arrays directly. Query<Ts...> is a thin wrapper that pre-computes the mask at construction.


Actor

Actor is the Tier-1 entity class. It cannot be instantiated directly — only via ActorManager::Create<T>().

sequenceDiagram
    participant App
    participant AM as ActorManager
    participant Scene

    App->>AM: Create PlayerActor
    AM->>Scene: CreateEntity() → EntityID
    AM->>AM: arena-alloc PlayerActor, assign EntityID + Scene*
    AM->>App: actor->OnCreate()   ← override to AddComponent()
    AM-->>App: ActorHandle

    App->>AM: Destroy(handle)
    AM->>App: actor->OnDestroy()  ← override to clean up
    AM->>Scene: DestroyEntity(id)
    AM->>AM: explicit destructor, remove handle

Virtual interface:

virtual void OnCreate()       {}   // called once after entity assigned; add components here
virtual void OnDestroy()      {}   // called before entity destroyed
virtual void OnTick(float dt) {}   // called every frame by ActorManager::Tick()

Component helpers (inline on the Actor base):

template<T> void AddComponent(T c);
template<T> T*   GetComponent();
template<T> bool HasComponent() const;
template<T> void RemoveComponent();

No default components are attached by the base class. A typical OnCreate() override looks like:

void PlayerActor::OnCreate() {
    AddComponent(TransformComponent{});
    AddComponent(MeshComponent{ .MeshUUID = uuid });
    AddComponent(NameComponent{ .Value = "Player" });
}


WorldTick — System Scheduler

WorldTick builds a dependency DAG from registered systems (SystemFn = void(*)(Scene&, float, WorldCommands&)) and executes them in topological wave order. It ships no built-in systems — application code registers all systems.

flowchart TD
    R["RegisterSystem(fn, deps)\ndeps: ReadMask, WriteMask, UsesCommands"]
    OB["OrderBefore(a, b)\ndeclare explicit ordering edge\nonly needed when masks conflict"]
    C["Commit()\nBuildEdges — detect mask conflicts\nTopologicalSort (Kahn)\nAssert: no cycles, no unresolved conflicts\nPre-allocate per-system staging WorldCommands"]
    W0["Wave — single system\nruns inline on main thread\nwrites to caller's WorldCommands"]
    W1["Wave — multiple systems\ndispatch to ThreadPool workers\neach writes to its own staging buffer"]
    B["Barrier\n100-spin yield then cv.wait_for (30s)"]
    M["Merge staging buffers\n(in wave order)\nfix up SpawnCallbackIndex offsets"]
    F["WorldCommands::Flush(scene)\napply SpawnEntity / DestroyEntity\nAddComponent / RemoveComponent"]

    R --> OB --> C
    C --> W0 --> W1 --> B --> M --> F
    F -->|"next Tick()"| W0

Conflict rule: systems A and B conflict if any of these overlap: A.WriteMask & B.ReadMask, A.WriteMask & B.WriteMask, A.ReadMask & B.WriteMask. Conflicting systems must have an explicit OrderBefore edge or Commit() asserts.


WorldCommands — Deferred Mutations

Structural scene mutations (AddComponent, DestroyEntity, etc.) cannot happen inside a parallel wave — they would race with other workers reading the same sparse-set arrays. Every mutation is buffered in WorldCommands and applied atomically after the wave barrier.

flowchart TD
    PW["Parallel wave\nN workers each have own staging[i]"]
    S0["worker 0\nstaging[0].SpawnEntity(callback)\nstaging[0].AddComponent(id, comp)"]
    S1["worker 1\nstaging[1].DestroyEntity(id)\nstaging[1].RemoveComponent by type (id)"]
    B["Barrier — all workers complete"]
    M["Main thread: Merge staging[0..N]\nfix SpawnCallbackIndex offsets"]
    F["WorldCommands::Flush(scene)\napply in command order\nSpawnCallbacks invoked with new EntityID"]

    PW --> S0 & S1 --> B --> M --> F

Supported command kinds:

Command What it does in Flush()
SpawnEntity(callback) Calls scene.CreateEntity(); invokes optional SpawnCallback(EntityID) with the new id
DestroyEntity(id) Deduplicates; guards for already-dead entity
AddComponent<T>(id, T) Copies component (≤ 256 bytes) into fixed buffer; calls scene.AddComponent
RemoveComponent<T>(id) Stores type-erased apply-fn pointer; calls scene.RemoveComponent<T>

Staging buffers are pre-allocated at Commit() (arena-backed, initial cap 256 commands per system) and reused every frame via Clear() — no heap after the first exercised frame.


ECS → Render synchronization

HierarchySystem, TransformSyncSystem, and LightSyncSystem provide the current main-thread derived-state synchronization path. They do not make runtime render IDs serializable and do not replace the scene-load/create/delete binding lifecycle.

flowchart LR
    TC["TransformComponent\n(ECS)"]
    MC["MeshComponent\nRenderInstanceId = UINT32_MAX"]
    FRT["HierarchySystem + TransformSyncSystem\nlocal TRS -> WorldTransform -> RenderScene"]
    RS["RenderScene::MeshInstance[]\nId, MeshUUID, Transform, Name"]
    DP["DrawScene → DrawIndirect"]

    TC -->|"exists"| FRT
    MC -->|"bound instance ID"| RS
    FRT -->|"updates existing instance transform"| RS
    RS --> DP

What exists: - HierarchySystem computes WorldTransform from local TransformComponent values. - TransformSyncSystem updates bound RenderScene mesh instance transforms. - LightSyncSystem updates RenderScene light data from ECS light/transform components. - MeshComponent::RenderInstanceId links a live ECS entity to a live RenderScene instance.

Authoring work still required: - Staged scene load/create/delete must rebuild mesh-instance bindings before publishing a new scene instance. - RenderInstanceId remains runtime-only and is never serialized or stored in undo snapshots. - The render thread consumes immutable snapshots; it must not read mutable ECS data.


Asset Pipeline

flowchart TD
    src["Source file\n.glb / .fbx / .png / .hdr"]
    importer["GltfImporter / AssimpImporter\n(ThreadPool worker via ImportCoordinator)"]
    cooked["Cooked artifacts\n.zemesh · .zematerial · Assets/Textures/…"]
    ingest["AssetManager::IngestMesh\nAssetManager::IngestTextures\nAssetManager::IngestMaterial"]
    registry["AssetRegistry::SetState(Loaded)\n→ RRM::OnAssetReady → pending upload queue"]
    rrm["RRM::FlushPendingUploads\n(render thread, next BeginFrame)"]
    gpu["GPU global VB/IB\nTextureArray (bindless)"]
    pipeline["AppRenderPipeline::RenderScene\nper-frame instance snapshot\nsubmesh/culling input + DrawIndirect"]

    src --> importer --> cooked --> ingest --> registry --> rrm --> gpu --> pipeline

On scene reload: EditorScene::ExtractAsync calls RRM::ResetGeometryBuffers() before ingestion; the render thread resets global buffer cursors to 0 so new geometry starts fresh.


Virtual File System (VFS)

Files: ZEngine/ZEngine/Core/VFS/

graph TD
    ctx["VFSContext"]
    m1["Mount /ZodiacEngine\n→ VFSDiskBackend\n→ executable-dir/ZodiacEngine/\npriority = -1"]
    m2["Mount /\n→ VFSDiskBackend\n→ project root\npriority = 0"]
    path["VFSPath\nnormalized, immutable\nno-alloc value type"]
    scanner["VFSScanner\nasync directory walker\npopulates content browser cache"]
    watcher["VFSFileWatcher\nFSEvents / inotify / RDCW\ndebounced via VFSContext::Tick()"]
    meta["MetaFileIO\n.meta sidecars\nuuid · source path · artifact path"]
    reg["AssetRegistry\nuuid → SlotHandle + state\ntriggers RRM::OnAssetReady on Loaded"]

    ctx --> m1 & m2
    ctx --> scanner & watcher & meta & reg
    ctx --> path

FileWatcher → hot-reload flow:

sequenceDiagram
    participant FW as VFSFileWatcher
    participant CTX as VFSContext::Tick (main thread)
    participant AR as AssetRegistry
    participant IC as ImportCoordinator
    participant TP as ThreadPool

    FW->>CTX: debounced change event
    CTX->>AR: OnAssetModified(path) → mark matching record/dependents stale
    CTX->>IC: Enqueue(path, Immediate)
    IC->>TP: dispatch reimport job (next Tick)

Initialization Order

Engine::Initialize() — ZEngine/ZEngine/Engine.cpp

sequenceDiagram
    participant EP as EntryPoint
    participant Win as GameWindow
    participant Dev as VulkanDevice
    participant VFS as VFSContext
    participant AM as AssetManager
    participant ECS as ECS (Scene · ActorManager · WorldTick)
    participant IC as ImportCoordinator
    participant RRM as RenderResourceManager
    participant MTS as MainThreadScheduler

    EP->>Win: Initialize (GLFW + VkInstance + surface)
    Win->>Dev: Initialize (queues, VMA, bindless descriptors)
    Dev->>VFS: Initialize (mount table, disk backend)
    VFS->>AM: Initialize (UUID map, registry, 1,024 MiB arena + 256 MiB container slab)
    AM->>ECS: Initialize (Scene, ActorManager, WorldCommands, WorldTick)
    ECS->>IC: Initialize + register importers (Gltf, Assimp, EnvMap)
    IC->>RRM: Initialize (global VB/IB, texture timelines, upload pool)
    RRM->>AM: InitFallbackTexture (hot-pink 4×4 — requires RRM live)
    AM->>VFS: InitWatcher (FSEvents / inotify / RDCW)
    VFS->>MTS: Initialize (512 MPSC slots from Bootstrap owner)

Hard dependencies: Device before VFS (surface), RRM before fallback texture, watcher after working directory.

Packaged engine assets

Engine-owned files are resolved from ZodiacEngine/ beside the executable, not from the process working directory. This keeps a Debug or installed package relocatable: it may be launched from the repository root, its output directory, or any other directory. Startup validates the package root before mounting it at /ZodiacEngine; all files under that directory then use the ordinary VFS lookup path, including shaders.

Development tools and package tests may explicitly set ZENGINE_ASSET_ROOT to an alternate ZodiacEngine directory. The override takes precedence over the executable-relative package location; normal application launches should not need it.


Shutdown Order

flowchart TD
    T["s_request_terminate = true"]
    RT["Join render thread\nNO GPU work after this"]
    MTS["MainThreadScheduler::Shutdown\ndiscard pending tasks"]
    ECS["ECS::ActorManager::Shutdown\nECS::Scene::Shutdown"]
    RRM["RRM::Shutdown\nQueueWaitAll · destroy upload pools · free global buffers"]
    AM["AssetManager::Shutdown"]
    ARP["AppRenderPipeline::Shutdown\nRenderGraph::Dispose (pipelines, framebuffers, callback passes)"]
    VFS["VFS::Shutdown"]
    DEV1["VulkanDevice::Deinitialize\nQueueWaitAll · first PendingFree drain\nSwapchainPtr→Dispose · CommandBufferMgr::Deinit\nsecond PendingFree drain"]
    WIN["Window::Deinitialize"]
    DEV2["VulkanDevice::Dispose\nfinal PendingFree drain\nGpuMem::Shutdown · vkDestroyDevice"]

    T --> RT --> MTS --> ECS --> RRM --> AM --> ARP --> VFS --> DEV1 --> WIN --> DEV2

See Rendering Domain — Shutdown for the Vulkan object destruction rules.