CE has its roots in OpenTomb, as I was once looking for a modern alternative to the original engine without DosBox, allowing me to play Tomb Raider on a modern system while taking advantage of modern hardware. This fork started around 2015, and has grown since, so let's discuss the design principles.
In general, the engine is built to make use of modern technology that's generally available for the past 5-ish years for gamers, while still maintaining the original feel and aesthetics. The engine is also designed to be accessible and customizable, so that everyone can adjust it for them to enjoy it the most, without being rocket science. Unfortunately, this also means that it's not "the original" - especially since the code is written to be safe, which in turn means there were some unavoidable changes in some algorithms, especially AI path finding and camera code (and both still have bugs I wasn't able to resolve after years).
I have watched hours and hours of streams and YouTube videos to understand what other people find enjoyable, and what could be expanded on, which led to some well-received features like edge outlines. I wanted this to feel like a true remaster, not just like "let's ship the hi-res textures and models we used during development." I know some people disagree on this, but that's fine. The goal is to make this a fun, enjoyable engine, which is easily adjustable to make it enjoyable for you (side note, butt bubbles were implemented after having a fun stream over a beer).
In conclusion...
- make it type-safe, modern C++
- make it fun (even while your own definition of fun may differ from others)
- don't make it realistic (it's a game, and fun must be the priority, not a simulation of life)
- don't be afraid of crazy ideas like butt bubbles (you can still remove it later if it proves to be too crazy)
- just fun!
When building, the CMake configuration will create wrappers for some enums that allow easy de-/serialization and conversion. For example, tracks_tr1.txt will be converted into an enum class, as well as some utility functions that allow conversion from and to strings, as well as enumerating all enum members.
The build configuration also downloads external dependencies, such as CImg, glm, and others.
The engine has several submodules, mostly separated within the src folder:
- The
archivemodule is responsible for accessing archives. It is used to import 3rd party levels, as well as to package up the local ghost data. - The
audiomodule is responsible for anything audio-related. It handles audio streams, positional audio, and static audio not related to a position. - The
coremodule contains anything used throughout the whole engine. It contains very generic stuff like vectors and i18n that is used globally across many other modules. - The
dosbox-cdrommodule contains the code for accessing disk images, used to import the main game data. - The
enginemodule contains the heart, and is the most complex one. It connects nearly all the other modules and makes the game work. - The
etcpakmodule contains the texture compression code for caching texture pack data. - The
ffmpegmodule contains everything to decode audio and video data. - The
gameflowmodule is a small module to handle gameflow metadata. - The
hidmodule is responsible to handle user input. - The
launchermodule contains anything the user gets to see first, i.e. the main window. - The
loadersubmodule contains everything to slurp in the data files. - The
menumodule contains anything regarding the in-game menu, i.e. the inventory. It is implemented as a state machine. - The
networkmodule contains all the stuff needed to play online with others. It contains authorization and state synchronization. - The
qsmodule contains everything regarding the quantity system, which is used to protect against using the wrong values on compile-time. It brought up several issues in the past after implementing this. Devils be in there. - The
rendersubmodule is an abstraction of the OpenGL wrappersoglbto make it easier to draw anything on the screen. - The
serializationmodule is for serializing and deserializing data from and to YAML. It is designed to be a plug-in thing, so that only the implementations need to include the headers necessary to (de-)serialize anything. Devils be in there, too. - The
sharedsubmodule contains some generally helpful stuff. It's not withincore, as it's containing stuff more specific to certain use-cases. soglbcontains theStructured OpenGL Bindings(so it's not my initials here). This is a type-safe wrapper around the OpenGL API.- The
testutilmodule contains some stuff to better integrate into CI pipelines. It's not relevant to anything in general, and you shouldn't worry about it. - The
uisubmodule is the UI framework, containing stuff like text boxes, lists, message boxes, etc., anything that defines the UI. - The
utilsubmodule contains some stuff even less common than thesharedmodule, even while it's used across some other modules - The
videomodule contains all the stuff to display the FMVs.
The "audio" module handles anything regarding audio effects. It controls:
- the listener position,
- the listener's audio effects (like muffling underwater),
- allocating voices to sound effects depending on the position since there's a limited amount of concurrently playing sounds,
- and multiple channels of audio streams.
Regarding audio streams, the engine allocates channels to audio streams, where each channel can only play a single stream. This allows switching ambient streams without overlapping, because they're using the same channel, while still playing interception audio like cinematic music at the same time in a different channel.
Contains i18n support, generally used types like angles, vectors, type-safe IDs, intervals and so on, but it also
contains the magic values like the sector size, climb limits, or the frame rate in magic.h.
Heavily relies on the qs module.
This module contains functionality to access CUE/DAT images necessary to import game data from GOG or Steam installations.
This is the heart of CE, and it contains several submodules itself. The Engine class is a container for anything
needed game-flow-wise to run a level.
The engine is generally built to allow loading multiple levels (or "worlds") at the same time, or even multiple gameflows. While that led to a cleaner architecture, it is not used at all, but could be used to preload a level once the prior level ends during the stats screen.
First, the engine initializes the script engine for the chosen game-flow. Then, the engine sets up a Presenter, which
basically is a container around everything a user can sense - audio and video currently, but may also include gamepad
rumble effects in the future once it's implemented in GLFW (blocked
by issue #57 and pull #1678). The
presenter also handles the hid and OpenGL window using soglb.
Once the Presenter is initialized, the user's chosen render settings are applied, their input config is applied, and (
optionally) the configured Glidos texture pack is loaded. Loading the Glidos texture pack does not involve loading the
textures yet, it is just loading the general layout information, as the materialized textures are cached using
etcpak.
Once the engine is initialized, the control returns to the main file.
The engine also maintains a cache for the world geometry, so that re-loading the same level is sped up significantly. The cache is reset on level change.
This submodule contains general AI stuff like behaviour handling and pathfinding.
The pathfinding code is one of the most convoluted areas in this engine, as the original code wasn't in the best shape. Additionally, the original code had several major issues with out-of-bounds accesses, which led to necessary changes that made the code even worse and behave differently. There are still bugs in the pathfinding code after all these years - if you can find and fix them, you're a god.
Contains everything for triggers and floor data logic. The "floor data" are sector-based instructions. For example, when you're walking over a bridge, there is an instruction to activate that bridge, otherwise you would fall through. Another example are boundary rooms - when reaching the edge of a room, an instruction will be triggered that changes the entity's parent room.
It also contains a function to get a floordata's secret mask, which is used to count the secrets in a level without relying on external information (this leads to a single discrepancy with the original TR1 levels).
This submodule is responsible to read and write ghost data, as well as providing a scene node for the ghost
(see the render module).
Lara is basically a state machine. Every animation Lara plays has a set state. This submodule provides the states' behaviour. For example, the "Jump Back" state handler sets the desired state to "Free Fall" if the vertical velocity exceeds a certain threshold. When Lara's animation is played, the animation frames can provide some state transition information, allowing to switch to a different animation, given a certain frame range. This will implicitly switch to a different animation state handler.
Contains everything that defines the game entities. The enemies' behaviours are defined here, as well as objects' behaviours. To support smooth rendering at higher frame rates, the engine uses a predictive interpolation system. At the end of each logic tick, the predicted state for the next tick is calculated. During rendering, transforms are interpolated between the predicted and actual state.
There are usually custom serialization
overrides for entities that have specific state variables (see also serialization), and also custom
collide() overrides to handle collisions with Lara (like the hand of Midas or switches). Some entities like bridges
are also able to change the effective ceiling or floor height when they're activated through floordata.
Contains the whole script engine. This loads the game-flow scripts and provides reflection for interacting with the scripts.
Game-flow scripts provide the following data:
- entity descriptors like smartness, behaviour stereotype, health, etc.
- soundtrack mappings from internal identifiers to files and some audio configurations, like fade-in time or the
slot/channel the audio files should be allocated to (see
audio) - the level sequence
- meta-information about the game like authors and the title
Provides the massaged data necessary to run everything, including some optimizations for easier data access and
converted game data (see loader).
Currently, worldgeometry is responsible for converting the raw game data into
something useful. It holds animation data, meshes, massaged floordata, rooms, audio data, etc.
This module is also responsible to re-map textures into the large texture atlases used by the engine, as well as
applying Glidos texture packs if configured. Materialized textures are cached using etcpak.
classDiagram
RoomGeometry --o Mesh
RoomGeometry --o TextureAnimator
RoomGeometry ..> AnimatedUV
WorldGeometry ..> StaticMesh
WorldGeometry ..> Mesh
WorldGeometry ..> SkeletalModelType
WorldGeometry --o Sprite
WorldGeometry --o SpriteSequence
WorldGeometry --o AtlasTile
WorldGeometry --o Animation
WorldGeometry ..> Texture2DArray
WorldGeometry --o RoomGeometry
RoomGeometry ..> Mesh
RoomGeometry ..> TextureAnimator
RoomGeometry ..> AnimatedUV
RoomGeometry ..> Mesh
World ..> Engine
World --* AudioEngine
World --* CameraController
World --> Voice
World --* ObjectManager
World --o PositionalEmitter
World --o PickupWidget
World ..> Player
World --* FloorData
World --o Box
World --o Room
World --o StaticSoundEffect
World --o CinematicFrame
World --o CameraSink
Box ..> "0..*" Box
SkeletalModelType ..> AnimFrame
SkeletalModelType ..> Animation
Sector ..> Box
Sector ..> Room
Room ..> Portal
Portal ..> Room
Portal ..> Mesh
Room --o Light
Room --o Portal
Room --o Sector
Room --o RoomStaticMesh
Room ..> Room
Room --* InstancedParticleCollection
Room --* RoomGeometry
PositionalEmitter --|> Emitter
World --* WorldGeometry
WorldGeometry --o StaticMesh
WorldGeometry --o Mesh
WorldGeometry --o SkeletalModelType
WorldGeometry --o Sprite
WorldGeometry --o SpriteSequence
WorldGeometry --o AtlasTile
WorldGeometry --o Animation
WorldGeometry --o Texture2DArray
WorldGeometry --* "0..*" RoomGeometry
This happens in texturing.cpp. The texturing class also maintains a texture size
cache file which heavily speeds up loading cached textures because only the layout process needs to be done, and loading
a cached texture does not involve loading individual, costly texture files.
The individual textures of the original texture atlases are determined by iterating over every primitive and get their bounding boxes. This step also removes spurious "inner boxes" (boxes that are contained within other boxes).
After the layout is done (which only needs the bounding boxes of the original textures, and the sizes of the new textures), the new layout is applied to the appropriate UV coordinates so they point to the changed texture coordinates. If no cached texture exists yet, the texture atlases are materialized, which also includes expanding the edges to avoid color and alpha bleeding when scaling down for mip-maps.
If no Glidos texture pack is configured, this process only re-arranges the original texture atlases into larger ones.
The final texture atlases are finally stored in a gl::Texture2DArray<gl::PremultipliedSRGBA8> resource.
Level data is converted into engine digestible data in World::initFromLevel. Before conversion, a few simple
pre-flight checks are done to identify problematic data - this is useful to identify broken or problematic custom levels
which are usually produced by Tomb Editor. Fixing the level data in these levels programmatically is basically
impossible without risking to break working levels.
After the pre-flight checks are done, the conversion starts. Conversion usually includes replacing indices with direct
pointers or separating individual flags or values from bit fields (partially done by the raw loader already). Also, the
scene tree is set up here (see render).
This is basically a copy of https://github.com/wolfpld/etcpak, but with some modifications to reduce code complexity, increase type safety, and easier integration for texture compression and decompression. Its main purpose is to reduce I/O and performance latencies for texture caching when using texture packs.
The "Human Input Device" module is responsible to handle everything the user inputs for the game. It can translate joystick movement to axis directions and merge multiple input configurations where several different inputs may lead to the same action.
Contains everything to read game data files and Glidos texture packs. This module only provides the raw game data and
does not do any processing necessary to render any entity. The conversion from raw data to anything useful to the engine
is done within the engine module.
The menu module contains the inventory code. It resembles the original code to some extent as that it is still a state
machine. However, here it is built around different class instances for different states. It makes heavy use of the
ui module.
Contains code to connect, authenticate, and communicate with haunted-coop.
This is the "Quantity System." It allows to create a type-safe unit system, where the compiler will fail if you try, for example, pass a velocity to a function that expects an acceleration. It will automatically change units when you combine different quantities, for example, it will change to unit to "acceleration" if you divide a velocity by a time unit. It is not designed to be something that can handle every combination of units or quantities, but it is more than sufficient for the task.
This contains everything to abstract the OpenGL API. It contains, for example, functionality to bind a shader parameter
to a callback function, which allows setting the parameter dynamically when meshes are drawn. It also contains the whole
render pipeline, with all the framebuffers needed for all the effects. It is an even more abstract wrapper
around soglb.
Scenes are built as trees of nodes. Each node has an optional "Renderable," which provides the visual of this node. Each node can be hidden; this property is recursive. The nodes' transforms are relative to their parents and are calculated on-demand. Each node contains a "render state" which contains only the necessary changes to the OpenGL state, overriding only the specified settings of their parent's node. Each "Renderable" may also provide a render state.
Rendering the scene tree is done using a visitor pattern. The visitor maintains a stack of render states that are applied to the OpenGL state when rendering a visual.
Each rendering step is done within a framebuffer. These framebuffers are usually from render passes, for example bloom or HBAO.
When rendering, the engine performs an initial "depth prefill" pass, pre-rendering the rooms without its entities into the depth buffer, to avoid rendering visuals with expensive fragment shaders.
Probably even more complicated than the qs module. This module provides a de-/serialization interface for YAML
files. It is designed as a plugin architecture, so that header files don't need to be polluted with specific
serializers, like std::maps or std::filesystem::path. It also helps with compile time.
In general, a serialization of something involves a dispatch call, which looks for freestanding de-/serialization and
factory methods suitable for the job, or methods contained within classes with the same names. Within these methods, the
serializer is then recursively called back-and-forth.
These are the "Structured OpenGL Bindings." Essentially, these are type-safe wrappers around the OpenGL API, including classes supporting the C++ RAII concept. This module is designed to provide a convenient, type- and context-safe API.
Additionally, it also contains a Text class handling modern fonts and rendering them into images. This is an outlier
for the purpose of this module, however.
This is the UI widget module used for the inventory. It is designed to automatically accommodate the contents of the widgets so that the developer does not need to care about sizing or positioning too much. It also contains some functionality to break long strings of text into lines.
When drawing the UI, the draw commands (lines, rects, etc.) are collected in the Ui class so all commands can be
rendered using a single draw call.
Contains the code responsible to play videos and make them look less ugly after upscaling.
Generally, it works like this (simplified, read docs above for missing details):
flowchart TD
main((main)) --> launcher
launcher --> quit(((quit)))
bootstrap["run bootstrap sequence<br>(Eidos logo FMV etc.)"]
menu["main menu"]
menu --> launcher
menu --> nextlevel
launcher --> load_gameflow["load gameflow script"] --> bootstrap
subgraph gameflow loop
nextlevel["select level from save<br>or first level sequence item"]
nextlevel --> load["load level data<br>and texture pack"] --> loadsave["load savegame<br>(optional)"]
loadsave --> level["run level"] --> nextlevel
level --> inventory --> level
level --> menu
inventory --> menu
end
bootstrap --> nextlevel
The audio engine manages all in-game sounds, including audio streams, positional audio, entity-bound positional audio,
and non-positional audio (e.g. when Lara is talking). It also hosts the sound engine from the audio module,
which is the abstraction of the underlying OpenAL API.
When loading a level, all level audio (except streams) is stored here. It handles "audio effects," which are descriptors that contain multiple sounds, sound playback chance, pitch range for random pitch, etc. The game engine only requests audio effects or streams from the audio engine.
The audio engine also handles which sounds are played at the same time, since there's only a limited amount of audible "voices." Depending on the distance between the listener and the sound, the audio engine may stop too distant sounds and re-allocate the freed voice to another sound that's closer.
classDiagram
AudioEngine --* SoundEngine
AudioEngine --o VoiceGroup
AudioEngine --o BufferHandle
AudioEngine ..> SoundEffectProperties
SoundEngine --* Device
SoundEngine ..> Voice
SoundEngine --o SlotStream
VoiceGroup ..> Voice
SlotStream ..> StreamVoice
StreamVoice ..> Voice
StreamVoice ..> StreamingSourceHandle
StreamVoice --* AbstractStreamSource
StreamVoice --o BufferHandle
BufferVoice --|> Voice
BufferVoice --* BufferHandle
Voice --* SourceHandle
Device --o Voice
StreamingSourceHandle --o BufferHandle
AbstractStreamSource <|-- BasicFfmpegStreamSource
BasicFfmpegStreamSource --* AudioStreamDecoder
BasicFfmpegStreamSource <|-- FfmpegMemoryStreamSource
BasicFfmpegStreamSource <|-- FfmpegStreamSource
BasicFfmpegStreamSource <|-- FfmpegSubStreamStreamSource