Architecture Overview

This page describes the high-level architecture of the Safi Engine: how C# game logic communicates with the C++ Vulkan renderer, what each file is responsible for, and how the build pipeline produces a running application.

High-Level Architecture

The engine is split into two language domains. C# (running on Mono) owns the main loop, ECS world, and all gameplay logic. C++ owns the Vulkan rendering backend. The two sides communicate exclusively through P/Invoke -- a foreign-function interface where C# calls extern "C" functions exported from a shared library.

C# Engine (managed/)            C++ VulkanRenderer (native/)
  World, Components, Bridge
C# Game (game_logic/)
  Scene setup, Systems       ──P/Invoke──>  GPU resources
  Per-frame logic            ──────────>    Draw calls
  NativeBridge.cs            ──DllImport──> bridge.cpp (extern "C")

Data flows one direction: C# tells C++ what to render. The native side never calls back into managed code. Every frame, C# systems iterate over ECS components and issue bridge calls to set transforms, update lights, submit UI vertices, and trigger the draw. The C++ side translates these into Vulkan command buffers.

A separate P/Invoke boundary exists for physics: PhysicsBridge.cs calls into libjoltc.dylib (built from the native/joltc/ submodule). The PhysicsWorld singleton manages the Jolt lifecycle, and PhysicsSystem (in game logic) creates bodies, steps the simulation, and syncs transforms back to ECS.

File Map

FilePurpose
native/renderer.hVulkanRenderer class declaration and all GPU-facing struct definitions (Vertex, UIVertex, GpuLight, LightUBO, etc.). Also defines MAX_LIGHTS (8) and light type constants.
native/renderer/renderer.cppCore Vulkan renderer: init, cleanup, renderFrame, camera, input, time, lighting UBO updates, and the per-frame render loop.
native/renderer/vulkan_init.cppVulkan setup: instance/device/swapchain creation, render pass setup, all three graphics pipelines, sync objects.
native/renderer/vulkan_util.cppVulkan utilities: buffer/image creation, layout transitions, shader module loading.
native/scene/mesh.cppMesh loading (glTF via cgltf), procedural primitives (box, sphere, plane, cylinder, capsule), combined vertex/index buffer management. #define CGLTF_IMPLEMENTATION lives here.
native/scene/entity.cppEntity pool management and debug entity tracking.
native/scene/material.cppTexture loading (stb_image), material/descriptor management, default texture, sampler. #define STB_IMAGE_IMPLEMENTATION lives here.
native/ui/ui.cppUI overlay pipeline, font atlas baking (stb_truetype), UI vertex buffer management, text rendering. #define STB_TRUETYPE_IMPLEMENTATION lives here.
native/bridge.cppextern "C" wrappers that delegate to a file-static g_renderer instance of VulkanRenderer. Each function is a thin try/catch shell around the corresponding method. This is the only translation unit that C# can see.
native/shaders/shader.vert3D vertex shader. Reads a UBO containing view and proj matrices, receives the per-entity model matrix via push constant. Outputs world-space position, normal, vertex color, and UV coordinates to the fragment stage.
native/shaders/shader.frag3D fragment shader. Implements Blinn-Phong shading with support for up to 8 dynamic lights (directional, point, spot). Samples a base color texture and combines it with per-vertex color and lighting.
native/shaders/ui.vertUI vertex shader. Converts pixel coordinates to NDC using a screenSize vec2 push constant. Passes through UV and vertex color.
native/shaders/ui.fragUI fragment shader. Samples an R8_UNORM font atlas texture, multiplies the single-channel alpha by the vertex color, and outputs for alpha blending.
native/CMakeLists.txtCMake configuration. Links against Vulkan, GLFW, and GLM. Produces librenderer.dylib. Enables VK_KHR_portability_enumeration for MoltenVK compatibility. Generates compile_commands.json for IDE intellisense.
native/vendor/Header-only third-party libraries: cgltf.h (glTF 2.0 parsing), stb_truetype.h (TrueType font rasterization), stb_image.h (image decoding for textures). Each requires a #define *_IMPLEMENTATION in exactly one .cpp file.
managed/runtime/Viewer.csApplication entry point. Creates the ECS World, calls Game.Setup(world), and runs the main loop.
managed/core/World.csECS world: entity creation/despawning, component storage, and Query() for matching entities by component type.
managed/core/Components.csAll component definitions (plain C# classes, data only). Includes Color (RGBA with hex string support), Transform, MeshRenderer, Camera, Light, Hierarchy, Rigidbody, Collider (with DebugColor for wireframe visualization), etc.
managed/rendering/NativeBridge.csC# P/Invoke declarations ([DllImport("renderer")]) for every function exported by bridge.cpp. Also defines GLFW key/mouse constants used by input systems.
managed/physics/PhysicsBridge.csC# P/Invoke declarations ([DllImport("joltc")]) for the Jolt Physics C API. Defines blittable structs (JPH_Vec3, JPH_RVec3, JPH_Quat, JPH_Plane, JPH_PhysicsSystemSettings) and enums (JPH_MotionType, JPH_Activation).
managed/physics/PhysicsWorld.csSingleton managing the Jolt Physics lifecycle: init, fixed-timestep stepping, body creation/removal, position/rotation readback, and shutdown. Survives hot reloads (engine layer).
managed/core/FreeCameraState.csStatic state for the debug free camera.
managed/runtime/HotReload.csFile watcher + recompiler for dev mode. Watches game_logic/ for changes, recompiles GameLogic.dll, then resets the world and re-runs Game.Setup for full scene re-initialization.
game_logic/Game.csScene setup entry point. Game.Setup(world) spawns entities, configures components, and registers systems.
game_logic/systems/*.csSystem implementations (static void methods on partial class Systems). One file per system. The built-in chain runs in registration order: InputMovement, Timer, Physics, FreeCamera, CameraFollow, LightSync, HierarchyTransform, DebugOverlay, DebugColliderRender, RenderSync.
game_logic/GameConstants.csTunable config values (debug mode, camera sensitivity, movement speed).
MakefileTop-level build orchestration: shader compilation, CMake invocation, C# compilation, and the run target that sets environment variables and launches Mono.
SaFiEngine.slnSolution file at repo root. Used by IDEs (VS Code C# Dev Kit, OmniSharp) to discover the C# project. Not used by the Makefile build.
managed/SaFiEngine.csproj.NET project file for IDE IntelliSense (autocomplete, go-to-definition). Targets net10.0. Not used by the Makefile build — mcs compiles directly from VIEWER_CS.

Build Pipeline

Running make run executes four steps in sequence:

1. Shader Compilation

glslc native/shaders/shader.vert  → build/shaders/vert.spv
glslc native/shaders/shader.frag  → build/shaders/frag.spv
glslc native/shaders/ui.vert      → build/shaders/ui_vert.spv
glslc native/shaders/ui.frag      → build/shaders/ui_frag.spv

Each .vert and .frag file is compiled from GLSL to SPIR-V using Google's glslc compiler. The SPIR-V binaries land in build/shaders/ where the renderer loads them at init.

2. Native Library Build

cmake -S native -B build/native -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build/native

CMake configures and builds the C++ code into build/librenderer.dylib. A symlink to compile_commands.json is placed at the repo root for IDE support.

3. C# Compilation

mcs -out:build/Viewer.exe managed/runtime/Viewer.cs managed/core/World.cs \
    managed/core/Components.cs managed/rendering/NativeBridge.cs \
    managed/core/FreeCameraState.cs \
    managed/physics/PhysicsBridge.cs managed/physics/PhysicsWorld.cs \
    game_logic/Game.cs game_logic/GameConstants.cs \
    game_logic/systems/InputMovementSystem.cs game_logic/systems/TimerSystem.cs \
    game_logic/systems/PhysicsSystem.cs game_logic/systems/CameraSystems.cs \
    game_logic/systems/LightSyncSystem.cs game_logic/systems/HierarchyTransformSystem.cs \
    game_logic/systems/DebugSystems.cs game_logic/systems/RenderSyncSystem.cs

All managed source files listed in the Makefile's MANAGED_CS and GAMELOGIC_CS_FILES variables are compiled into a single Mono executable.

4. Run

DYLD_LIBRARY_PATH=build:/opt/homebrew/lib \
VK_ICD_FILENAMES=/opt/homebrew/etc/vulkan/icd.d/MoltenVK_icd.json \
    mono build/Viewer.exe

DYLD_LIBRARY_PATH tells Mono where to find librenderer.dylib (and GLFW/MoltenVK from Homebrew). VK_ICD_FILENAMES points Vulkan's loader to the MoltenVK ICD so that Vulkan runs on top of Metal.

Triple-Pipeline Architecture

The renderer runs three graphics pipelines within a single Vulkan render pass. All pipelines share the same swapchain framebuffers and command buffers, but they have different pipeline states.

3D Scene Pipeline

SettingValue
Depth testEnabled (VK_COMPARE_OP_LESS)
Face cullingBack-face culling
ShadingBlinn-Phong with up to 8 dynamic lights
Vertex formatVertex -- position, normal, color, UV
DescriptorsSet 0: view/proj UBO. Set 1: light UBO. Set 2: base color texture sampler.
Push constantsmat4 model -- per-entity model matrix

The 3D pipeline renders all opaque scene geometry first.

Debug Wireframe Pipeline

SettingValue
Depth testEnabled (VK_COMPARE_OP_LESS_OR_EQUAL)
Depth writeDisabled (wireframes don't occlude other objects)
Face cullingNone
Polygon modeVK_POLYGON_MODE_LINE (requires fillModeNonSolid device feature)
Vertex formatSame Vertex as 3D pipeline
DescriptorsSame layout as 3D pipeline (set 0: UBOs, set 1: material texture)
Push constantsSame mat4 model as 3D pipeline

The debug wireframe pipeline renders after 3D geometry and before the UI overlay, but only when the debug overlay is enabled (debugOverlayEnabled_ == true). Debug entities are stored in a separate debugEntities_ list and use meshes from the shared combined vertex/index buffers. Created alongside the 3D pipeline in createGraphicsPipeline() by modifying rasterizer and depth-stencil state.

UI Overlay Pipeline

SettingValue
Depth testDisabled
Face cullingNone
BlendingSRC_ALPHA / ONE_MINUS_SRC_ALPHA alpha blending
Vertex formatUIVertex -- position, UV, color
DescriptorsCOMBINED_IMAGE_SAMPLER bound to the font atlas (512x512 R8_UNORM)
Push constantsvec2 screenSize -- for pixel-to-NDC conversion

The UI pipeline renders after all 3D geometry but before vkCmdEndRenderPass. It uses host-visible, persistently-mapped vertex buffers (one per frame-in-flight, max 4096 vertices) so that C# can write UI vertices directly without staging copies. The font atlas is baked once at init from assets/fonts/RobotoMono-Regular.ttf using stb_truetype.

Where to Edit

Where to Edit -- Adding a New Native Function

Every new function exposed to C# requires changes in three files:

  1. native/renderer.h + the appropriate .cpp file in native/renderer/, native/scene/, or native/ui/ -- Add the method to the VulkanRenderer class and implement it.
  2. native/bridge.cpp -- Add an extern "C" wrapper that calls the new method on g_renderer, wrapped in a try/catch.
  3. managed/rendering/NativeBridge.cs -- Add a [DllImport("renderer")] static extern declaration matching the C function signature. :::

:::tip Where to Edit -- Adding a New Shader

  1. Create the .vert and/or .frag file in native/shaders/.
  2. In the Makefile, add a new SPV variable (e.g., MY_VERT_SPV = $(SHADER_DIR)/my_vert.spv) and a corresponding compilation rule (glslc $< -o $@).
  3. Add the new SPV target to the shaders: dependency list so it gets built automatically.
  4. In the appropriate .cpp file (e.g., renderer/vulkan_init.cpp for pipelines, renderer/renderer.cpp for loading), load the new SPIR-V binary and create the pipeline that uses it. :::

:::tip Where to Edit -- Adding a New C# File The project uses two folders for C# code:

  • Engine files (managed/) -- Add to the appropriate subdirectory (core/, rendering/, physics/, or runtime/) and add the path to MANAGED_CS in the Makefile. These are stable engine internals.
  • Game files (game_logic/) -- Add the path to GAMELOGIC_CS_FILES in the Makefile. Systems go in game_logic/systems/ using the partial class Systems pattern. These are user-editable game logic files and are hot-reloadable with make dev.

The .csproj uses EnableDefaultCompileItems so it discovers new files automatically — no project file changes needed. Only the Makefile needs updating.