light/docs/architecture.rst

199 lines
9.4 KiB
ReStructuredText
Raw Normal View History

Architecture
=========================
**Light** is a **modular** and feature rich engine with emphasis on the **entity component system** architectural design.
It is composed of numerous **modules** and prefers **composition over inheritance**.
Building Blocks
------------------------
Modules are the **building blocks** of Light.
Each module can provide one, and only one of the following:
- Library
These modules provide some form of functionality.
They may depend on library modules.
For instance, they may provide: asset parsing, logging, string functions,
math functions, containers, etc.
Library modules may also **wrap** a third-party library and provide an interface following Light's coding guidelines.
This way:
* Unsafe libraries, often native C libraries, can be made safer (without sacrificing performance).
* Coding conventions can be consistent across all lines of code in other modules.
* Deprecated and confusing conventions (eg. out parameters) can be wrapped away and replaced by simpler interfaces.
* It may be possible to provide only the required symbols from a library.
- Tool
These modules compile as executables that provide tooling.
They may depend on any other module type.
An example of a **tool** module is the "AssetBaker" that relieves other modules
from linking to large third-party asset readers (and optimizers) and bakes assets' content
in a common, and often optimized, format.
- Component
These modules specify POD structures to be used as components of one or more systems.
They shall not depend on any other module. This separation (of components and system modules) is for 2 reasons:
First, for simple re-usability of **components** between multiple **systems** as it is quite likely
for more than 1 **system** to depend on a component.
For instance, **physics simulation** and **rendering** both depend on the **TransformationComponent**
Second, even if a component is used solely by **1 system**, decoupling the **component** from the
**system's implementation** allows us to provide alternative implementations for
a system. For instance, we can have a **polygon-renderer** and a **raytracer** for our
rendering system.
- System
System modules may depend on any number of modules but must depend on at least one component module.
These modules implement an aspect of the engine, e.g. physics system, rendering system,
audio system, networking system, script system, etc.
They implement a common **system interface** and may periodically **tick** to execute their logic
and **mutate** different components while doing so.
- Main
**Light** is the stock implementation of the **main** module. It's basically our engine.
You may design and use an alternative implementation.
The main module acts as the **aggregator** and **synchronizer** of a required module list.
It synchronizes between multiple modules using a concept called **tick dependencies**.
Since we want to maximally utilize our hardware, we need to have as much concurrency as possible.
One obvious way to increase concurrency is to make multiple systems tick (execute their logic)
in parallel.
But we can't just tick haphazardly, we need proper **synchronization**.
Let's break down how synchronization works with **tick dependencies** by briefly going
through different module types:
**Tool** modules are executables (eg. AssetBaker). They don't need any external synchronization.
**Library** modules simply provide reusable functionality for **tool** and **system** modules.
**Component** modules, even more simply, provide a set of POD structures.
**System** modules, however, bring about a bit of complexity.
They need to execute a piece of code periodically, which may cause **mutations**.
For instance, a **physics simulation system** needs to tick every frame (or multiple times
a frame at fixed intervals) to simulate physics, this simulation may **mutate** one
or more components, such as the **TransformationComponents**.
Meanwhile, the **rendering system** might use the **TransformationComponent** for vertex shader operations
to determine where things should end up on the screen.
We can't be **mutating** a component in one thread while **reading** it in another, this causes all sorts
of nasty problems. Hence why we need synchronization.
Systems can think of synchronization of their logic to be done in one of 3 ways:
**external**, **internal**, and **unsynchronized**.
- **Unsynchronized**:
As evident from the name, no syncing needs to be done for these parts of a system.
- **Internal**:
This type of synchronization is handled internally by the module and does
not concern other systems.
- **External**:
This is the synchronization done by the main module (Light). And can be achieved
by specifying a list of tick dependencies.
The system interface provides 3 tick functions:
- **tick_pre_unsync**:
This gets called for all **active systems** at the same time before the frame starts.
Light waits for the execution of all **tick_pre_unsync** functions to finish before proceeding to **tick_sync**.
This is usually for systems to handle internally synchronized pre-frame logic.
- **tick_sync**:
This relies on the tick dependencies of a system.
A tick dependency simply marks a **type of component** as a **mutable** or **immutable** dependency for
a particular **system**. If system **A** shares only **immutable** dependencies with system **B**, or
if system **A** shares no dependencies with system **B**, they can tick together.
If system **A** has a **mutable** dependency with a component, then all systems that have either
**mutable** or **immutable** dependency with that component need to tick before or after system **A's**
tick.
- **tick_post_unsync**:
Same as **tick_pre_unsync**, but happens after all **tick_sync** calls
have been executed.
You might be wondering why we're bothering with specifying tick dependencies between
such high-level concepts as **systems** where we could manually do the external synchronization
between them.
There may be truth in that claim, but our intention here is to free **Light** from knowing
about the implementation details of our systems and instead make it simply provide a framework for setting up
and running multiple systems together.
Furthermore, now our **systems** don't need to know anything about the existence of other systems either
and their logic is completely isolated.
Performance
----------------
**Light** engine aims to keep a high-performant design on all levels of the engine, from the grand architecture to the low-level implementations.
It also tries to ensure a consistent performance across platforms and APIs.
We achieve this by not thinking of performance as a concern for later times and put it as one
of our first priorities.
Some of the main techniques **Light** utilizes to ensure optimal code performance:
- Hardware Friendly Architecture
- Native Support
Light provides native support for the supported platforms and architectures.
It also provides native graphics API support for all the supported operating systems:
Metal for MacOS, DirectX12 for Windows and Vulkan for the rest.
- Baking
Light bakes and optimizes anything that can be baked as soon as it can.
For assets, this will significantly decrease our load times because optimized (and specialized) data can be directly
streamed to RAM/VRAM without intermediate processing.
We don't only bake assets like models, images and audio. We also bake anything about the scene
that's bake-able in any sense.
- Low Memory Footprint
Memory is sacred! That's my personal philosophy. I always use low memory-footprint applications.
I value and respect memory. I worship memory. I pray to the memory gods, so that I may never run
out of memory.
Games and simulations naturally have high memory consumption. But that fact should not give us
a free pass for ignoring our memory footprint. Light engine respects its user's hardware by
not being wasteful and always optimizing its memory consumption (alongside its performance).
Every byte matters!
- Minimal Indirection
Light minimizes unnecessary indirections and makes friends with the compilers by
providing them as much context as possible. It uses compile-time paradigms and principles before
considering (necessary) indirections.
All problems in programming can be solved by another level of indirection. But perhaps not performance
problems.
- Rigorous Testing
State
----------------
The data in Light engine can be coarsely divided into 4 types:
- Shared
Components are the only way for our systems to have data shared between themselves.
They are laid out in the most reasonably efficient way possible by our ECS implementation.
Currently we use **EnTT**, however we **may** roll our own implementation in the future if needs be.
- Internal
Systems can hold any amount of internal state as they wish. Light however won't go over-board
and respects the hardware's cache locality when it's iterating over a set of components.
- Transient
Mostly just the stack.
- Cold
Cold state is everything that lives on the disk, saved game-state, baked assets, etc.