![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
The one page to read first, and the one to come back to. It starts at what the engine is and ends at the patterns it is built from; each subsystem has a deeper page beside this one, listed in the manual's index.
CMake 3.25+, Ninja, C++17, OpenGL 4.3 core. The build produces four executables
The engine holds no game of its own. A game is a project: a directory with a project.json, its own scenes, assets, and gameplay code built into its own bin/. Every executable finds one by the same rule - the project beside the executable, unless an argument names a different one - so a shipped game ships its exe next to its project.json and the player passes nothing.
Three roots keep the halves apart: engineRoot() for what ships with the engine (shaders, default font, icons; read-only to a game), projectRoot() for what the game owns, and userRoot() for how one person likes their tools - the editor's recent-projects list and window layout, which follow the user rather than either of the other two. examples/potion_runner, examples/physics_lab and examples/stress_arena are complete worked examples - the first and the last build their worlds in code, and the middle one is the only one that authors and saves a scene, which is also what makes it the multiplayer sample. See io.md.
vkmEngine is built around an open, type-erased ECS and a stage-based system pipeline. The Engine class owns the services every system is handed - the ones FrameContext carries by reference, below - and a per-stage list of systems. Each frame, stages run in declaration order; within a stage, systems run sequentially in registration order. There is no parallel layer scheduler - per-system data parallelism (via ThreadPool) is the scaling lever, not framework-level system parallelism.
Engine is not a singleton: nothing reaches it globally. It is stack-constructible, so tests and headless tools spin up their own instance. Singletons are limited to process-wide registries reached via a static get(). There are five, and the code is the list rather than this sentence - grep -rn "static .*& get()" src/ names them: ThreadPool, AsyncLoadQueue, BehaviorRegistry, AssetLibrary and NetSchema. (A recipe import goes through the AssetFactory function-pointer seam in io/asset/asset_factory.h, not a singleton; recoverable errors go through the reportError() sink, captured by an editor-owned EngineErrorLog.) Profiling goes through debug/profiler.h (a Tracy facade), not an in-engine statistics registry.
Each system is registered at exactly one stage (core/system.h):
In order: acting on the input sampled before any stage; events, async loading, scripting, animation and physics; local-to-world resolution; culling; building the RenderView and submitting it; the editor.
Default wiring lives in setupEngineApp (app/engine_app.h, a header-only inline bootstrap the three hosts that build an Engine include and call - vkm_editor, vkm_runtime and vkm_server; vkm_cook builds none). The editor adds EditorSystem from its own main() after that returns; the other two never do:
| Stage | Systems |
|---|---|
| Input | CameraControllerSystem (editor binary only - the editor's own view and its fly controls, published as ctx.hostView; an authoring tool, so the authoring host is what registers it) |
| Simulation | (EventBus flush), SplashSystem (the startup logo; frame-clock presentation, publishes ctx.splash), AsyncLoaderSystem, BehaviorSystem, AnimationSystem, SkeletalAnimationSystem, RagdollSystem (after the pose it reads, before the bodies it writes), PhysicsSystem, CharacterControllerSystem, SkySystem |
| Transform | BoneSocketSystem, HierarchySystem, UISystem (the game UI; runs in every host), AudioSystem and ParticleSystem (both after the world resolve they read poses from) |
| Visibility | VisibilitySystem |
| Render | RenderSystem |
| Editor | EditorSystem (editor binary only) |
Place a new system by responsibility and let stage order schedule it - see ../guides/engine.md.
System::fixedUpdate(FrameContext&) runs on an accumulator clocked at the project's tick rate (project.json's tickRate, 64 by default), clamped at a max accumulator (0.25 s) to prevent the spiral-of-death after a frame hitch. The cap is a duration rather than a tick count, so a project that raises its rate buys more ticks per hitch rather than a longer stall. It is the deterministic-simulation hook (physics, networking tick); take the step length from ctx.clock.getFixedStep(), never from the frame delta. A networked client fills the accumulator up to 5% fast or slow (Clock::setPacing, see networking.md); the step itself never changes. The loop calls fixedUpdate() on every system in stage order; the default body is empty, so a system that does not override it costs one virtual call per tick.
The per-frame bundle passed to every system (core/system.h). The field type says which kind of state it is: references are engine-owned services, valid for the whole session; pointers are per-frame products, null until the stage that produces them has run:
Time is read off the clock, not the context: ctx.clock.getDeltaTime() is real elapsed seconds (input, camera, UI, file watching), getSimDelta() is that delta scaled by play state (0 while paused, exactly one step while single-stepping), and getFixedStep() is the project's tick length, to use in fixedUpdate(). Simulation systems read the sim delta so pause, time-scale, and single-step apply uniformly; anything that must advance regardless of play state reads the real delta. A system reads the timeline its responsibility lives on, so AudioSystem runs every frame whether or not the simulation advanced - pausing a game must not cut its music (see Audio).
Gameplay gets the same split rather than a choice of system: a behavior's onUpdate is simulation time and does not run at all while paused, and its onRealtimeUpdate is real time and runs every frame regardless - so a pause menu can animate, and unpause, over a frozen world (see Scripting).
The context is rebuilt from scratch each frame and the fixed-step loop runs before any producer stage, so visibility and ui are always null inside fixedUpdate() - read those from update() only. poses is the exception, because a pose is simulation: SkeletalAnimationSystem fills it in its own fixedUpdate, so a system registered after it in the Simulation stage reads this tick's pose.
Cross-cutting compile-time limits live in core/engine_config.h (treat it as the source of truth for exact names/values): MAX_LIGHTS = 256; MAX_SHADOW_CASTERS_2D = 6 2D atlas tiles (4 of them the first shadow-casting directional light's CSM cascades, via NUM_CASCADES, when there is one) + MAX_SHADOW_CASTERS_CUBE = 2 cube slots; DEFAULT_TICK_RATE (64) with MIN_TICK_RATE / MAX_TICK_RATE bounding what a project may ask for, and the MAX_FRAME_ACCUMULATOR (0.25 s) cap. The GL backend writes these into every shader's prelude from the C++ constants themselves, so a shader uses MAX_LIGHTS without declaring it (see lighting.md). Per-system tunables (cull distance, camera sensitivity) live in a nested Settings struct on the owning system, not here.
Engine code, single include root src/engine/:
| Path | Contents |
|---|---|
| core/ | Engine, System, FrameContext, SystemStage, Clock, HostChrome, engine_config, reflect, fnv1a |
| core/math/ | math helpers (rotation, axes, bounds, frustum, random, easing) |
| core/memory/ | TypeId, SparseSet, SlotAllocator, StorageIndex, TypeRegistry |
| core/event/ | EventBus (typed pub/sub; engine-owned infrastructure) and the EventSender view behaviors get |
| ecs/ | Scene, EntityId, Environment, PhysicsSettings, EntityResolver and EntityNamer (in entity_mapping.h), HierarchyOperations (free functions) |
| ecs/component/core/ | Transform, WorldTransform, Hierarchy, Name |
| ecs/component/render/ | Mesh, Light, Camera, Decal, LOD, ParticleEmitter, ReflectionProbe, IrradianceVolume |
| ecs/component/animation/ | Animation, Animator, BoneSocket, AnimationTrack |
| ecs/component/audio/ | AudioSource, AudioListener |
| ecs/component/physics/ | Rigidbody, Collider, CharacterController, Joint, Ragdoll |
| ecs/component/ui/ | UICanvas, UIElement, UIImage, UIText, UIButton, UIScroll, UIShape |
| ecs/component/prefab/ | PrefabEntity, PrefabInstance, NetSpawn |
| system/animation/ | AnimationSystem, SkeletalAnimationSystem, PoseBuffer, composePose, BoneSocketSystem |
| system/async/ | AsyncLoaderSystem |
| system/audio/ | AudioSystem, AudioDevice (the only engine file that includes the audio backend) |
| system/hierarchy/ | HierarchySystem |
| system/particle/ | ParticleSystem (CPU simulation; GLParticlePass draws what it emits) |
| system/physics/ | PhysicsSystem, CharacterControllerSystem, and authoring/, character/, collision/, query/, solver/ (see physics.md for the folder map) |
| system/render/ | RenderSystem, RenderBackend, RenderView, RenderSettings, data/ |
| system/script/ | BehaviorSystem, Behavior, ReflectedBehavior, BehaviorRegistry, ScriptComponent, ScriptModule (see scripting.md) |
| system/sky/ | SkySystem (points the key light at wherever Environment says the sun is) |
| system/splash/ | SplashSystem (the startup image, and the frame it hands over on) |
| system/ui/ | UISystem, UIDrawData (Transform stage, after Hierarchy; see ui.md) |
| system/visibility/ | VisibilitySystem, Visibility, VisibilityContext and Culling (AABB helpers are core/math/bounds.h) |
| resource/ | ResourceManager, Resource, Handle, texture_format |
| resource/asset/ | MeshAsset, MaterialAsset, TextureAsset, FontAsset, SkeletonAsset, AnimationClipAsset, AudioClipAsset |
| resource/generate/ | generateCube and its siblings - meshes, textures, materials and the default scene, built in code so a project needs no art to run |
| io/ | json_vec, project_paths (shared I/O helpers) |
| io/asset/ | AssetLibrary, AssetFactory, AssetCook (the cooked binary format), AssetSerializer, and cooked_loader.h's free loadCooked* functions. The cooker that writes it is a tool, in src/tools/cook/ |
| io/scene/ | SceneSerializer, ComponentSerializer |
| net/ | NetSession and the wire it speaks - schema, snapshots, commands, prediction, interpolation (see networking.md). Engine-owned infrastructure like EventBus, not a System |
| platform/ | windows_api - the one door Windows headers come through, so NOGDI and the far/near/ERROR macros are dealt with once |
| platform/window/ | WindowManager, FrameLimiter |
| platform/input/ | InputHandle (raw keyboard/mouse state), InputMap, InputBinding, InputSource, InputCommand, InputActions. The map answers the same four queries for a frame or for a tick's command, and carries the pointer and the wheel - neither is an action a binding could name |
| platform/net/ | UdpSocket, winsock_init (the sockets net/ speaks over) |
| platform/threading/ | ThreadPool + parallelFor (shared-deque pool, see threading.md) |
| platform/library/ | DynamicLibrary (cross-platform .dll/.so loader for gameplay hot-reload) |
| debug/ | build_info, engine_error_log, fault_latch, profiler (Tracy facade), screenshot |
OpenGL backend, src/backend/opengl/ (every file gl_-prefixed; includes are module-qualified like the engine's, and flat only for the root files above and for vkmGL's own headers):
| Path | Contents |
|---|---|
| (top level) | GLBackend, GLView, GLTarget, GLPass, GLFrameContext, and what every pass shares: the fullscreen triangle and the mip-level arithmetic |
| convention/ | gl_bindings (UBO/sampler contract), gl_format_conversion |
| asset/ | the GPU copy of one engine asset, kept current by GLView: GLMesh (a range in the GLMeshPool every mesh shares), GLMaterial, GLTexture |
| frame/ | what one frame uploads from the RenderView: GLCamera, GLLights, the object buffer, the skin palette, the instance batcher and the GLDrawList it and the shadow pass submit through, GLShadowData, the stream upload |
| storage/ | GPU-only state that outlives a frame, filled by a pass or a bake and sampled by others: GLShadowAtlas, GLIBL, GLIrradianceVolume, the probe array and manager, GLBloom, the cluster grid, the fog volume |
| offline/ | GPU work that runs outside the pass list: the shared rig (GLSceneCapture, GLCubeConvolver) and everything it is lent to - the IBL, irradiance and probe bakers, and the editor's material preview |
| pass/ | the passes: shadow, depth-prepass, resolve (depth + colour scopes), gtao, cluster-cull, fog (compute), skybox, forward, particle, decal, reflection, dof, bloom, grid, composite, ui, splash |
Editor (src/editor/): at the root, the parts every panel sees - EditorSystem, EditorState, EditorContext, the settings that persist the first and the verbs (editor_actions.h) that act on it. Beneath it, one directory per responsibility: command/ (the undoable Command and everything that pushes one), session/ (what the editor is doing right now - the open project, scene I/O, the play snapshot, material previews), chrome/ (the frame around the viewport: menu bar, status bar, the dockspace's default layout), panels/, overlays/ (what is drawn over the viewport, the transform gizmo included), input/ (keybinds, the shortcut dispatcher and the editor's view) and ui/ (widgets, style, theme, dialogs, the asset picker). Tools (src/tools/): loader/ (the HDR environment and plain image decoders and the SDF font baker) and project_boot (the prologue every host runs) build into vkm_tools, which every host links; the heavy importers (import/model_loaders, texture_loaders, material_loaders) and the asset cooker (cook/, with the LOD generation and mesh processing it bakes with, and the texture bake that mips and block-compresses textures) build into vkm_cook, which the editor and vkm_cook_app link and the runtime does not, so the runtime links neither the model importers, meshoptimizer, the heavy image decode nor the encoder.
Application and gameplay layers sit outside the src/engine/ include root:
| Path | Contents |
|---|---|
| app/engine_app.h | setupEngineApp: the shared, header-only bootstrap that registers the default systems. It installs no backend - each host that draws sets its own - and seeds no scene: that is the project's answer, given by bootProjectWorld (src/tools/project_boot.h) after it returns, with the tick rate and the world's fingerprint. The three hosts that build an Engine include it directly (there is no EngineApp library) |
| app/editor/ | vkm_editor entry point; opens a project and loads its module for hot-reload. Opens anyway when the module or the entry scene is broken - repairing those is what it is for |
| app/runtime/ | vkm_runtime entry point; opens a project and plays it, or exits non-zero when it cannot (which conditions) |
| app/cook/ | vkm_cook entry point; headless asset cook (no window, no GL, no Engine) |
| app/server/ | vkm_server entry point; hosts a game and referees it - no window, no render backend, the same system stack a client runs |
| examples/ | complete worked projects (Potion Runner, Physics Lab, Stress Arena). Gameplay lives in a project, never in the engine |
Engine includes use module-qualified paths from src/engine/:
The backend's includes are module-qualified from src/backend/opengl/ too, flat only for its own root files; engine code never reaches into it, seeing only RenderBackend through engine headers. Tools use their own root (#include "import/texture_loaders.h"). Full rules: ../guides/code-style.md.
The engine builds a backend-agnostic RenderView snapshot each frame and hands it to a RenderBackend through one seam (init / render). The OpenGL backend runs a fixed pass list, in the order GLBackend::init registers them - that list is the record, ending in the splash. There is no render-graph abstraction and no shader variant cache.
Real features: five light types including LTC area lights, Forward+ clustered lighting, CSM + spot + point-cube shadows, IBL (HDR or procedural sky), GTAO with bent normals, froxel volumetric fog, a baked SH irradiance volume, reflection probes, screen-space reflections (a pass on this frame's lit colour), projected decals, CPU billboard particles, MSAA, DoF, bloom, and a screen-space in-game UI (SDF text, wrapping, clipping, scrolling, buttons). Not present: TAA, FXAA, motion blur, lens flare, auto-exposure, contact shadows.
Engine code never includes a gl_* header; MaterialAsset is the renderer contract. See rendering.md and ui.md.
ResourceManager owns all assets (MeshAsset, TextureAsset, MaterialAsset, FontAsset, SkeletonAsset, AnimationClipAsset, AudioClipAsset) behind typed generational Handle<T>s. Assets are identified by a unique non-empty name; scene files reference them by name and resolve via findByName. commit() bumps a per-resource version so the backend skips unchanged uploads. See resources.md.
| Pattern | Where | Purpose |
|---|---|---|
| Generational handles | StorageIndex | Prevent use-after-free for entities, resources, slots |
| Sparse-dense dual array | SparseSet<T> | O(1) add/remove/lookup, packed iteration |
| Type erasure + TypeId | ISparseSet, typeId<T>() | Open component registry without modifying Scene |
| Fold expressions | forEach<A, B, ...> | Compile-time multi-component query |
| if constexpr dispatch | Scene::forEach, AnimationTrack | Compile-time routing by type, no virtual call |
| Compile-time reflection | core/reflect.h Field + Traits | Field iteration driving (de)serialization and inspectors |
| Frame-local snapshot | RenderView | Capture scene state for the backend, no shared mutation |
| Version-based GPU sync | Resource::version + GLView | Skip redundant GPU uploads |
| Multi-draw indirect | sorted drawables + GLDrawList | One multi-draw over consecutive runs sharing program, material and vertex layout |
| Shared-deque thread pool | ThreadPool + free parallelFor | Data-parallel loops; main thread participates |
| Command pattern | Command, CommandStack | Editor undo/redo with drag-coalesce |
| Staging-and-swap | SceneSerializer::load | Transactional scene load; failed loads leave live scene intact |