Introduction
VkEngine is a C++ game engine built on Vulkan for high-performance 3D graphics. It provides a complete game development framework including rendering, physics simulation, audio playback, and scene management. LabEscape, a puzzle-escape game included with the engine, demonstrates all major features in a production example.
Repository: https://github.com/spikest3r/VulkanEngine
Key Features
- Vulkan-based Rendering: Modern graphics API for cross-platform high-performance rendering with ImGui integration
- Physics Simulation: NVIDIA PhysX integration for realistic physics, collision detection, and character controllers
- Spatial Audio: FMOD integration for 3D sound with distance attenuation and positional effects
- Scene System: Flexible scene management with virtual initialization, update, and cleanup hooks
- Game Objects: C++ entity system with transforms, physics, audio, and rendering
- Character Controller: First-person character movement with gravity, jumping, and terrain interaction
- Resource Management: Unified system for meshes, textures, and sounds with lazy loading
- Input Handling: Keyboard, mouse, and DualSense controller support (with haptics on Windows/Linux)
- Debugging Tools: ImGui-based debug UI, physics debug rendering, raycast visualization
Technology Stack
- Graphics: Vulkan with GLFW windowing
- Physics: NVIDIA PhysX 5.x
- Audio: FMOD Studio
- Math: GLM (OpenGL Mathematics)
- 3D Asset Loading: Assimp
- GUI: ImGui with ImGui implementation for Vulkan
- Fonts: FreeType (via ImGui)
Getting Started: Basic Usage
Create an engine instance and load scenes:
#include <engine.h>
int main() {
Engine* engine = Engine::Create();
engine->init(800, 600, "My Game");
// Create scenes (inherit from Scene class)
MyScene* scene = engine->createScene<MyScene>();
engine->loadScene(scene);
// Main loop
while (engine->running()) {
engine->updateScene();
engine->update();
engine->render();
}
engine->cleanup();
Engine::Destroy(engine);
return 0;
}
Customize scene behavior:
class MyScene : public Scene {
void EarlyInitScene(Engine* engine) override;
void InitScene(Engine* engine) override;
void UpdateScene(Engine* engine) override;
void DestroyScene(Engine* engine) override;
};
Create game objects:
// In InitScene or later
Mesh* mesh = engine->getMesh("myMesh");
Texture* texture = engine->getTexture("myTexture");
PhysicsMaterial* material = engine->createPhysicsMaterial(0.5f, 0.3f, 0.2f);
auto obj = engine->createGameObject<MyGameObject>(
transform,
mesh,
texture,
material,
isDynamic // true for dynamic physics, false for static
);
See LabEscape Example for a complete working game.
Architecture Overview
High-Level Design
VkEngine is organized into interconnected subsystems coordinated by the Engine class. All major systems (rendering, physics, audio) operate on a shared set of game objects within scenes.
┌─────────────────────────────────────────────────────────┐
│ Engine │
│ - Lifecycle management (init/update/render/cleanup) │
│ - Subsystem coordination │
│ - Scene loading/unloading │
└─────────────────────────────────────────────────────────┘
↓ ↓ ↓ ↓
┌────────────┐ ┌─────────────┐ ┌────────┐ ┌──────────┐
│ Vulkan │ │ PhysX │ │ FMOD │ │ GLFW │
│ Rendering │ │ Physics │ │ Audio │ │ Input │
└────────────┘ └─────────────┘ └────────┘ └──────────┘
↓ ↓ ↓ ↓
┌─────────────────────────────────────────────────────┐
│ Active Scene │
│ - Game Objects │
│ - Resources (Meshes, Textures, Sounds) │
│ - Custom game logic │
└─────────────────────────────────────────────────────┘
Core Classes
Engine
The central coordinator singleton. Key responsibilities:
- Initialization: Vulkan context setup, PhysX world creation, FMOD system initialization, window creation
- Scene Management: Loading/unloading scenes, tracking active scene
- Resource Management: Creating and tracking meshes, textures, sounds
- Object Creation: Factory for game objects and scenes with custom allocator
- Physics: Managing PhysX scene, raycasts, triggers
- Audio: Managing FMOD system and spatial audio
- Input: Polling GLFW, DualSense, gamepad state
- Rendering: Recording Vulkan command buffers, managing frame synchronization
- Cleanup: Deferred destruction of resources and objects via queues
Access Pattern:
Engine* engine = Engine::Create();
engine->init(width, height, "title");
// ... use engine
engine->cleanup();
Engine::Destroy(engine);
Scene
Container for objects and resources in a logical grouping (typically a level). Developers inherit from Scene to customize behavior:
Initialization:
EarlyInitScene(Engine*)- Called first, before resource loading; good for requesting resourcesInitScene(Engine*)- Called after resources are available; create game objects here
Runtime:
UpdateScene(Engine*)- Called every frame for scene-specific logicDestroyScene(Engine*)- Called during cleanup
Resource management:
- Scenes maintain collections of meshes, textures, and game objects
- Resources are automatically cleaned up on scene unload
- Override
CreateGameObject()to customize object instantiation
GameObject
The fundamental entity in VkEngine. All renderable/physical things are game objects:
Core Properties:
Transform- Position, rotation (quaternion), scaleMesh*- 3D geometryTexture*- Surface appearancename,tag- Identification- Physics integration - optional RigidActor and material
- Audio - can play sounds with spatial positioning
Lifecycle:
virtual void Start(Engine*); // Called after creation
virtual void Update(Engine*); // Called every frame
virtual void Destroy(Engine*); // Called on cleanup
Inheritance: Developers create custom GameObject subclasses for specific types (player, enemies, pickups, etc.)
Resources (Mesh, Texture, Sound)
All inherit from IResource:
- Mesh: 3D geometry loaded from files via Assimp, converted to Vulkan buffers and PhysX shapes
- Texture: Image data loaded via stb_image, stored as Vulkan images with samplers
- Sound: Audio clips managed by FMOD, supporting spatial positioning and effects
Resources are reference-counted per scene and cleaned up on scene unload.
CharacterController
Specialized GameObject for player characters:
- PhysX kinematic controller with gravity and collision
- Movement with directional input
- Jump mechanics with gravity acceleration
- Vertical velocity tracking
- Integration with scene raycasting for slope handling
Data Flow: Main Loop
Engine::update()
↓
Scene::UpdateScene()
↓
forEach(GameObject)
- PhysX simulation step
- GameObject::Update() callback
- Collision detection
- Audio position update
↓
Engine::render()
- Record Vulkan commands per GameObject
- Submit to GPU
- Present frame
Frame N+1
Memory Management
The engine uses a custom allocator pattern:
Engine::requestMemory(size)/Engine::freeMemory(ptr)for allocation- Objects store an
ObjectHeadercontaining a destroy function pointer - Proper alignment handling for user-defined types
- Deferred destruction via queues for safe cleanup during game loop
See Ownership and Lifetimes for detailed lifetime semantics.
Ownership and Lifetimes
Memory Management Model
VkEngine uses a hierarchical ownership model with deferred destruction:
- Engine Ownership: Engine singleton owns scenes, resources, and allocator
- Scene Ownership: Active scene owns its game objects, physics actors, and per-scene resources
- Object Ownership: Each GameObject owns its Vulkan/PhysX resources
- Deferred Deletion: Objects are queued for destruction and cleaned up on the next safe point
Game Object Lifetime
Creation
Game objects are created within scenes using a template factory:
// In Scene::InitScene(Engine* engine)
Mesh* mesh = engine->getMesh("player_mesh");
Texture* texture = engine->getTexture("player_texture");
PhysicsMaterial* material = engine->createPhysicsMaterial(0.5f, 0.3f, 0.2f);
auto player = engine->createGameObject<PlayerCharacter>(
transform, // Initial position, rotation, scale
mesh, // Render geometry
texture, // Surface appearance
material, // Physics properties
true // isDynamic - false for static colliders
);
// Engine calls player->Start() automatically
Lifecycle Phases
- Construction: Object memory is allocated with custom allocator, constructor runs
- Engine Integration: Engine stores object metadata (ID, physics actor, audio group)
- Start:
GameObject::Start(Engine*)called - user initialization - Active: Object participates in physics, rendering, and updates each frame
- Update:
GameObject::Update(Engine*)called every frame - Destruction: Deferred via
engine->requestDestroyGameObject(object)or scene unload
Destruction Pattern
Objects are never destroyed immediately. Instead:
void MyScene::UpdateScene(Engine* engine) {
if (shouldRemoveObject) {
engine->requestDestroyGameObject(object);
// object still valid here
}
}
// Later, engine checks queues and calls:
// - object->Destroy(engine)
// - PhysX actor cleanup
// - Audio channel cleanup
// - Memory deallocation
This prevents iterator invalidation and double-deletion bugs during game loop execution.
Resource Lifetime
Creation and Caching
Resources are created and cached globally by the engine:
// First call: loads from disk, caches result
Mesh* mesh = engine->createMesh("player_mesh", "assets/player.obj");
// Subsequent calls: returns cached instance
Mesh* sameMesh = engine->getMesh("player_mesh");
Per-Scene Resources
The active scene maintains collections of available resources:
sceneMeshes- Meshes used by objects in this scenesceneTextures- Textures used by objects in this scenesceneGameObjects- All instantiated objects
Cleanup on Scene Transition
When a new scene is loaded:
engine->loadScene(nextScene); // Implicit unload of current scene
This triggers:
Scene::DestroyScene()for active scene- Destruction of all game objects in active scene
- Cleanup of scene-specific resources (through resource destruction queue)
- Initialization of new scene:
EarlyInitScene()thenInitScene()
Vulkan Resource Management
Buffers and Images
Each GPU resource (vertex buffer, index buffer, image) is owned by its containing object:
- GameObject Rendering: Owns mesh buffers and texture images
- Descriptor Sets: Allocated from global descriptor pool, per-frame recycling
- Frame Buffering: Uses
MAX_FRAMES_IN_FLIGHT = 2with dual-buffered command buffers and synchronization primitives - Synchronization: Fences and semaphores prevent CPU-GPU synchronization hazards
Swapchain Management
- Created during
Engine::init() - Recreated on window resize via framebuffer callback
- Images owned by GLFW/Vulkan driver, not VkEngine
PhysX Resource Management
Physics Actors
Each GameObject with physics owns a PxRigidActor (PxRigidStatic or PxRigidDynamic):
// Created during engine->createGameObject<>() with mesh and material
// Automatically removed when object is destroyed
// Shape and material owned by PhysX internally
Character Controllers
Created separately from GameObjects:
ICharacterController* controller = engine->createCharacterController(
height, radius, position,
material,
interactWithActors // Whether to interact with dynamic objects
);
// Request destruction separately
engine->requestDestroyCharacterController(controller);
Triggers
Physics-only entities without rendering:
Trigger* trigger = engine->createBoxTrigger(position, size);
trigger->onTriggerEnter = [](GameObject* other) { /* ... */ };
trigger->onTriggerExit = [](GameObject* other) { /* ... */ };
// Request destruction when done
engine->requestDestroyTrigger(trigger);
Audio Resource Management
Sounds
Created by engine and cached globally:
Sound* sfx = engine->createSound("footstep", "assets/step.ogg", false, true);
// false = not looping
// true = 3D spatial audio
Channel Groups
Each GameObject has a private FMOD::ChannelGroup* for sound isolation:
- Owned by engine’s FMOD system
- Destroyed when GameObject is destroyed
- Used for per-object volume and pause control
Spatial Audio
Position and velocity updated automatically from GameObject transform each frame.
Memory Allocation Strategy
Custom Allocator
The engine uses a tracking allocator (if enabled):
void* Engine::requestMemory(size_t size);
void Engine::freeMemory(void* ptr);
With object header pattern for type-safe destruction:
struct ObjectHeader {
void (*destroy)(void*); // Function pointer to typed destructor
void* allocationBase; // Pointer to raw allocation
};
Developers using engine APIs don’t need to manage this directly - it’s handled internally.
Container Allocators
STL containers in scenes use EngineAllocator<T>:
- Simple malloc/free wrapper
- Allows optional memory tracking for debugging
- Applied to scene resource collections
Lifetime Rules Summary
| Resource | Created By | Owned By | Destroyed By | When |
|---|---|---|---|---|
| GameObject | engine->createGameObject<>() | Engine | requestDestroyGameObject() or scene unload | Next frame |
| Mesh | engine->createMesh() | Engine (global cache) | Manual via requestDestroy() or engine cleanup | On request or exit |
| Texture | engine->createTexture() | Engine (global cache) | Manual via requestDestroy() or engine cleanup | On request or exit |
| Sound | engine->createSound() | Engine (global cache) | Manual via requestDestroy() or engine cleanup | On request or exit |
| Scene | engine->createScene<>() | Engine | requestDestroyScene() or manual | On request or exit |
| Trigger | engine->createBoxTrigger() | Engine | requestDestroyTrigger() | On request or exit |
| CharacterController | engine->createCharacterController() | Engine | requestDestroyCharacterController() | On request or exit |
| PhysicsMaterial | engine->createPhysicsMaterial() | Engine | Manual via requestDestroy() or engine cleanup | On request or exit |
Game Objects and Entities
GameObject Class
The GameObject is the fundamental entity in VkEngine. All renderable things (players, enemies, props) are GameObjects. Developers create custom subclasses to add game-specific behavior.
Core Properties
class GameObject {
public:
Transform transform; // Position, rotation (quaternion), scale
std::string name; // For identification
std::string tag; // For categorization and filtering
std::function<void(GameObject*, float)> onCollision; // Collision callback
};
Transform Details:
position: World space location (Vector3)rotation: Quaternion representing orientationscale: Object size scaling (Vector3)
All transforms are updated via updateTransform() when modified.
Lifecycle
Creation
Mesh* mesh = engine->getMesh("cube");
Texture* tex = engine->getTexture("white");
PhysicsMaterial* mat = engine->createPhysicsMaterial(0.5f, 0.3f, 0.2f);
auto obj = engine->createGameObject<MyObject>(
transform, // Initial transform
mesh, // Render geometry
tex, // Surface appearance
mat, // Physics material
isDynamic // true for physics simulation, false for static
);
// Engine calls obj->Start() automatically
Lifecycle Hooks
virtual void Start(Engine* engine); // Called after creation
virtual void Update(Engine* engine); // Called every frame
virtual void Destroy(Engine* engine); // Called on cleanup
Example custom subclass:
class Enemy : public GameObject {
public:
void Start(Engine* engine) override {
// Initialization: load resources, set physics, attach sounds
walkSound = engine->getSound("walk_sfx");
}
void Update(Engine* engine) override {
// Per-frame logic: move, animate, detect player proximity
transform.position += velocity * engine->getDeltaTime();
}
void Destroy(Engine* engine) override {
// Cleanup (though most cleanup is automatic)
}
private:
Sound* walkSound;
Vector3 velocity;
};
Physics Integration
Static vs Dynamic
// Static object (part of environment)
engine->createGameObject<Rock>(
transform, mesh, texture, material,
false // Static - doesn't move, doesn't respond to physics
);
// Dynamic object (affected by gravity and collisions)
engine->createGameObject<Ball>(
transform, mesh, texture, material,
true // Dynamic - simulated by PhysX
);
Applying Forces
void Update(Engine* engine) override {
// Apply directional force
applyForce(Vector3 direction, float power);
// Apply specific force vector
applyForce(Vector3 force);
// Query current velocity
Vector3 vel = getVelocity();
}
Collision Callbacks
obj->onCollision = [this](GameObject* other, float impulse) {
// Called when this object collides with 'other'
// impulse = magnitude of collision force
if (other->tag == "enemy") {
health -= 10;
}
};
Changing Physics at Runtime
void Update(Engine* engine) override {
if (shouldFall) {
setPhysicsType(PhysicsType::Dynamic); // Now affected by gravity
}
}
Audio Integration
Playing Sounds
void Update(Engine* engine) override {
if (isMoving) {
// Play sound at object's location with spatial audio
Sound* footstep = engine->getSound("footstep");
playSound(footstep, 1.0f); // volume = 1.0
}
}
Sound Control
void Update(Engine* engine) override {
if (isPaused) {
setSoundPause(true); // Pause all sounds from this object
}
if (shouldStopAll) {
stopAllSounds(); // Stop all active sounds
}
}
Spatial Positioning: Sound position automatically follows object transform. 3D sounds have distance-based attenuation configured at creation time.
Rendering
Updating Appearance
void Update(Engine* engine) override {
if (takeDamage) {
Texture* damagedTex = engine->getTexture("rock_damaged");
updateTexture(damagedTex); // Change surface appearance
}
}
Getting ID
uint32_t id = getID(); // Unique ID within engine session
CharacterController
A specialized kinematic physics entity for player movement. Unlike GameObjects, CharacterControllers use PhysX’s kinematic character controller for reliable first-person mechanics.
Creation
ICharacterController* controller = engine->createCharacterController(
height, // 1.8f typical for human
radius, // 0.4f typical for human width
position, // Initial spawn point
material, // Physics material
interactWithActors // true = interact with dynamic objects, false = pass through
);
Movement
// Primary movement interface
void Move(Vector3 direction, float speed, float dt);
// direction = normalized direction vector (forward/back/left/right)
// speed = units per second
// dt = delta time from engine
// Example: WASD input
Vector3 dir = Vector3(0, 0, 0);
if (engine->getKey(KeyCode::W) == PRESS) dir.z += 1;
if (engine->getKey(KeyCode::S) == PRESS) dir.z -= 1;
if (engine->getKey(KeyCode::A) == PRESS) dir.x -= 1;
if (engine->getKey(KeyCode::D) == PRESS) dir.x += 1;
controller->Move(dir, 10.0f, engine->getDeltaTime());
Jumping
void Jump(float force);
// Typical usage
if (engine->getKey(KeyCode::Space) == PRESS && isGrounded) {
controller->Jump(15.0f); // force = upward impulse
}
State Queries
Vector3 pos = controller->getPosition();
controller->setPosition(newPos); // Teleport
float vertVel = controller->getVerticalVelocity();
if (vertVel < 0) isGrounded = false;
if (vertVel == 0) isGrounded = true;
Characteristics
- Built on PhysX kinematic controller (not dynamic rigid body)
- Automatic gravity application and vertical velocity accumulation
- Automatically handles slope walking and step climbing
- Smooth, reliable first-person camera control
- Can be configured to interact or ignore dynamic objects
Triggers
Non-rendered physics volumes that detect overlaps with GameObjects. Unlike colliders, triggers don’t affect physics simulation.
Creation
Trigger* exitZone = engine->createBoxTrigger(
position, // Center position
size // Box dimensions (width, height, depth)
);
Callbacks
exitZone->onTriggerEnter = [this](GameObject* other) {
if (other->tag == "player") {
levelComplete = true;
}
};
exitZone->onTriggerExit = [this](GameObject* other) {
if (other->tag == "player") {
levelComplete = false;
}
};
Cleanup
engine->requestDestroyTrigger(exitZone);
Use Cases
- Level exit zones
- Pickup areas
- Hazard detection
- Cutscene triggers
- Spawn zones
- Environmental effects (water, lava)
Resource Management
Resource System Overview
VkEngine has a unified resource system for all asset types. Resources are created, cached globally, and referenced by GameObjects and Scenes. Cleanup happens automatically on scene unload or via explicit request.
Resource Types
Mesh
3D geometry data with vertices and indices.
Properties:
- Vertices (position, color, texture coordinates)
- Index buffer for face definitions
- Vulkan GPU buffers (VkBuffer for vertices/indices)
- PhysX collision meshes (convex for dynamic objects, triangle mesh for static)
- Supports multiple meshes per file via Assimp
Creation:
// Load from file (OBJ, FBX, GLTF, etc.)
Mesh* mesh = engine->createMesh("player", "assets/player.obj");
// Create from vertex data
std::vector<Vertex> vertices = { /* ... */ };
std::vector<uint32_t> indices = { /* ... */ };
Mesh* custom = engine->createMesh("custom", vertices, indices);
Retrieval:
Mesh* mesh = engine->getMesh("player"); // Returns cached instance
Supported Formats: OBJ, FBX, GLTF, DAE, BLEND (via Assimp support list)
Vertex Structure:
struct Vertex {
glm::vec4 pos; // Position + padding
glm::vec3 color; // Vertex color
glm::vec2 texCoord; // Texture coordinates
};
Texture
2D image data for rendering surfaces.
Properties:
- Loaded from disk via stb_image
- Stored as Vulkan VkImage with VkImageView
- Linear or optimal tiling based on device
- Descriptor set for shader binding
- Sampler for filtering and wrapping
Creation:
Texture* tex = engine->createTexture("rock", "assets/rock.png");
Retrieval:
Texture* tex = engine->getTexture("rock");
Supported Formats: PNG, JPG, TGA, BMP (via stb_image)
Sound
Audio clip for playback via FMOD.
Properties:
- Loaded into FMOD system
- Can be looping or one-shot
- 2D or 3D (spatial)
- Mono or stereo
- Supports compression formats
Creation:
Sound* sfx = engine->createSound(
"footstep", // Name
"assets/step.ogg", // Path
false, // looping = false for one-shot
true // three_dim = true for spatial audio
);
Retrieval:
Sound* sfx = engine->getSound("footstep");
Supported Formats: OGG, WAV, MP3, FLAC, etc. (depends on FMOD installation)
Resource Interface
All resources inherit from IResource:
class IResource {
public:
virtual ~IResource();
virtual void destroy(void*) = 0;
virtual ResourceType getType() = 0;
std::string getName();
};
Each resource type implements:
destroy()- Vulkan/FMOD cleanupgetType()- Returns TEXTURE, MESH, or SOUND- Automatic management by engine
Resource Lifecycle
Creation and Caching
All resources are created at engine level and globally cached:
// First call: loads from disk
Mesh* mesh1 = engine->createMesh("rock", "assets/rock.obj");
// Subsequent calls: returns cache (no reload)
Mesh* mesh2 = engine->getMesh("rock");
assert(mesh1 == mesh2); // Same pointer
// Multiple references in same scene are okay
scene->obj1->mesh = mesh1;
scene->obj2->mesh = mesh1; // Shared mesh
Loading Phases
Resources are typically loaded during scene initialization:
class Level1Scene : public Scene {
void EarlyInitScene(Engine* engine) override {
// Called first - request assets
// Good for spawning background load tasks
}
void InitScene(Engine* engine) override {
// Called after assets available - use them
mesh = engine->createMesh("level", "assets/level1.obj");
texture = engine->createTexture("floor", "assets/floor.png");
}
};
Cleanup
Resources are cleaned up when:
- Scene unloads (scene-specific resources are cleaned)
- Explicit destruction requested
// Request deferred destruction
engine->requestDestroy(myTexture);
// Actually destroyed on next frame's cleanup phase
Scene Resource Organization
Each scene maintains collections of resources it uses:
class Scene {
private:
std::vector<SceneResource> sceneMeshes; // All meshes in scene
std::vector<SceneResource> sceneTextures; // All textures in scene
std::vector<SceneGameObject> sceneGameObjects; // All objects in scene
};
These are managed automatically by the engine when objects reference them.
Memory Strategy
Vulkan GPU Memory
- Buffers: Mesh vertex/index buffers allocated and bound at creation
- Images: Textures uploaded to GPU memory with optimal tiling
- Descriptor Sets: Allocated from per-frame descriptor pool, recycled each frame
- Synchronization: Staging buffers used for CPU→GPU transfer
Current implementation uses direct Vulkan allocation (VMA integration is noted as TODO in code).
FMOD Audio Memory
- Sounds kept resident in system memory
- Streaming option available at creation time for large files
- Memory managed by FMOD internally
Asset Organization Best Practices
assets/
├── models/
│ ├── player.obj
│ ├── enemy.obj
│ └── level1.obj
├── textures/
│ ├── player.png
│ ├── enemy.png
│ └── floor.png
├── sounds/
│ ├── step_concrete.ogg
│ ├── step_metal.ogg
│ └── ambient_wind.ogg
├── scenes/
│ ├── level1.scene
│ └── level2.scene
└── fonts/
└── ui_font.ttf
Scene Callbacks
Scenes can customize resource loading behavior:
class CustomScene : public Scene {
void ResourceLoaded(std::string name, const char* path, ResourceType type) override {
// Called when a resource finishes loading
if (type == MESH && name == "player") {
onPlayerModelLoaded();
}
}
GameObject* CreateGameObject(Engine* engine,
const char* objectType, const char* tag,
const char* name, Transform transform,
Mesh* mesh, Texture* texture,
bool dynamic) override {
// Called when creating objects from scene file
// Can instantiate custom GameObject subclasses based on objectType
if (strcmp(objectType, "enemy") == 0) {
return engine->createGameObject<Enemy>(transform, mesh, texture, ...);
}
return engine->createGameObject<GameObject>(transform, mesh, texture, ...);
}
};
Memory Management Rules
| Type | Cached Globally | Per-Scene Copy | Lifetime |
|---|---|---|---|
| Mesh | Yes | No | Until explicit destroy() or engine cleanup |
| Texture | Yes | No | Until explicit destroy() or engine cleanup |
| Sound | Yes | No | Until explicit destroy() or engine cleanup |
| GameObject | No | Yes | Scene lifetime |
| PhysicsMaterial | No | Yes | Reference held by objects |
Key Rule: Don’t destroy resources while scenes are using them. Request destruction, and let engine handle cleanup order.
Scenes
Scene System
Scenes are the primary organizational unit in VkEngine. They contain game objects and resources for a logical level or area of your game. Each scene is responsible for its own initialization, updates, and cleanup. Developers create custom scene classes to implement game-specific logic.
Core Scene Class
Base class for creating custom scenes:
class Scene {
public:
virtual ~Scene() {}
virtual void EarlyInitScene(Engine* engine); // Before resource loading
virtual void InitScene(Engine* engine); // After resources loaded
virtual void UpdateScene(Engine* engine); // Per-frame updates
virtual void DestroyScene(Engine* engine); // Cleanup
virtual GameObject* CreateGameObject(
Engine* engine, const char* objectType, const char* tag,
const char* name, Transform transform,
Mesh* mesh, Texture* texture, bool dynamic);
virtual void ResourceLoaded(std::string name, const char* path, ResourceType type);
};
Initialization Phases
EarlyInitScene
Called first, before resources are loaded. Use this phase to:
- Request resources to be loaded
- Configure scene parameters
- Initialize systems
class Level1Scene : public Scene {
void EarlyInitScene(Engine* engine) override {
// Request resources (they may not be ready yet)
// This is where you'd start background loading tasks
// Initialize RNG for procedural generation
rng.seed(time(nullptr));
}
};
InitScene
Called after all requested resources are available. Use this phase to:
- Create game objects using loaded resources
- Set up physics triggers
- Configure scene-specific audio
- Initialize game state
class Level1Scene : public Scene {
void InitScene(Engine* engine) override {
// All resources are now available
Mesh* playerMesh = engine->getMesh("player");
Texture* playerTex = engine->getTexture("player");
PhysicsMaterial* mat = engine->createPhysicsMaterial(0.5f, 0.3f, 0.2f);
// Create player
auto player = engine->createGameObject<Player>(
{0, 1, 0, {0,0,0,1}, {1,1,1}}, // Transform
playerMesh,
playerTex,
mat,
true // Dynamic
);
// Create level environment
Mesh* levelMesh = engine->getMesh("level");
auto level = engine->createGameObject<GameObject>(
{0, 0, 0, {0,0,0,1}, {1,1,1}},
levelMesh,
nullptr, // No texture
mat,
false // Static
);
// Set up exit trigger
exitTrigger = engine->createBoxTrigger({0, 1, 10}, {2, 2, 2});
exitTrigger->onTriggerEnter = [this](GameObject* other) {
if (other->tag == "player") levelComplete = true;
};
}
};
Runtime Behavior
UpdateScene
Called every frame for scene-specific logic:
class Level1Scene : public Scene {
void UpdateScene(Engine* engine) override {
// Check for level completion
if (levelComplete) {
loadNewScene = true;
sceneToLoad = nextLevel;
}
// Update UI
updateHUD(engine);
// Play ambient sounds
if (ambienceSound && !isPlaying) {
player->playSound(ambienceSound, 0.3f);
isPlaying = true;
}
}
};
DestroyScene
Called during cleanup. Usually most cleanup is automatic, but use this for:
- Stopping background tasks
- Saving game state
- Explicit cleanup for complex objects
class Level1Scene : public Scene {
void DestroyScene(Engine* engine) override {
// Stop background music
if (musicPlaying) {
engine->requestDestroy(musicSound);
}
// Save any persistent game state
savePlayerProgress();
}
};
Object Creation Customization
Override CreateGameObject() to customize object instantiation:
GameObject* CreateGameObject(
Engine* engine,
const char* objectType, // Custom type name from scene file
const char* tag,
const char* name,
Transform transform,
Mesh* mesh,
Texture* texture,
bool dynamic
) override {
// Create custom game object types based on objectType string
if (strcmp(objectType, "enemy") == 0) {
return engine->createGameObject<Enemy>(transform, mesh, texture, material, dynamic);
}
else if (strcmp(objectType, "pickup") == 0) {
return engine->createGameObject<Pickup>(transform, mesh, texture, material, dynamic);
}
else if (strcmp(objectType, "trigger_zone") == 0) {
return engine->createGameObject<TriggerZone>(transform, mesh, texture, material, dynamic);
}
// Default: standard GameObject
return engine->createGameObject<GameObject>(transform, mesh, texture, material, dynamic);
}
Scene Resources
Scenes manage collections of:
- Meshes: 3D geometry available in the scene
- Textures: Image assets available in the scene
- Game Objects: Instantiated entities in the scene
These are automatically organized for efficient rendering and physics.
Scene File Format
Scene files define the initial layout. Format is text-based:
MESH: meshName filePath
TEXTURE: textureName filePath
OBJECT: objectType tag name meshName textureName dynamic
TRANSFORM: posX posY posZ rotX rotY rotZ rotW scaleX scaleY scaleZ
Example scene file:
MESH: player models/player.obj
MESH: level models/level.obj
TEXTURE: player_tex textures/player.png
TEXTURE: level_tex textures/level.png
OBJECT: Player player Player player_mesh player_tex true
TRANSFORM: 0 1 0 0 0 0 1 1 1 1
OBJECT: LevelGeometry level Level level_mesh level_tex false
TRANSFORM: 0 0 0 0 0 0 1 1 1 1
Loading from file:
bool valid;
MyScene* scene = engine->createScene<MyScene>("assets/level1.scene", &valid);
if (!valid) {
std::cerr << "Failed to load scene file\n";
}
engine->loadScene(scene);
Creating empty scene:
MyScene* scene = engine->createScene<MyScene>();
engine->loadScene(scene);
Scene Transitions
In main game loop:
while (engine->running()) {
engine->updateScene();
engine->update();
engine->render();
// Check if scene wants to transition
if (engine->isLastFrame()) {
auto scene = static_cast<MyScene*>(engine->getActiveScene());
if (scene && scene->loadNewScene) {
scene->loadNewScene = false;
engine->loadScene(scene->sceneToLoad);
}
}
}
In scene class:
class Level1Scene : public Scene {
public:
bool loadNewScene = false;
Scene* sceneToLoad = nullptr;
void UpdateScene(Engine* engine) override {
if (playerReachedExit) {
loadNewScene = true;
sceneToLoad = nextLevel;
}
}
};
Example: Complete Game Scene
class GameLevel : public Scene {
private:
ICharacterController* player;
Sound* ambience;
Sound* footsteps;
bool levelComplete = false;
public:
void EarlyInitScene(Engine* engine) override {
// Create character controller
PhysicsMaterial* mat = engine->createPhysicsMaterial(0.5f, 0.3f, 0.2f);
player = engine->createCharacterController(1.8f, 0.4f, {0, 1, 0}, mat, true);
}
void InitScene(Engine* engine) override {
// Load assets
ambience = engine->createSound("ambient", "assets/wind.ogg", true, false);
footsteps = engine->createSound("step", "assets/footstep.ogg", false, true);
Mesh* levelMesh = engine->getMesh("level");
auto levelGeo = engine->createGameObject<GameObject>(
{{0,0,0}, {0,0,0,1}, {1,1,1}},
levelMesh, nullptr, mat, false
);
// Create exit trigger
Trigger* exit = engine->createBoxTrigger({0, 1, 10}, {2, 2, 2});
exit->onTriggerEnter = [this](GameObject* other) {
levelComplete = true;
};
}
void UpdateScene(Engine* engine) override {
// Handle player movement
Vector3 moveDir = {};
if (engine->getKey(KeyCode::W) == PRESS) moveDir.z += 1;
if (engine->getKey(KeyCode::S) == PRESS) moveDir.z -= 1;
if (engine->getKey(KeyCode::A) == PRESS) moveDir.x -= 1;
if (engine->getKey(KeyCode::D) == PRESS) moveDir.x += 1;
player->Move(moveDir, 10.0f, engine->getDeltaTime());
// Check level completion
if (levelComplete) {
loadNewScene = true;
sceneToLoad = nextLevel;
}
}
void DestroyScene(Engine* engine) override {
// Cleanup happens automatically
}
};
Tips and Best Practices
- Separate concerns: Keep scene initialization clean, move complex logic to GameObjects
- Use tags for filtering: Filter objects by tag for quick lookups and logic
- Scene files for layout: Use scene files for static level design, code for dynamic behavior
- Resource sharing: Load shared resources once, reuse across objects
- Deferred cleanup: Always use
requestDestroy(), never delete directly - Camera control: Engine camera follows the physics, override for custom behavior
Rendering
Vulkan-Based Rendering Pipeline
VkEngine uses Vulkan for high-performance 3D graphics with forward rendering. The engine handles all Vulkan setup and management—developers work with high-level GameObject and Scene APIs.
Rendering Architecture
Core Components
Initialization (in Engine::init()):
- Vulkan instance with required extensions
- Physical device selection
- Logical device with graphics and present queues
- Swapchain for window output
- Render pass defining attachment formats and layout
- Graphics pipeline with vertex and fragment shaders
- Descriptor pool for resource binding
- Framebuffers for each swapchain image
Frame Synchronization:
- Double buffering with
MAX_FRAMES_IN_FLIGHT = 2 - Per-frame uniform buffers for camera matrices
- Synchronization primitives (fences, semaphores)
- Command buffers recorded and submitted per frame
Rendering Loop
Engine::render() // Called once per frame
1. Wait for previous frame fence
2. Acquire next swapchain image
3. Update uniform buffer with current view/projection matrices
4. Begin command buffer recording
- Start render pass
- Bind graphics pipeline
- For each GameObject with a mesh:
- Bind mesh vertex/index buffers
- Update model matrix UBO
- Draw indexed vertices
- Render UI elements
- Render ImGui
- End render pass
5. Submit command buffer to graphics queue
6. Present swapchain image to screen
Camera System
The engine provides a simple camera system with configurable properties:
Vector3 cameraPosition; // World position (default: 0, 0, 5)
Vector3 cameraRotation; // Euler angles in degrees (default: 0, -90, 0)
Vector3 cameraOffset; // Offset from target position (default: 0, 0, 0)
NearFarPlanes planes; // Near/far clipping planes (default: 0.1, 100)
// Example: follow object with offset
void updateCamera(Engine* engine, GameObject* target) {
engine->cameraPosition = target->transform.position + Vector3(0, 2, -5);
engine->cameraRotation = {0, -90, 0}; // Look forward
}
Projection:
- Field of view: 45°
- Aspect ratio: window width / height
- Orthogonal near/far clipping planes
View Matrix: Calculated from cameraPosition and cameraRotation
Shader System
Shaders
Default shaders are compiled to SPIR-V bytecode:
vert.spv- Vertex shaderfrag.spv- Fragment shader
Located in engine shader directory.
Vertex Input
struct Vertex {
glm::vec4 pos; // Position + padding
glm::vec3 color; // Vertex color
glm::vec2 texCoord; // Texture coordinates
};
Uniform Buffers
Updated per-frame and per-object:
struct UniformBufferObject {
glm::mat4 model; // Object-to-world transformation
glm::mat4 view; // World-to-camera transformation
glm::mat4 proj; // Camera-to-normalized device coordinates
};
struct LightPushConstants {
glm::vec3 lightPos; // Directional light direction
float ambient; // Ambient light multiplier
glm::vec3 lightColor; // Light color (RGB)
uint32_t unlit; // 1 = unlit, 0 = lit with light
};
Materials and Textures
Texture Binding
Each texture has a descriptor set for shader binding:
// In fragment shader
layout(set=1, binding=0) uniform sampler2D texSampler;
Texture Sampler:
- Linear filtering for smooth sampling
- Clamp to edge wrapping
- Supports anisotropic filtering (hardware-dependent)
Updating Textures at Runtime
void Update(Engine* engine) {
if (takeDamage) {
Texture* damagedTex = engine->getTexture("rock_damaged");
updateTexture(damagedTex); // Change surface appearance
}
}
Drawing GameObjects
Per-Frame Pipeline
For each GameObject with a mesh:
-
Model Matrix:
GetModel()transforms object from local space to world space- Calculated from position, rotation (quaternion), and scale
- Automatically updated when
transformchanges
-
Binding: Mesh vertex/index buffers bound to command buffer
-
Draw Call: Indexed draw with vertex count from mesh
-
Descriptor Sets:
- Frame descriptor set (UBO for camera matrices)
- Texture descriptor set (sampled in fragment shader)
Optimization
- Single render pass per frame
- Minimal state changes (objects with same texture bound together is implicit)
- No explicit frustum culling (all objects rendered)
- Command buffers recorded fresh each frame
UI System
ImGui Integration
ImGui is integrated for debug UI and in-game overlays:
void SetUICallback(std::function<void(Engine*)> callback);
Usage:
engine->SetUICallback([](Engine* engine) {
ImGui::SetNextWindowPos(ImVec2(10, 10));
ImGui::Begin("Debug");
ImGui::Text("FPS: %.0f", ImGui::GetIO().Framerate);
ImGui::End();
});
UI Elements
Rendered 2D elements for HUD:
UIElement* createUIElement(Texture* texture, Vector2 pos, Vector2 size);
struct UIElement {
Vector2 position; // Screen position in pixels
Vector2 size; // Screen size in pixels
// (texture managed internally)
};
Example: Crosshair HUD element
void MyScene::InitScene(Engine* engine) {
Texture* crosshair = engine->getTexture("crosshair");
ui_crosshair = engine->createUIElement(crosshair, {400, 300}, {32, 32});
}
Debug Rendering
Physics Debug Visualization
Render PhysX shapes to debug physics:
engine->renderPhysXDebug(true); // Enable
engine->renderPhysXDebug(false); // Disable
Shows wireframe collider shapes and actor positions.
Raycast Visualization
Debug raycasts with red/green lines:
struct RayDebug {
Vector3 origin; // Start point
Vector3 hitOrEnd; // Hit point or end if no hit
bool hit; // Whether raycast hit something
};
RayDebug ray = {rayOrigin, hitPoint, true};
engine->pushRayDebug(ray);
// Rendered as line in next frame
Rendering Configuration
Clear Color
engine->setClearColor(Vector3(0.1f, 0.1f, 0.1f)); // Dark gray
Light Positioning
engine->setLightPosition(Vector3(1, 1, -1)); // Directional light direction
Ground Plane
Optional ground plane for level layout visualization:
engine->setGroundPlaneActive(true); // Show
engine->setGroundPlaneActive(false); // Hide
GPU Memory and VRAM Statistics
std::vector<VRAMStats> getVRAMStats();
// Returns GPU memory usage and allocation info
Graphics Pipeline Details
Vulkan Extensions
Windows: VK_KHR_win32_surface
Linux: VK_KHR_wayland_surface (or xcb)
Render Pass
- Format: Optimal for platform (typically BGRA8 on Windows, RGBA8 on Linux)
- Attachment: Single color attachment
- Depth: No depth attachment (2.5D or depth-disabled rendering)
- Load Op: Clear to specified color
Pipeline State
- Topology: Triangle list
- Winding: Counter-clockwise
- Culling: Back-face culling enabled
- Depth Test: Disabled (no depth buffer)
- Blending: Disabled (opaque rendering)
Swapchain
- Mode: FIFO (vsync) - waits for vertical blank
- Images: Double buffered (2 images)
- Format: Device-optimal format (UNORM color space)
Performance Considerations
Current Bottlenecks
- No frustum culling: all objects rendered regardless of camera view
- No LOD system: no level-of-detail mesh switching
- Single pass rendering: no deferred rendering
- No batch rendering: each object is separate draw call
Optimization Opportunities
- Frustum Culling: Skip GameObjects outside camera view
- Instancing: Render multiple instances with single draw call
- Deferred Rendering: Render to G-buffer for complex lighting
- Texture Atlasing: Combine textures to reduce state changes
- Mesh Optimization: Reduce vertex count and optimize indices
Known Limitations
- No compute shaders
- No tessellation shaders
- Single directional light
- No normal mapping or parallax mapping
- No post-processing effects
- Fixed vertex layout (position, color, texcoord)
Input
Input System Overview
VkEngine polls input devices every frame and provides immediate query APIs. Supported devices include keyboard, mouse, gamepad, and DualSense controllers (PlayStation/Windows). Input is platform-independent; use abstract key codes and button enums.
Keyboard Input
Querying Key State
KeyState getKey(KeyCode code);
// Returns: PRESS or RELEASE (current frame state)
Usage:
if (engine->getKey(KeyCode::W) == PRESS) {
playerMoveForward();
}
if (engine->getKey(KeyCode::Space) == PRESS && isGrounded) {
playerJump();
}
Key Codes
VkEngine provides GLFW key code mappings:
Letters: KeyCode::A through KeyCode::Z
Numbers: KeyCode::Key0 through KeyCode::Key9
Function Keys: KeyCode::F1 through KeyCode::F25
Special Keys:
KeyCode::Escape
KeyCode::Enter
KeyCode::Tab
KeyCode::Backspace
KeyCode::Delete
KeyCode::Insert
KeyCode::Home
KeyCode::End
KeyCode::PageUp
KeyCode::PageDown
KeyCode::Up
KeyCode::Down
KeyCode::Left
KeyCode::Right
Modifiers:
KeyCode::LeftShift
KeyCode::RightShift
KeyCode::LeftControl
KeyCode::RightControl
KeyCode::LeftAlt
KeyCode::RightAlt
KeyCode::LeftSuper
KeyCode::RightSuper
Keypad:
KeyCode::KP0 through KeyCode::KP9
KeyCode::KPDecimal
KeyCode::KPDivide
KeyCode::KPMultiply
KeyCode::KPSubtract
KeyCode::KPAdd
KeyCode::KPEnter
KeyCode::KPEqual
Mouse Input
Mouse Position
Vector2 getMousePos();
// Returns: cursor position in screen space (pixels from top-left)
Mouse Buttons
KeyState getMouseButton(MouseButton button);
// Returns: PRESS or RELEASE
MouseButton Enum:
MouseButton::Left // Primary button
MouseButton::Right // Secondary button
MouseButton::Middle // Scroll button
MouseButton::Button4 through Button8 // Extra buttons
Mouse Scroll
float getScrollDelta();
// Returns: scroll wheel movement this frame
// Positive = scroll up, negative = scroll down
Mouse Ray (3D Picking)
Convert screen coordinates to 3D ray for raycasting:
Vector3 rayOrigin;
Vector3 rayDirection;
engine->getMouseRay(rayOrigin, rayDirection);
// Now raycast
RaycastHit hit = engine->raycast(rayOrigin, rayDirection, 100.0f);
if (hit.object) {
onObjectClicked(hit.object);
}
The ray is calculated from inverse view-projection matrix.
Cursor Control
Cursor Modes
void setCursorMode(CursorMode mode);
enum CursorMode {
NORMAL, // Visible, unrestricted (default)
HIDDEN, // Hidden but functional
DISABLED, // Captured by window, invisible
CAPTURED // Platform-dependent capture mode
};
Example: First-person camera setup
engine->setCursorMode(CursorMode::DISABLED); // Capture cursor
// In update loop
Vector2 mousePos = engine->getMousePos();
// Calculate camera rotation from mouse movement
Gamepad Input
Gamepad State
GamepadState* getGamepad();
// Returns: current gamepad state (may be null if no gamepad connected)
GamepadState Structure
struct GamepadState {
unsigned char buttons[15]; // Button states: GLFW_PRESS or GLFW_RELEASE
float axes[6]; // Axis values: -1.0 to 1.0
};
Button Indices
GAMEPAD_BUTTON_A // Face button south
GAMEPAD_BUTTON_B // Face button east
GAMEPAD_BUTTON_X // Face button west
GAMEPAD_BUTTON_Y // Face button north
GAMEPAD_BUTTON_LEFT_BUMPER // LB / L1
GAMEPAD_BUTTON_RIGHT_BUMPER // RB / R1
GAMEPAD_BUTTON_BACK // Select / Back
GAMEPAD_BUTTON_START // Start
GAMEPAD_BUTTON_GUIDE // Xbox button / PS button
GAMEPAD_BUTTON_LEFT_THUMB // Left stick click
GAMEPAD_BUTTON_RIGHT_THUMB // Right stick click
GAMEPAD_BUTTON_DPAD_UP
GAMEPAD_BUTTON_DPAD_RIGHT
GAMEPAD_BUTTON_DPAD_DOWN
GAMEPAD_BUTTON_DPAD_LEFT
Axis Indices
GAMEPAD_AXIS_LEFT_X // Left stick horizontal
GAMEPAD_AXIS_LEFT_Y // Left stick vertical
GAMEPAD_AXIS_RIGHT_X // Right stick horizontal
GAMEPAD_AXIS_RIGHT_Y // Right stick vertical
GAMEPAD_AXIS_LEFT_TRIGGER // LT / L2 (0 to 1)
GAMEPAD_AXIS_RIGHT_TRIGGER // RT / R2 (0 to 1)
Gamepad Usage Example
GamepadState* pad = engine->getGamepad();
if (pad) {
// Movement
float moveX = pad->axes[GAMEPAD_AXIS_LEFT_X];
float moveY = pad->axes[GAMEPAD_AXIS_LEFT_Y];
Vector3 moveDir = {moveX, 0, moveY};
player->Move(moveDir, 10.0f, engine->getDeltaTime());
// Camera
float camX = pad->axes[GAMEPAD_AXIS_RIGHT_X];
float camY = pad->axes[GAMEPAD_AXIS_RIGHT_Y];
engine->cameraRotation.y += camX * 2.0f; // Yaw
engine->cameraRotation.x += camY * 2.0f; // Pitch
// Actions
if (pad->buttons[GAMEPAD_BUTTON_A] == GLFW_PRESS) {
playerJump();
}
if (pad->buttons[GAMEPAD_BUTTON_X] == GLFW_PRESS) {
playerInteract();
}
}
DualSense Controller (PlayStation)
Detection
bool isDualSenseAttached();
// Returns: true if DualSense is connected
Haptics
Play haptic feedback using sound data:
void dualsense_playHaptics(Sound* sound, float volume);
// volume: 0.0 to 1.0
Only works on Windows 11+ with DualSense connected.
Lightbar Control
Set DualSense controller lightbar color:
void dualsense_setLightbarColor(unsigned char R, unsigned char G, unsigned char B);
// RGB values: 0-255
Example: Color-coded status indicator
if (playerHealth > 50) {
engine->dualsense_setLightbarColor(0, 255, 0); // Green (healthy)
} else if (playerHealth > 25) {
engine->dualsense_setLightbarColor(255, 165, 0); // Orange (injured)
} else {
engine->dualsense_setLightbarColor(255, 0, 0); // Red (critical)
}
Input Processing Pattern
Update Loop Pattern
void UpdateScene(Engine* engine) {
// Poll input
Vector3 moveDir = {};
if (engine->getKey(KeyCode::W) == PRESS) moveDir.z += 1;
if (engine->getKey(KeyCode::S) == PRESS) moveDir.z -= 1;
if (engine->getKey(KeyCode::A) == PRESS) moveDir.x -= 1;
if (engine->getKey(KeyCode::D) == PRESS) moveDir.x += 1;
// Gamepad alternatives
GamepadState* pad = engine->getGamepad();
if (pad) {
moveDir.x = pad->axes[GAMEPAD_AXIS_LEFT_X];
moveDir.z = pad->axes[GAMEPAD_AXIS_LEFT_Y];
}
// Apply movement
player->Move(moveDir, 10.0f, engine->getDeltaTime());
// Jump
if ((engine->getKey(KeyCode::Space) == PRESS ||
(pad && pad->buttons[GAMEPAD_BUTTON_A] == GLFW_PRESS))
&& isGrounded) {
player->Jump(15.0f);
}
// Interact
if (engine->getKey(KeyCode::E) == PRESS) {
handleInteraction();
}
}
Input Filtering
// Prevent repeated actions from held keys
bool jumpPressed = false;
void Update(Engine* engine) {
bool jumpKeyDown = (engine->getKey(KeyCode::Space) == PRESS);
if (jumpKeyDown && !jumpPressed && isGrounded) {
player->Jump(15.0f);
jumpPressed = true;
}
if (!jumpKeyDown) {
jumpPressed = false;
}
}
Mouse Interaction Example
void MyScene::UpdateScene(Engine* engine) {
// Check for click
if (engine->getMouseButton(MouseButton::Left) == PRESS && !clickProcessed) {
// Get 3D ray from mouse
Vector3 rayOrigin, rayDirection;
engine->getMouseRay(rayOrigin, rayDirection);
// Raycast
RaycastHit hit = engine->raycast(rayOrigin, rayDirection, 100.0f);
if (hit.object && hit.object->tag == "interactive") {
hit.object->onInteract();
clickProcessed = true;
}
}
if (engine->getMouseButton(MouseButton::Left) == RELEASE) {
clickProcessed = false;
}
}
Input Implementation Details
Polling Frequency
- Input polled every frame via
glfwPollEvents() - State available immediately after polling
- No input buffering (only current frame state)
Coordinate System
- Screen Space: (0, 0) at top-left, X right, Y down
- World Space: Used for raycast conversion
Frame Timing
- All input queries return state for current frame
- Held keys return PRESS every frame (not RELEASE/REPEAT)
- Use state flags to detect transitions (held vs just-pressed)
Tips and Best Practices
- Use abstractions: Create input manager class to map keys to actions
- Support both input methods: Allow keyboard and gamepad for same action
- Handle missing gamepads gracefully: Check
getGamepad()before use - Debounce input: Track state changes, not raw pressed states
- First-person movement: Use relative mouse mode (DISABLED) for smooth camera
- Menu navigation: Use keyboard for menus on desktop, gamepad on console
- Platform differences: Test on all target platforms (input APIs may vary)
Audio
FMOD Integration
VkEngine uses FMOD Studio for professional audio management with spatial 3D positioning, effects, and channel control. The audio system is fully integrated with game objects.
Audio System Initialization
FMOD is initialized during Engine::init():
- Max Channels: 512 simultaneous sounds
- Output: Device default (speakers, headphones)
- Formats: WAV, OGG, MP3, FLAC, etc. (platform dependent)
- 3D Features: Spatial positioning with distance-based attenuation
- Effects: Reverb and other FMOD effects (default: doppler disabled)
Sound Resources
Creating Sounds
Sound* createSound(
std::string name, // Unique identifier
const char* path, // File path
bool looping, // true = loop, false = one-shot
bool three_dim // true = 3D spatial, false = 2D ambient
);
Example:
// Create one-shot sound
Sound* footstep = engine->createSound("footstep", "assets/footstep.wav", false, true);
// Create looping ambient sound
Sound* windAmbient = engine->createSound("wind", "assets/wind.ogg", true, false);
// Create looping spatial sound (from specific location)
Sound* machinery = engine->createSound("machinery", "assets/machinery.ogg", true, true);
Retrieving Sounds
Sound* sound = engine->getSound("footstep");
// Returns cached instance (no reload)
Supported Formats
Via FMOD: WAV, OGG, MP3, FLAC, XMA, AT9, etc.
Common choices:
- WAV: High quality, larger file size (good for critical SFX)
- OGG: Compressed, smaller file size (good for ambient/music)
- MP3: Widely compatible (good for main menu music)
Playing Sounds from Game Objects
Playing a Sound
gameObject->playSound(Sound* sound, float volume);
Example:
void PlayerCharacter::Update(Engine* engine) {
Vector3 moveDir = getMovementInput();
if (moveDir.length() > 0) {
// Play footstep sound
Sound* step = engine->getSound("footstep");
playSound(step, 0.7f); // 70% volume
}
}
Sound Control
void stopAllSounds();
// Stops all sounds playing from this object
void setSoundPause(bool pause);
// Pause/resume all sounds from this object
Channel Group Management
Each GameObject has an internal FMOD::ChannelGroup*:
- All sounds from that object belong to its channel group
- Channel groups allow per-object volume and pause control
- Automatically destroyed when object is destroyed
Spatial Audio (3D Sound)
3D Listener
The engine automatically manages the 3D listener position:
- Position: Camera position (updated every frame)
- Forward/Up vectors: Calculated from camera rotation
- Velocity: Used for Doppler effect (currently disabled)
3D Sound Sources
GameObjects with 3D sounds:
- Position synchronized with GameObject transform every frame
- Distance attenuation applied automatically
- Panning based on relative position to listener
Example: Enemy audio from a specific location
class Enemy : public GameObject {
void Update(Engine* engine) override {
// Sound automatically follows enemy position
if (isAlive) {
engine->getGameObject("sfx_player")->playSound(
engine->getSound("enemy_growl"), 0.5f
);
}
}
};
Attenuation
3D sounds fade with distance based on FMOD settings:
- Sounds at close range: full volume
- Sounds at medium range: volume decreases
- Sounds at far range: silence
Global Audio Control
Master Mute
void setGlobalMute(bool mute);
// Mute/unmute all audio
Example: Pause menu audio muting
void PauseMenu::UpdateScene(Engine* engine) {
if (isPaused) {
engine->setGlobalMute(true); // Silent during pause
} else {
engine->setGlobalMute(false); // Resume audio
}
}
Audio Implementation Pattern
Scene-Based Audio
Audio is typically managed at the scene level:
class GameLevel : public Scene {
private:
Sound* ambience;
Sound* musicTrack;
GameObject* audioPlayer; // For scene-level sounds
void InitScene(Engine* engine) override {
// Load audio resources
ambience = engine->createSound("ambient_wind", "assets/wind.ogg", true, false);
musicTrack = engine->createSound("level_music", "assets/level1_music.ogg", true, false);
// Create "speaker" object for scene-level sounds
audioPlayer = engine->createGameObject<GameObject>(
{{0,0,0}, {0,0,0,1}, {1,1,1}},
nullptr, nullptr, nullptr, false
);
}
void UpdateScene(Engine* engine) override {
// Play ambient sounds
if (!ambiencePlaying) {
audioPlayer->playSound(ambience, 0.3f);
ambiencePlaying = true;
}
}
void DestroyScene(Engine* engine) override {
audioPlayer->stopAllSounds();
}
};
DualSense Haptics (Windows/PlayStation)
Provide haptic feedback feedback on DualSense controllers:
engine->dualsense_playHaptics(Sound* sound, float volume);
Maps audio frequencies to haptic patterns.
Example: Impact feedback
void Enemy::takeDamage(GameObject* attacker) {
health -= 10;
// Haptic feedback
if (engine->isDualSenseAttached()) {
Sound* impact = engine->getSound("impact_sfx");
engine->dualsense_playHaptics(impact, 1.0f);
}
}
Audio Implementation Details
FMOD System Update
- Called every frame in
Engine::update() - Updates channel states
- Processes 3D listener and source positions
- Applies effects
Channel Groups
Per-object channel groups allow:
- Individual object volume control
- Per-object pause/resume
- Grouping of related sounds
- Easier audio debugging
Memory Management
- Sounds kept resident in system memory
- FMOD manages memory internally
- Streaming supported for large files
- Cleanup on
Engine::cleanup()
Distance Attenuation
FMOD distance model (typical):
- Close range (< 1 unit): Full volume
- Far range (> 100 units): Silence
- Linear falloff in between
- Configurable per sound if needed
Tips and Best Practices
- Use looping sounds sparingly: Looping sounds consume channels longer
- One-shot effects are efficient: Footsteps, impacts, UI clicks
- Music should be separate: Use a dedicated music channel if possible
- Spatial audio for environment: Use 3D for enemies, machinery, events
- Volume balancing: Keep ambient sounds quiet, prioritize player feedback
- Test with headphones: 3D audio is most noticeable with spatial audio
- Platform testing: Audio behavior may differ on Windows vs Linux
- Cleanup properly: Request destruction before unloading scenes
- DualSense haptics optional: Always check
isDualSenseAttached()first - Profile performance: Monitor max simultaneous channels in profiling
Limitations and Future Work
- No real-time audio synthesis
- No custom effects (limited to FMOD built-in)
- No music streaming with playback synchronization
- No multi-listener 3D audio (single camera)
- No audio compression configuration per-file
- Doppler effect currently disabled
Physics
PhysX Integration
VkEngine uses NVIDIA PhysX 4.x for physics simulation, collision detection, and character control. The physics system is tightly integrated with GameObjects and provides straightforward APIs for physics interactions.
Physics Materials
Creating Materials
PhysicsMaterial* createPhysicsMaterial(
float staticFriction, // Friction when stationary
float dynamicFriction, // Friction when moving
float restitution // Bounciness (0-1, 1 = perfect bounce)
);
Example:
// Slippery ice
PhysicsMaterial* ice = engine->createPhysicsMaterial(0.1f, 0.05f, 0.3f);
// Rough concrete
PhysicsMaterial* concrete = engine->createPhysicsMaterial(0.8f, 0.6f, 0.1f);
// Bouncy rubber
PhysicsMaterial* rubber = engine->createPhysicsMaterial(0.5f, 0.4f, 0.8f);
// Default material
PhysicsMaterial* standard = engine->createPhysicsMaterial(0.5f, 0.4f, 0.2f);
Material Combinations
When two objects collide, their materials are combined:
- Friction = average of both materials
- Restitution = average of both materials
Rigid Bodies in Game Objects
Creating Objects with Physics
Physics bodies are automatically created when creating a GameObject with a mesh:
createGameObject<T>(
transform,
mesh,
texture,
material,
isDynamic // Physics type determined by this flag
)
Static Objects (isDynamic = false)
Mesh* levelMesh = engine->getMesh("level");
PhysicsMaterial* mat = engine->createPhysicsMaterial(0.8f, 0.6f, 0.1f);
auto level = engine->createGameObject<GameObject>(
{{0,0,0}, {0,0,0,1}, {1,1,1}},
levelMesh,
texture,
mat,
false // Static - immovable collision geometry
);
Properties:
- Immovable and affected by no forces
- Uses triangle mesh collision (accurate for complex shapes)
- Good for terrain, buildings, static obstacles
- High performance (no simulation needed)
Dynamic Objects (isDynamic = true)
Mesh* ballMesh = engine->getMesh("ball");
PhysicsMaterial* bouncyMat = engine->createPhysicsMaterial(0.3f, 0.2f, 0.7f);
auto ball = engine->createGameObject<Ball>(
{{0, 2, 0}, {0,0,0,1}, {1,1,1}},
ballMesh,
texture,
bouncyMat,
true // Dynamic - simulated by physics engine
);
Properties:
- Affected by gravity and forces
- Continuous collision detection enabled (prevents tunneling)
- Fixed mass: 10.0 kg
- Angular damping: 1.0
- Linear damping: 0.5
- Uses convex mesh collision
Applying Forces
Impulse (Instantaneous Force)
void applyForce(const Vector3& force);
// Applies force immediately, like an impulse or explosion
Example:
void takeDamage(Vector3 explosionPoint) {
// Knockback from explosion
Vector3 knockback = (transform.position - explosionPoint).normalize() * 50.0f;
applyForce(knockback);
}
Directional Force
void applyForce(Vector3 direction, float power);
// Applies normalized directional force
Example:
void Update(Engine* engine) {
// Wind force pushing object
Vector3 windDirection = {1, 0, 0};
float windStrength = 5.0f;
applyForce(windDirection, windStrength);
}
Querying Physics State
Velocity
Vector3 getVelocity();
// Returns current linear velocity
Example:
void Update(Engine* engine) {
Vector3 vel = getVelocity();
float speed = sqrt(vel.x*vel.x + vel.y*vel.y + vel.z*vel.z);
if (speed > maxSpeed) {
// Moving too fast, apply drag
applyForce(-getVelocity() * 0.5f);
}
}
Physics Types
PhysicsType Enum
enum class PhysicsType {
Static, // Immovable
Kinematic, // Scripted motion (e.g., platforms)
Dynamic // Physics-simulated
};
Changing Physics Type at Runtime
void setPhysicsType(PhysicsType type);
Example: Object falls when triggered
void Update(Engine* engine) {
if (shouldFall) {
setPhysicsType(PhysicsType::Dynamic);
}
}
Collision Detection
Collision Callbacks
std::function<void(GameObject*, float)> onCollision;
Set a callback to be notified of collisions:
void InitScene(Engine* engine) override {
auto obj = engine->createGameObject<MyObject>(...);
obj->onCollision = [this](GameObject* other, float impulse) {
if (other->tag == "projectile") {
takeDamage(impulse);
}
};
}
Parameters:
other: The object this one collided withimpulse: Magnitude of collision force
Collision Layers
Currently no built-in collision layer system. To exclude collisions:
- Use Triggers instead of physics for certain interactions
- Manage collision logic in callbacks
Triggers (Overlap Volumes)
Triggers are non-physical collision volumes that detect overlaps without affecting physics:
Creating Triggers
Trigger* createBoxTrigger(Vector3 position, Vector3 size);
Example:
Trigger* damageZone = engine->createBoxTrigger(
{0, 0, 10}, // Center position
{5, 2, 5} // Size (width, height, depth)
);
damageZone->onTriggerEnter = [this](GameObject* other) {
if (other->tag == "player") {
playerTakeDamage(10); // Continuous damage in zone
}
};
damageZone->onTriggerExit = [this](GameObject* other) {
if (other->tag == "player") {
stopDamage();
}
};
Callbacks
std::function<void(GameObject*)> onTriggerEnter;
std::function<void(GameObject*)> onTriggerExit;
Called when objects enter/exit the trigger volume.
Cleanup
engine->requestDestroyTrigger(trigger);
Physics Queries
Raycasting
RaycastHit raycast(Vector3 origin, Vector3 direction, float distance);
struct RaycastHit {
float distance; // Distance from origin to hit point
GameObject* object; // The object that was hit (null if no hit)
};
Example: Picking objects with mouse
void MyScene::UpdateScene(Engine* engine) {
if (engine->getMouseButton(MouseButton::Left) == PRESS) {
Vector3 rayOrigin, rayDirection;
engine->getMouseRay(rayOrigin, rayDirection);
RaycastHit hit = engine->raycast(rayOrigin, rayDirection, 1000.0f);
if (hit.object) {
selectObject(hit.object);
}
}
}
Ignores:
- Trigger volumes
- CharacterController shapes (only solid objects)
Sweep (AABB Overlap)
SweepHit sweep(Vector3 position, Vector3 size, GameObject* ignore = nullptr);
struct SweepHit {
std::vector<GameObject*> objects; // All objects in volume
};
Example: Find nearby enemies
Vector3 playerPos = player->transform.position;
SweepHit nearby = engine->sweep(playerPos, {10, 10, 10}, player);
for (GameObject* obj : nearby.objects) {
if (obj->tag == "enemy") {
engageEnemy(obj);
}
}
Character Controller
Creation
The CharacterController is specialized for first-person character movement:
ICharacterController* createCharacterController(
float height, // Capsule height (e.g., 1.8f)
float radius, // Capsule radius (e.g., 0.4f)
Vector3 position, // Starting position
PhysicsMaterial* material, // Physics material
bool interactWithActors // true = push rigid bodies, false = pass through
);
Movement
void Move(Vector3 direction, float speed, float dt);
Example: Player movement in UpdateScene
void PlayerScene::UpdateScene(Engine* engine) {
Vector3 moveDir = {};
if (engine->getKey(KeyCode::W) == PRESS) moveDir.z += 1;
if (engine->getKey(KeyCode::S) == PRESS) moveDir.z -= 1;
if (engine->getKey(KeyCode::A) == PRESS) moveDir.x -= 1;
if (engine->getKey(KeyCode::D) == PRESS) moveDir.x += 1;
controller->Move(moveDir, 10.0f, engine->getDeltaTime());
// Update camera to follow controller
Vector3 pos = controller->getPosition();
engine->cameraPosition = pos + Vector3(0, 1.5f, 0); // Eyes at 1.5m height
}
Jumping
void Jump(float force);
Example:
void UpdateScene(Engine* engine) {
if (engine->getKey(KeyCode::Space) == PRESS && isGrounded) {
controller->Jump(15.0f);
}
// Check if still grounded
float vertVel = controller->getVerticalVelocity();
isGrounded = (vertVel == 0.0f); // Grounded when vertical velocity is zero
}
Position
Vector3 getPosition();
void setPosition(Vector3 newPosition);
Example: Teleport or respawn
if (fellOffMap) {
controller->setPosition(spawnPoint);
}
Vertical Velocity
float getVerticalVelocity();
Useful for:
- Detecting if jumping or falling
- Animation state (falling, jumping, landing)
- Knockback recovery timing
Example: Animation state
float vertVel = controller->getVerticalVelocity();
if (vertVel > 0) {
setAnimationState("jumping");
} else if (vertVel < 0) {
setAnimationState("falling");
} else {
setAnimationState("idle");
}
CharacterController Details
- Gravity: -24.0 units/s²
- Shape: Capsule (collision geometry)
- Collision: Stops at obstacles
- Slope walking: Automatically stays on slopes
- Stepping: Climbs small step heights
- Mass: Fixed for consistent feel
Physics Coordinate System
- X-axis: Left/right (positive right)
- Y-axis: Front/back (positive back)
- Z-axis: Up/down (positive up, gravity is -Z)
This is a Z-up coordinate system.
Physics Engine Details
Simulation Accuracy
- Time stepping: Variable timestep per frame (uses engine delta time)
- Solver iterations: Configurable per simulation
- Continuous collision detection: Enabled for fast-moving objects
- Sleeping: Bodies sleep when inactive for performance
Mesh Cooking
When a mesh is created:
- Dynamic objects: Converted to convex mesh (efficient, less accurate)
- Static objects: Converted to triangle mesh (accurate, static)
- Caching: Cooked meshes cached to avoid recomputation
Performance Characteristics
- Dynamic bodies: O(n) where n = number of dynamic objects
- Static bodies: O(1) per-frame (shape is fixed)
- Triggers: O(n) overlap tests
- Raycasts: O(log n) with spatial acceleration
Physics Implementation in LabEscape
LabEscape demonstrates:
- Player controlled by CharacterController
- Static level geometry from mesh
- Triggers for exit zones and puzzles
- Physics-based objects (balls in cup game)
- Collision-based interactions
Tips and Best Practices
- Static for immovable: Use static bodies for terrain, buildings
- Convex shapes optimal: CharacterController and dynamic objects use convex meshes
- Avoid nested meshes: One mesh per object for best performance
- Trigger for detection: Use triggers instead of collision callbacks for non-physics events
- Raycast for picking: Prefer raycasts over overlap tests for precise interaction
- Material tuning: Test friction/restitution for intended feel
- Mass balance: All dynamic objects have mass 10kg; adjust forces for balance
- Gravity direction: Remember -Z is down; adjust camera accordingly
Known Limitations
- No ragdoll physics
- No rope or cable simulation
- No destruction/deformable meshes
- No joint constraints (hinges, springs, etc.)
- No fluid simulation
- Limited collision layer system
- No sleeping optimization configuration
Engine Loop
Main Game Loop
The engine loop is the heartbeat of the game, executing once per frame and coordinating all systems.
Basic Loop Structure
int main() {
Engine* engine = Engine::Create();
engine->init(800, 600, "My Game");
MyScene* scene = engine->createScene<MyScene>();
engine->loadScene(scene);
// Main game loop
while (engine->running()) {
engine->updateScene(); // Update scene logic
engine->update(); // Update physics, input, timers, audio
engine->render(); // Render frame with Vulkan
}
engine->cleanup();
Engine::Destroy(engine);
return 0;
}
Loop Phases
1. UpdateScene (Game Logic)
engine->updateScene();
Calls the active scene’s UpdateScene() method:
void Scene::UpdateScene(Engine* engine) {
// Per-frame game logic
// Update HUD, check level completion, manage enemies, etc.
}
Responsibilities:
- Update scene-specific logic
- Manage level progression
- Control ambient effects
- Handle scene transitions
2. Update (Engine Systems)
engine->update();
Coordinates all engine subsystems in order:
2.1 Input Polling:
glfwPollEvents()- Get keyboard, mouse, window events- Gamepad state updated
- Cursor position recorded
- Mouse scroll delta processed
2.2 Timing:
- Calculate elapsed time since last frame
- Update delta time
- Used by CharacterController, physics, and user code
2.3 Game Object Updates:
forEach (GameObject in active scene) {
gameObject->Update(engine); // User-defined per-object logic
}
Each GameObject’s Update() is called:
- Input handling
- Local state updates
- Sound playback
- Movement logic
2.4 Physics Step:
physicsScene->simulate(deltaTime); // PhysX simulation
physicsScene->fetchResults(); // Get collision results
Simulation:
- Apply gravity
- Update velocities
- Detect collisions
- Apply collision responses
- Update rigid body positions
2.5 Collision Processing:
- Process collision callbacks (onCollision)
- Process trigger callbacks (onTriggerEnter/onTriggerExit)
- Update CharacterController position
2.6 Transform Synchronization:
- Update Vulkan model matrices from GameObject transforms
- Update PhysX actor positions from GameObjects
- Sync CharacterController position to camera
2.7 Camera Update:
- Calculate view matrix from
cameraPositionandcameraRotation - Calculate projection matrix from aspect ratio and FOV
- Update view frustum for rendering
2.8 Audio System Update:
- Update FMOD system (
fmodSystem->update()) - Sync 3D listener to camera position
- Update all 3D sound source positions
- Process audio playback and effects
2.9 Timer Processing:
oneShotTimers.update(currentTime); // Process event timers
Execute callbacks for timers that have elapsed:
engine->addTimer(2.5f, []() {
std::cout << "2.5 seconds have passed\n";
});
2.10 Resource Cleanup:
checkResourceDestroy(); // Clean up destroyed resources
checkGameObjectDestroy(); // Clean up destroyed objects
checkTriggerDestroy(); // Clean up destroyed triggers
checkCharacterControllerDestroy(); // Clean up destroyed controllers
checkSceneDestroy(); // Clean up destroyed scenes
Uses deferred deletion queues for safe Vulkan cleanup.
3. Render (Frame Submission)
engine->render();
Records and submits Vulkan frame:
3.1 Frame Synchronization:
waitForFence(inFlightFences[currentFrame]);
resetFence(inFlightFences[currentFrame]);
Wait for GPU to finish previous frame before reusing buffers.
3.2 Acquire Swapchain Image:
vkAcquireNextImageKHR(swapChain, imageAvailableSemaphore, ...)
Get next image to render to.
3.3 Update Uniform Buffers:
updateUniformBuffer(currentFrame, ubo);
// Contains view and projection matrices
3.4 Command Buffer Recording:
beginCommandBuffer(commandBuffer);
vkCmdBeginRenderPass(renderPass);
vkCmdBindPipeline(graphicsPipeline);
forEach (GameObject with mesh in scene) {
bindVertexBuffer(mesh->vertices);
bindIndexBuffer(mesh->indices);
bindDescriptorSets(frameDescriptorSet, textureDescriptorSet);
vkCmdDrawIndexed(mesh->indexCount);
}
renderUI(); // UI elements
renderImGui(); // Debug UI
vkCmdEndRenderPass();
endCommandBuffer(commandBuffer);
3.5 Command Buffer Submission:
VkSubmitInfo submitInfo = {
.waitSemaphoreCount = 1,
.pWaitSemaphores = &imageAvailableSemaphore,
.commandBufferCount = 1,
.pCommandBuffers = &commandBuffer,
.signalSemaphoreCount = 1,
.pSignalSemaphores = &renderFinishedSemaphore
};
vkQueueSubmit(graphicsQueue, &submitInfo, inFlightFence);
Submit recorded commands to GPU.
3.6 Swapchain Presentation:
VkPresentInfoKHR presentInfo = {
.waitSemaphoreCount = 1,
.pWaitSemaphores = &renderFinishedSemaphore,
.swapchainCount = 1,
.pSwapchains = &swapChain,
.pImageIndices = &imageIndex
};
vkQueuePresentKHR(presentQueue, &presentInfo);
Display rendered frame on screen.
Frame Timing
Delta Time
float getDeltaTime();
// Returns: seconds elapsed since last frame
Used for frame-rate independent movement:
void Update(Engine* engine) {
float dt = engine->getDeltaTime();
Vector3 moveAmount = moveDirection * speed * dt;
transform.position += moveAmount;
}
Frame Rate
Engine targets v-sync (60 FPS typically):
- Waits for vertical blank before presenting
- Prevents screen tearing
- Provides consistent timing
Timing Accuracy
Delta time is accurate to milliseconds; suitable for:
- Physics simulation
- Animation playback
- Smooth movement
- Event scheduling with
addTimer()
Update Order Summary
while (engine->running()) {
│
├─ Scene::UpdateScene() [USER LOGIC]
│
├─ Engine::update()
│ ├─ Input polling (glfwPollEvents)
│ ├─ Delta time calculation
│ ├─ GameObject::Update() for each object [USER LOGIC]
│ ├─ Physics simulation (PhysX step)
│ ├─ Collision processing
│ ├─ Transform synchronization
│ ├─ Camera matrix update
│ ├─ Audio system update
│ ├─ Timer processing (callbacks)
│ └─ Deferred destruction (cleanup queues)
│
└─ Engine::render()
├─ Swapchain synchronization
├─ Uniform buffer update
├─ Vulkan command buffer recording
│ ├─ Render pass begin
│ ├─ Draw GameObjects
│ ├─ Draw UI elements
│ ├─ Render ImGui
│ └─ Render pass end
├─ Command buffer submission
└─ Swapchain presentation
Scene Transitions
Scenes are transitioned at specific points in the loop:
while (engine->running()) {
engine->updateScene();
engine->update();
engine->render();
// Check if scene wants to transition
if (engine->isLastFrame()) { // Transitioned from previous frame
auto scene = static_cast<MyScene*>(engine->getActiveScene());
if (scene && scene->loadNewScene) {
scene->loadNewScene = false;
engine->loadScene(scene->sceneToLoad); // Load new scene
// Old scene: DestroyScene() called, objects destroyed
// New scene: EarlyInitScene(), then InitScene() called
}
}
}
Timing:
- Scene load request happens in
updateScene()orupdate() - Actual transition happens on next loop iteration after
render() - Ensures all systems complete before switching scenes
Initialization Sequence
Called once when engine starts:
engine->init(width, height, "title");
Sequence:
- Window Creation - GLFW window initialization
- Vulkan Initialization:
- Instance creation
- Physical device selection
- Logical device and queues
- Swapchain and framebuffers
- Renderpass and graphics pipeline
- Command pools and buffers
- Descriptor pools and layouts
- Semaphores and fences
- PhysX Initialization:
- Physics foundation
- Physics scene
- Default material
- FMOD Audio Initialization:
- FMOD system creation
- Channel groups
- 3D listener setup
- ImGui Setup:
- ImGui context creation
- GLFW and Vulkan backends
- Input System:
- GLFW input callbacks
- Gamepad polling setup
- DualSense Setup (if available):
- Controller detection
- Haptics support
Cleanup Sequence
Called when engine shuts down:
engine->cleanup();
Engine::Destroy(engine);
Sequence:
- Active Scene Cleanup -
DestroyScene()called - GameObjects Destroyed - All objects in scene destroyed
- Resources Destroyed - Meshes, textures, sounds freed
- ImGui Cleanup - ImGui context destroyed
- FMOD Cleanup - Audio system shut down
- PhysX Cleanup - Physics scene and foundation cleaned up
- Vulkan Cleanup:
- Wait for device idle
- Destroy pipelines, shaders, descriptors
- Destroy buffers and images
- Destroy swapchain and framebuffers
- Destroy device and instance
- Window Cleanup - GLFW window destroyed
Best Practices
- Keep UpdateScene fast: Complex logic should be in GameObject::Update()
- Don’t create/destroy in Update: Use request methods, let engine cleanup
- Frame-rate independent: Always use
getDeltaTime()for movement - Input in UpdateScene: Process input for global logic, per-object in GameObject::Update()
- Physics continuous: Don’t manually move objects with large jumps; use forces instead
- Timer precision: Timers are accurate to about 1 frame; use for event scheduling, not animation
- Avoid blocking calls: Don’t use sleep() or wait(); will freeze the game
- Profile bottlenecks: Use engine profiling to find slow systems
Checking Loop Status
bool running = engine->running();
// false when window closed or engine->exit() called
bool lastFrame = engine->isLastFrame();
// true for one frame after render, useful for deferred operations
Exit Handling
Request engine shutdown gracefully:
engine->exit();
// Sets internal flag; loop exits on next iteration
The main loop then:
while (engine->running()) { // Now false
// Loop exits
}
engine->cleanup();
LabEscape Example Game
Overview
LabEscape is a complete puzzle-escape game built with VkEngine. It demonstrates all major engine features in a production game context including rendering, physics, audio, input, scene management, and game logic. The source code serves as the primary example for VkEngine usage patterns.
Github Repo: https://github.com/spikest3r/LabEscape_VkEngine
Game Structure
Main Loop
int main() {
Engine* engine = Engine::Create();
engine->init(800, 600, "Lab Escape");
// Initialize physics material for player
GlobalObjects::characterMaterial = engine->createPhysicsMaterial(0.0f, 0.0f, 0.0f);
// Load fonts for UI
ToolUI::AddFontFromFileTTF(UIFonts::defaultFont, fontPath, 14.0f);
ToolUI::AddFontFromFileTTF(UIFonts::largeFont, fontPath, 28.0f);
// Create all scenes
GlobalObjects::level1 = engine->createScene<Level1Scene>("assets/level1.scene", &valid);
GlobalObjects::level2 = engine->createScene<Level2Scene>("assets/level2.scene", &valid);
GlobalObjects::level3 = engine->createScene<Level3Scene>("assets/level3.scene", &valid);
GlobalObjects::levelFinal = engine->createScene<LevelFinalScene>("assets/level4.scene", &valid);
GlobalObjects::intro = engine->createScene<IntroScene>();
GlobalObjects::credits = engine->createScene<CreditsScene>();
// Start at intro
engine->setCursorMode(CursorMode::DISABLED);
engine->loadScene(GlobalObjects::intro);
// Main loop
while (engine->running()) {
engine->updateScene();
engine->update();
engine->render();
// Handle scene transitions
if (engine->isLastFrame()) {
auto scene = static_cast<BaseScene*>(engine->getActiveScene());
if (scene && scene->loadNewScene) {
scene->loadNewScene = false;
engine->loadScene(scene->sceneToLoad);
}
}
}
engine->cleanup();
Engine::Destroy(engine);
return 0;
}
Scene Hierarchy
BaseScene
Base class for all game scenes:
class BaseScene : public Scene {
public:
bool loadNewScene = false;
Scene* sceneToLoad = nullptr;
};
Provides scene transition mechanics.
PlayerScene
Base class for playable levels:
class PlayerScene : public BaseScene {
private:
ICharacterController* controller;
Sound* step1;
Sound* step2;
Trigger* exitTrigger;
// Movement settings
float baseMoveSpeed = 12.0f;
float mouseSensitivity = 0.1f;
// Camera bobbing
float bobbingAmplitude = 0.2f;
float bobbingFrequency = 13.0f;
protected:
void InitScene(Engine* engine) override;
void UpdateScene(Engine* engine) override;
void checkKeyboard(Engine* engine);
void checkGamepad(Engine* engine);
void processMovement(Engine* engine);
};
Features:
- Character controller for player movement
- WASD/gamepad movement input
- Mouse/right stick camera control
- Footstep sound effects
- Camera head bobbing animation
- Exit trigger for level completion
Level Scenes
Level1Scene
Introduction level with basic mechanics.
Level2Scene
Intermediate puzzles.
Level3Scene
Advanced mechanics with multiple puzzle systems:
- Simon Says memory puzzle
- Ball-in-cup game
- Password keypad system
LevelFinalScene
Final escape sequence.
IntroScene & CreditsScene
Menu scenes for game flow.
Game Objects Used in LabEscape
Player Character
Implemented as CharacterController:
void PlayerScene::InitScene(Engine* engine) {
controller = engine->createCharacterController(
1.8f, // Height
0.4f, // Radius
spawnPosition,
globalMaterial,
true // Interact with actors
);
}
Features:
- First-person perspective
- WASD movement or gamepad stick
- Mouse look or gamepad right stick
- Gravity-based jumping with jump animation
- Head bobbing while moving
- Footstep sounds on movement
Interactive Objects
Keypad - Numerical input puzzle:
Keypad* keypad = engine->createGameObject<Keypad>(
transform, mesh, texture, material, false
);
keypad->setCode("1234");
keypad->onCodeEntered = [this](bool correct) {
if (correct) doorOpen = true;
};
Simon Says Cubes - Memory puzzle:
SimonSaysCube* cube = engine->createGameObject<SimonSaysCube>(
transform, mesh, texture, material, false
);
cube->onClicked = [this](int index) {
// Process player input for Simon Says game
};
Notes - Readable objects:
NoteObject* note = engine->createGameObject<NoteObject>(
transform, nullptr, noteTexture, nullptr, false
);
note->text = "Important clue...";
note->onRead = [this]() { unlockedHint = true; };
Environmental Objects
Doors - Animated objects:
GameObject* door = engine->createGameObject<GameObject>(
transform, doorMesh, doorTexture, material, false
);
// Animated via transform updates in UpdateScene
Level Geometry - Static collision:
auto levelGeo = engine->createGameObject<GameObject>(
{{0,0,0}, identity, {1,1,1}},
levelMesh,
levelTexture,
groundMaterial,
false // Static
);
Real Usage Examples
Movement and Camera Control
From PlayerScene::UpdateScene():
void PlayerScene::UpdateScene(Engine* engine) {
// Get movement input
Vector3 moveDir = {};
if (engine->getKey(KeyCode::W) == PRESS) moveDir.z += 1;
if (engine->getKey(KeyCode::S) == PRESS) moveDir.z -= 1;
if (engine->getKey(KeyCode::A) == PRESS) moveDir.x -= 1;
if (engine->getKey(KeyCode::D) == PRESS) moveDir.x += 1;
// Apply movement
controller->Move(moveDir, baseMoveSpeed, engine->getDeltaTime());
// Update camera
Vector3 pos = controller->getPosition();
engine->cameraPosition = pos + Vector3(0, 1.6f, 0); // Eye height
// Handle mouse look
Vector2 mousePos = engine->getMousePos();
if (firstMouse) {
lastX = mousePos.x;
lastY = mousePos.y;
firstMouse = false;
}
float xOffset = mousePos.x - lastX;
float yOffset = lastY - mousePos.y; // Reversed Y
lastX = mousePos.x;
lastY = mousePos.y;
yaw += xOffset * mouseSensitivity;
pitch += yOffset * mouseSensitivity;
// Clamp pitch
if (pitch > 89.0f) pitch = 89.0f;
if (pitch < -89.0f) pitch = -89.0f;
engine->cameraRotation = {pitch, yaw, 0};
}
Raycast-Based Interaction
From level update logic:
void PlayerScene::checkRaycast(Engine* engine) {
// Create ray from camera
Vector3 forward, right;
engine->getCameraVectors(forward, right);
Vector3 rayOrigin = engine->cameraPosition;
Vector3 rayDirection = forward;
// Raycast in front of player
RaycastHit hit = engine->raycast(rayOrigin, rayDirection, 3.0f);
if (hit.object) {
if (hit.object->tag == "interactive") {
// Highlight interactable
if (engine->getKey(KeyCode::E) == PRESS) {
hit.object->onInteract();
}
}
}
}
Trigger-Based Puzzle Logic
From Level3Scene:
void Level3Scene::InitScene(Engine* engine) {
// Simon Says puzzle trigger
simonSaysTrigger = engine->createBoxTrigger(
simonSaysTablePos,
{4.0f, 5.0f, 10.0f}
);
simonSaysTrigger->onTriggerEnter = [this](GameObject* other) {
if (other->tag == "player" && !simonSaysRunning) {
startSimonSaysGame();
}
};
}
Sound Management
From PlayerScene:
void PlayerScene::InitScene(Engine* engine) {
// Load footstep sounds
step1 = engine->createSound("step1", "assets/footstep1.wav", false, true);
step2 = engine->createSound("step2", "assets/footstep2.wav", false, true);
// Ambient sound
ambientSfx = engine->createSound("ambient", "assets/wind.ogg", true, false);
ambientPlayer = engine->createGameObject<GameObject>(
{{0,0,0}, identity, {1,1,1}},
nullptr, nullptr, nullptr, false
);
}
void PlayerScene::processMovement(Engine* engine) {
if (isMoving && !stepPlaying) {
// Play alternating footsteps
Sound* step = (std::rand() % 2 == 0) ? step1 : step2;
ambientPlayer->playSound(step, 0.7f);
stepPlaying = true;
stepTimer = stepInterval;
}
}
DualSense Features (Windows)
From Level3Scene:
void Level3Scene::InitScene(Engine* engine) {
// Set idle lightbar color
if (engine->isDualSenseAttached()) {
engine->dualsense_setLightbarColor(190, 200, 255); // Cyan
}
}
void PlayerScene::UpdateScene(Engine* engine) {
// Change color based on puzzle state
if (puzzleSolved) {
engine->dualsense_setLightbarColor(0, 255, 0); // Green
} else if (wrongAttempt) {
engine->dualsense_setLightbarColor(255, 0, 0); // Red
// Haptic feedback on error
Sound* errorSound = engine->getSound("error_sfx");
engine->dualsense_playHaptics(errorSound, 1.0f);
}
}
UI Integration with ImGui
From SetUICallback:
engine->SetUICallback([](Engine* engine) {
ImGui::SetNextWindowPos(ImVec2(10, 10));
ImGui::Begin("Debug", nullptr, ImGuiWindowFlags_AlwaysAutoResize);
ImGui::Text("FPS: %.1f", ImGui::GetIO().Framerate);
ImGui::Text("Camera Pos: %.2f, %.2f, %.2f",
engine->cameraPosition.x,
engine->cameraPosition.y,
engine->cameraPosition.z);
ImGui::End();
});
Scene File Example (level1.scene)
MESH: level models/level1.obj
MESH: door models/door.obj
TEXTURE: level_floor textures/floor.png
TEXTURE: door_frame textures/door.png
OBJECT: LevelGeometry level level level_floor false
TRANSFORM: 0 0 0 0 0 0 1 1 1 1
OBJECT: Door door Door door_frame true
TRANSFORM: 0 2 10 0 0 0 1 1 1 1
Key Design Patterns Used
Global State Management
struct GlobalObjects {
static PhysicsMaterial* characterMaterial;
static Level1Scene* level1;
static Level2Scene* level2;
static Level3Scene* level3;
static LevelFinalScene* levelFinal;
static IntroScene* intro;
static CreditsScene* credits;
};
Allows levels to reference each other for transitions.
Scene Transition Pattern
void Level1Scene::UpdateScene(Engine* engine) {
if (playerReachedExit) {
loadNewScene = true;
sceneToLoad = GlobalObjects::level2;
}
}
Main loop handles actual transition on next frame.
Input Filtering
bool pauseKeyPressed = false;
void UpdateScene(Engine* engine) {
bool pauseKeyDown = (engine->getKey(KeyCode::Escape) == PRESS);
if (pauseKeyDown && !pauseKeyPressed) {
togglePause();
pauseKeyPressed = true;
}
if (!pauseKeyDown) {
pauseKeyPressed = false;
}
}
Prevents repeated actions from held keys.
Deferred Destruction
void Level1Scene::UpdateScene(Engine* engine) {
if (shouldRemoveObject) {
engine->requestDestroyGameObject(object);
// Object still valid, destroyed later
}
}
Safe cleanup without iterator invalidation.
Learning Resources
To understand VkEngine better, study LabEscape:
- main.cpp - Main loop and initialization
- basescene.h - Base scene implementation
- levelscenes.h - Level-specific scenes
- level.cpp* - Detailed game logic for each level
- Assets - See how resources are organized and referenced
Performance Characteristics
LabEscape runs smoothly on:
- Windows 10/11 with modern GPU (GTX 1060 or better)
- Linux with Vulkan-capable GPU
Typical performance:
- FPS: 60 with v-sync
- Memory: 200-300 MB
- VRAM: 100-150 MB
Extending LabEscape
To create your own game using LabEscape as a template:
- Copy project structure
- Replace scenes - Create your own Scene classes
- Add custom GameObjects - Inherit from GameObject for specific types
- Create assets - Models, textures, sounds
- Wire up input - Customize PlayerScene input handling
- Implement puzzles - Use triggers and raycasts
LabEscape demonstrates all necessary patterns; your game just needs to customize the specifics.