Praxis3D game engine

2014 - NOW


A work-in-progress 3D game engine. Its purpose is to serve as a testbed for new ideas and implementations. My aim is to improve my skills and learn everything I can by exposing myself to every part of a game engine. I have been working on it during my free time for quite a while, motivated only by my passion for programming and problem-solving.

Written in C++/20, using OpenGL graphics API.
Shaders are coded in GLSL and gameplay scripts in LUA.
Scene files are saved in a modified JSON format.



Features:


  • Atmospheric light scattering
  • Automatic light exposure
  • Cascaded shadow mapping
  • Communication via observer-listener
  • Data-driven and modular engine design
  • Deferred renderer
  • Entity Component System
  • FXAA screen-space anti-aliasing
  • GUI system using ImGui
  • HBAO and SSAO ambient occlusion
  • HDR and tone-mapping
  • In-game scene editor
  • LUA scripting
  • Parallax occlusion mapping
  • Physically based bloom
  • Physically based rendering
  • Physics system using Bullet3
  • Sound system using FMOD
  • Stochastic texture sampling
  • Task-based multi-threading
  • Text editor
  • Texture inspector



Detailed description:


Engine:

C++/20 - uses new language features (lambda expressions, variadic templates, move semantics, etc..) where applicable.

ECS - all game world objects are represented in an entity-component system. Game objects are defined not by a type hierarchy, but by the components that are associated with them (composition over inheritance). This system works perfectly with observer pattern (that is implemented in the engine for data changes) and task scheduler (systems can work on their components by multi-threading, independent of what object contains which components). The ECS is implemented using EnTT API.

Engine states - the engine is divided into multiple states with their instances of various systems and scenes. This decouples various phases of the engine from each other and permits quick switching between the states, without them interfering with each other. Uses include switching between main menu, play, and world edit states.

Error handling - the method of error handling is consistent throughout the entire engine. It is based on function returns, with every type of error having an error code and error source (more data can be included). Every system and object has an empty null instance, hence in the event of failure, a null object is substituted, allowing the engine to continue with missing functionality. Error severity ranges from info messages to fatal error, and have different outcomes (outputting a message to the console, asking the user if they want to continue without some functionality, stopping the engine, etc). Errors and their sources contain text that is loaded from a file (which can be switched for different languages) that explains the error to the user.

Key commands - gameplay components (or LUA scripts) can request a key command and bind it to any user input. The key command is updated at every start of the frame. This allows for a modular interaction between objects and user inputs, while used in scripts utilizes a data-driven design.

Object pool - a generic, templated object pool implementation designed for game objects to optimize data locality and maintain a contiguous memory for cache miss reduction.

Scenes and systems - all engine functionality is divided into different systems (i.e. graphics, physics, scripting, etc). Every system contains scenes (that create, destroy and manage game objects (components)) and tasks (that are sent to the task manager to perform updates on the components). Systems can send messages to each other via the change controller (observer pattern).

Scene graph - the objects within the scene are contained in a scene graph. Parent/child pairing allows for easy object transform manipulation in the 3D space.

Service locators - functionality that is commonly used within multiple systems are put into services (i.e. clock, error handler, task manager, loaders, etc). Service locators are used to be able to reach any of the services. The service locator is responsible for creating, initializing, destroying a service, and granting (global) access to the instantiated classes (services). Using this decoupling, the services can be swapped out without any systems knowing. If a service fails to initialize it can be replaced by a null service and the engine can continue to run with missing functionality.

Shader bindings - compiled shaders are checked and cross-referenced for any needed data. The required runtime variables are then automatically sent by shader uniforms. This adaptive method allows drastic shader changes without rewriting and recompiling the engine code.


Audio:

FMOD - sounds are handled by using the FMOD API. Both the core and studio APIs are implemented, thus supporting both audio banks and any other regular audio file.

Impact Sounds - objects collisions are detected via the physics system and impact sounds are played based on the material of the objects (i.e. wood, metal, plastic, etc.). Custom collision sounds for a specific object can also be added.

Sound Spatialisation - supports 3D sound position. Inverse distance attenuation is used for sound fade out. 3D listener velocity (of the camera) is used to apply a doppler effect.


Graphics:

Ambient occlusion - SSAO and HBAO algorithms are implemented for a fast screen-space ambient occlusion effect.

Atmospheric light scattering - a precomputed physically-based atmosphere rendering using Rayleigh and Mie multiple scattering. Real-time rendering of the sky and atmospheric fog based on camera position, sun, and atmospheric particle parameters. Uses precomputed 3D lookup tables for optimization.

Automatic exposure - firstly, the luminance histogram of the framebuffer is calculated using compute shaders. The scene average luminance is then calculated by recursively downsampling the framebuffer (using compute shaders). Exposure compensation is then performed by converting the color from RGB to Yxy color space and adjusting the luminosity component.

Cascaded shadow mapping - a shadow mapping technique using multiple depth buffers as cascades that span across the view frustum, giving better shadow quality closed to the camera. Uses Percentage Closer Filtering with Poisson disk sampling to turn aliased shadow edges into soft noise. Uses layered depth rendering with a geometry shader to draw objects to all depth maps with a single draw call. Utilizes automatic slope bias calculation to minimize shadow acne and cascade edge blending to hide transitions between shadow cascades.

Deferred rendering - uses a g-buffer to decouple the lighting calculations from geometry rendering. Shading is carried out on visible pixels only, thus eliminating unnecessary calculations and handling any depth complexity. This also allows easy integration of new effects as everything is handled like a screen-space post-processing effect, which is faster and usually independent of the scene complexity.

Displacement mapping - the parallax displacement effect can be turned on for any object individually (provided it contains height maps), which makes them appear to have 3D features without rendering any extra geometry. Dynamic LOD is set up to optimize the rendering time with variable calculation complexity based on distance from the camera.

FXAA - a fast, shader-based, screen-space anti-aliasing technique, that works by analyzing the contrast in a scene to identify edges, then applies a blur to these edges to reduce the appearance of jagged edges.

Gaussian blur - a customizable blur pass (used for effects like emissive textures and bloom). The Gaussian kernel is pre-computed to save from unnecessary calculations and the blur pass is divided into vertical and horizontal passes to minimize expensive texture lookups.

GUI - using immediate-mode Dear ImGui API. Multi-threading wrappers are used to capture GUI calls into lambda functors so they can be executed during a GUI rendering pass in the main rendering thread.

HDR and tone-mapping - everything is rendered into HDR buffers. After the exposure compensation, the HDR color is tone-mapped (using 1 of 8 available tone-mapping functions) to LDR color space. Gamma correction is performed before showing the image on screen.

Lens flare - a very fast, pseudo lens flare effect including ghosting, halos, flares, and chromatic aberration. Uses Gaussian for blurring.

Physically based bloom - bloom is implemented using compute shaders with shared memory utilization for improved performance. Blurring is performed by downsampling and then upsampling the HDR framebuffer using a 3x3 filter kernel.

PBR - implemented physically based rendering, using BRDFs comprised of Lambertian model for diffuse reflection and Cook-Torrance model for specular reflections (Beckman for microfacet distribution; Schlick's approximation for Fresnel effect; Schlick/GGX for geometry attenuation).

Renderer - the renderer is split into frontend and backend. The engine interacts with the frontend, and all the draw calls with the required data are captured into command buffers. The commands can be sorted for optimization and stored until the backend (working on the rendering thread) is ready to draw a new frame. This also decouples the engine from specific graphics APIs and new backend implementations can be written to utilize a different API.

Rendering passes - because of deferred rendering design, each graphics effect is implemented as a post-processing rendering pass, independent of each other. Rendering passes are assigned in a map file, hence this data-driven design allows complete graphical effects changes for every scene or engine state.

Solar position - topocentric solar coordinates are very accurately calculated by an implementation of the SPA algorithm. Sun position is used for atmospheric light scattering to further increase realism by showing an accurate solar position.

Stochastic texture sampling - accomplishes texture tilling without the visible pattern repetition by dividing the texture into different virtual patterns and blending between them using low frequency index variation pattern.

Texture compression - using texture compression for RGB and RGBA formats and down-sampling techniques. Normal map compression with Z value reconstruction.


Loaders:

Config loader - the initial engine configuration is loaded from a file before any other systems or services are initialized. Every single variable (property) in the engine is defined inside the config loader to a default value but is replaced by the value in the configuration file if it is present. Thus every parameter in the engine is data-driven.

Model loader - models are loaded using Assimp API. The loader detects separate meshes and can retrieve textures and other meta-data. It can also calculate normals, tangents, and bitangens if they are missing. Supports a very wide array of formats and can load textures in the background. Provides a mesh handle wrapper with a reference counter to track if the model is being used and eliminates duplicate loading.

Scene loader - game objects, maps, and system parameters are loaded from JSON (modified to add additional data types) files. All the data is read and put into properties and property sets. After loading, the property names are hashed and sorted for faster searching. Data types of properties are recognized (i.e. bool, float, string, vector, matrix, etc) and set. However, each data type has an explicit conversion to any other data type, thus eliminating any errors of systems trying to retrieve the wrong data type from a property. Every game object, scene, and system can be serialized and exported back into property sets and saved as JSON to be loaded later.

Texture loader - textures are loaded using FreeImage API. The loader converts image formats to RGBA used in the renderer can resize images (to match sizes) with repeating wrapping and can combine different textures into one (for example roughness, metalness, height, and ambient-occlusion maps into one, if they are single channel). Supports a very wide array of formats and can load textures in the background. Provides a texture handle wrapper with a reference counter to track if the texture is being used, eliminates duplicate loading, and provides a default "texture missing" image if the texture is not present, to let the engine continue to run with errors.


Multi-threading:

Change controller - communication is carried out via the observer pattern, which is implemented over every game object and component. However, data changes cannot be simply sent between objects, because they are being updated simultaneously. To counter this, all messages with data changes are captured by the change controller and stored in thread-local storage. At the end of the frame, during the data synchronization phase, all captured messages are propagated without stalling any threads.

Object communication - messages between objects (including scene-graph managing) are carried out by the observer pattern. An object can subscribe to any other object to be notified about any changes. Possible and interested changes between the observer and the subject are cross-checked, to reduce over-subscription. Data changes can be sent instantly or accumulated over a frame, cataloged by a bit-mask. The data itself can be stored inside the message or retrieved at the end of the frame (by instead storing a reference to the observed object).

Task scheduler - multi-threading system enables functional and data decomposition by splitting all work into small tasks and is implemented over Intel's TTB API. At the start of every frame, the task scheduler collects tasks from every system, sorts them based on their performance hints or specific needs, and distributed them over all available CPU cores. Thus, the multi-threading system is very scalable and can utilize all available physical processors. The work is further distributed by custom parallel "for" loops.


Physics:

BulletPhysics - physics system is implemented using the open-source real-time collision detection and multi-physics simulation bullet3 API. Conversion of math data structures between Bullet and OpenGL. Bounding shape generation from mesh vertices.


Scene editor:

Editor - full-fledged scene editor, that gives the ability to create, edit and delete scenes. Allows easy adjustment of any component, scene and engine settings, manipulation of object hierarchy. Supports entity duplication, shortcuts, inner windows resizing, play-pause-restart functionality for physics and scripting.

Hot reloading - allows reloading of shaders and LUA scripts inside the scene editor. If present, errors are shown and the ability to continue without the affected shader or script is given.

Object manipulation - contains a 3D manipulation gyzmos that allow adjusting objects position, rotation and scale in 3D space using a mouse.

Output console - shows color-coded logs, warnings and errors.

Prefabs - supports predefined entities (prefabs) that store all their components and settings. Any entity can be quickly exported as a prefab to be loaded later.

Text editor - ability to modify LUA scripts and GLSL shaders in-engine, with syntax highlighting. Notifies when a file was modified and not saved.

Texture inspector - view color values of any texture that is loaded to GPU VRAM. Supports panning and zooming to individual pixels.


Scripting:

LUA - scripting is implemented using Sol API for LUA to C++ binding. The scripts are executed in a LuaJIT VM, making them incredibly fast and optimized. The data bindings and callbacks are implemented with lambda functions, they can be requested from the LUA script itself, including shared data, engine variables, and functors (to be passed to other systems). This includes math data types and functions (executed in C++ for performance), inputs (key presses, window changes), interaction with other game objects (like spatial changes), engine variables, and GUI functions. Data and variable changes in LUA can be tracked and notify the C++ code for retrieval and processing. Scripting allows changing how the game works without changing and recompiling the engine.



Features: