Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 resources
  • InitScene(Engine*) - Called after resources are available; create game objects here

Runtime:

  • UpdateScene(Engine*) - Called every frame for scene-specific logic
  • DestroyScene(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), scale
  • Mesh* - 3D geometry
  • Texture* - Surface appearance
  • name, 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 ObjectHeader containing 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:

  1. Engine Ownership: Engine singleton owns scenes, resources, and allocator
  2. Scene Ownership: Active scene owns its game objects, physics actors, and per-scene resources
  3. Object Ownership: Each GameObject owns its Vulkan/PhysX resources
  4. 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

  1. Construction: Object memory is allocated with custom allocator, constructor runs
  2. Engine Integration: Engine stores object metadata (ID, physics actor, audio group)
  3. Start: GameObject::Start(Engine*) called - user initialization
  4. Active: Object participates in physics, rendering, and updates each frame
  5. Update: GameObject::Update(Engine*) called every frame
  6. 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 scene
  • sceneTextures - Textures used by objects in this scene
  • sceneGameObjects - All instantiated objects

Cleanup on Scene Transition

When a new scene is loaded:

engine->loadScene(nextScene);  // Implicit unload of current scene

This triggers:

  1. Scene::DestroyScene() for active scene
  2. Destruction of all game objects in active scene
  3. Cleanup of scene-specific resources (through resource destruction queue)
  4. Initialization of new scene: EarlyInitScene() then InitScene()

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 = 2 with 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

ResourceCreated ByOwned ByDestroyed ByWhen
GameObjectengine->createGameObject<>()EnginerequestDestroyGameObject() or scene unloadNext frame
Meshengine->createMesh()Engine (global cache)Manual via requestDestroy() or engine cleanupOn request or exit
Textureengine->createTexture()Engine (global cache)Manual via requestDestroy() or engine cleanupOn request or exit
Soundengine->createSound()Engine (global cache)Manual via requestDestroy() or engine cleanupOn request or exit
Sceneengine->createScene<>()EnginerequestDestroyScene() or manualOn request or exit
Triggerengine->createBoxTrigger()EnginerequestDestroyTrigger()On request or exit
CharacterControllerengine->createCharacterController()EnginerequestDestroyCharacterController()On request or exit
PhysicsMaterialengine->createPhysicsMaterial()EngineManual via requestDestroy() or engine cleanupOn 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 orientation
  • scale: 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 cleanup
  • getType() - 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:

  1. Scene unloads (scene-specific resources are cleaned)
  2. 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

TypeCached GloballyPer-Scene CopyLifetime
MeshYesNoUntil explicit destroy() or engine cleanup
TextureYesNoUntil explicit destroy() or engine cleanup
SoundYesNoUntil explicit destroy() or engine cleanup
GameObjectNoYesScene lifetime
PhysicsMaterialNoYesReference 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

  1. Separate concerns: Keep scene initialization clean, move complex logic to GameObjects
  2. Use tags for filtering: Filter objects by tag for quick lookups and logic
  3. Scene files for layout: Use scene files for static level design, code for dynamic behavior
  4. Resource sharing: Load shared resources once, reuse across objects
  5. Deferred cleanup: Always use requestDestroy(), never delete directly
  6. 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 shader
  • frag.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:

  1. Model Matrix: GetModel() transforms object from local space to world space

    • Calculated from position, rotation (quaternion), and scale
    • Automatically updated when transform changes
  2. Binding: Mesh vertex/index buffers bound to command buffer

  3. Draw Call: Indexed draw with vertex count from mesh

  4. 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

  1. Frustum Culling: Skip GameObjects outside camera view
  2. Instancing: Render multiple instances with single draw call
  3. Deferred Rendering: Render to G-buffer for complex lighting
  4. Texture Atlasing: Combine textures to reduce state changes
  5. 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

  1. Use abstractions: Create input manager class to map keys to actions
  2. Support both input methods: Allow keyboard and gamepad for same action
  3. Handle missing gamepads gracefully: Check getGamepad() before use
  4. Debounce input: Track state changes, not raw pressed states
  5. First-person movement: Use relative mouse mode (DISABLED) for smooth camera
  6. Menu navigation: Use keyboard for menus on desktop, gamepad on console
  7. 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

  1. Use looping sounds sparingly: Looping sounds consume channels longer
  2. One-shot effects are efficient: Footsteps, impacts, UI clicks
  3. Music should be separate: Use a dedicated music channel if possible
  4. Spatial audio for environment: Use 3D for enemies, machinery, events
  5. Volume balancing: Keep ambient sounds quiet, prioritize player feedback
  6. Test with headphones: 3D audio is most noticeable with spatial audio
  7. Platform testing: Audio behavior may differ on Windows vs Linux
  8. Cleanup properly: Request destruction before unloading scenes
  9. DualSense haptics optional: Always check isDualSenseAttached() first
  10. 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 with
  • impulse: 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

  1. Static for immovable: Use static bodies for terrain, buildings
  2. Convex shapes optimal: CharacterController and dynamic objects use convex meshes
  3. Avoid nested meshes: One mesh per object for best performance
  4. Trigger for detection: Use triggers instead of collision callbacks for non-physics events
  5. Raycast for picking: Prefer raycasts over overlap tests for precise interaction
  6. Material tuning: Test friction/restitution for intended feel
  7. Mass balance: All dynamic objects have mass 10kg; adjust forces for balance
  8. 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 cameraPosition and cameraRotation
  • 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() or update()
  • 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:

  1. Window Creation - GLFW window initialization
  2. 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
  3. PhysX Initialization:
    • Physics foundation
    • Physics scene
    • Default material
  4. FMOD Audio Initialization:
    • FMOD system creation
    • Channel groups
    • 3D listener setup
  5. ImGui Setup:
    • ImGui context creation
    • GLFW and Vulkan backends
  6. Input System:
    • GLFW input callbacks
    • Gamepad polling setup
  7. DualSense Setup (if available):
    • Controller detection
    • Haptics support

Cleanup Sequence

Called when engine shuts down:

engine->cleanup();
Engine::Destroy(engine);

Sequence:

  1. Active Scene Cleanup - DestroyScene() called
  2. GameObjects Destroyed - All objects in scene destroyed
  3. Resources Destroyed - Meshes, textures, sounds freed
  4. ImGui Cleanup - ImGui context destroyed
  5. FMOD Cleanup - Audio system shut down
  6. PhysX Cleanup - Physics scene and foundation cleaned up
  7. Vulkan Cleanup:
    • Wait for device idle
    • Destroy pipelines, shaders, descriptors
    • Destroy buffers and images
    • Destroy swapchain and framebuffers
    • Destroy device and instance
  8. Window Cleanup - GLFW window destroyed

Best Practices

  1. Keep UpdateScene fast: Complex logic should be in GameObject::Update()
  2. Don’t create/destroy in Update: Use request methods, let engine cleanup
  3. Frame-rate independent: Always use getDeltaTime() for movement
  4. Input in UpdateScene: Process input for global logic, per-object in GameObject::Update()
  5. Physics continuous: Don’t manually move objects with large jumps; use forces instead
  6. Timer precision: Timers are accurate to about 1 frame; use for event scheduling, not animation
  7. Avoid blocking calls: Don’t use sleep() or wait(); will freeze the game
  8. 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:

  1. main.cpp - Main loop and initialization
  2. basescene.h - Base scene implementation
  3. levelscenes.h - Level-specific scenes
  4. level.cpp* - Detailed game logic for each level
  5. 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:

  1. Copy project structure
  2. Replace scenes - Create your own Scene classes
  3. Add custom GameObjects - Inherit from GameObject for specific types
  4. Create assets - Models, textures, sounds
  5. Wire up input - Customize PlayerScene input handling
  6. Implement puzzles - Use triggers and raycasts

LabEscape demonstrates all necessary patterns; your game just needs to customize the specifics.