Awesome
<p align="center"> <img src="media/logo.png" /> </p>IRIS
Iris is a cross-platform game engine written in modern C++
Table of Contents
- Screenshots
- Features
- Dependencies
- Included third-party libraries
- Using iris
- Building
- Examples
- Design
Screenshots
Features
- Cross platform: Windows, Linux, macOS and iOS
- Multiple rendering backends: D3D12, Metal, OpenGL
- 3D rendering and physics
- Custom sampling profiler
- Post processing effects:
- FXAA
- SSAO
- Bloom
- HDR
- Graph based shader compiler
- Skeleton animation
- Job based parallelism (fiber and thread implementations)
- Networking
- Lua scripting
Dependencies
The following dependencies are required to build iris:
- cmake > 3.18
- C++20 compiler
The following compilers have been tested
Platform | Version | Compiler |
---|---|---|
macOS | 14.0.5 | clang |
macOS | 13.1.6 | Apple clang (Xcode) |
linux | 14.0.5 | clang |
linux | 12.1.0 | g++ |
windows | 19.32.31332 | msvc |
Included third-party libraries
The following dependencies are automatically checked out as part of the build:
Dependency | Version | License |
---|---|---|
assimp | 5.0.1 | |
bullet | 3.17 | |
stb | c0c9826 | / |
googletest | 1.11.0 | |
directx-headers | 1.4.9 | |
lua | 5.4.3 | |
inja | 3.3.0 |
Note that these libraries may themselves have other dependencies with different licenses.
Using iris
Iris (and all of its dependencies) are built as static libraries. There are three ways you can include iris in your project. These all assume you are using cmake, it is theoretically possible to integrate iris into other build systems but that is beyond the scope of this document.
Prebuilt libraries
Prebuilt binaries (and required headers) are available in releases. Simply download, extract and copy somewhere. They can be either checked into your project or stored externally. Then simply add the following to your project cmake:
find_package(iris REQUIRED PATHS path/to/iris/lib/cmake)
target_link_libraries(my_project iris::iris)
System install
After building run the following as root/admin from the build directory to install iris into your system:
cmake --install .
Then simply add the following to your project:
find_package(iris REQUIRED)
target_link_libraries(my_project iris::iris)
Build in project
It is also possible to build iris as part of your project. Add the source to your project (either by copying the files or as a git submodule). Then add the following to your project:
add_subdirectory(iris)
target_include_directories(my_project PRIVATE iris/include)
target_link_libraries(my_project iris::iris)
Alternatively you can let cmake handle the checking out of iris:
FetchContent_Declare(
iris
GIT_REPOSITORY https://github.com/irisengine/iris
GIT_TAG v1.0.0)
FetchContent_GetProperties(iris)
if(NOT iris_POPULATED)
FetchContent_Populate(iris)
add_subdirectory(${iris_SOURCE_DIR} ${iris_BINARY_DIR} EXCLUDE_FROM_ALL)
endif()
target_include_directories(my_project PRIVATE iris/include)
target_link_libraries(my_project iris::iris)
Building
Options
Cmake option | Default value |
---|---|
IRIS_BUILD_UNIT_TESTS | ON |
The following build methods are supported
Command line
The following commands will build a debug version of iris. Note that this also works in PowerShell
mkdir build
cd build
cmake ..
cmake --build .
# to run tests
ctest
Visual Studio Code / Visual Studio
Opening the root CMakeLists.txt
file in either tool should be sufficient. For vscode you will then have to select an appropriate kit. On Windows you will need to ensure the "Desktop development with C++" workload is installed.
Tests can be run with a googletest adaptor e.g. visual studio or vscode
Examples
The samples directory contains some basic usages.
- sample_browser - single executable with multiple graphics samples (tab to cycle through them)
- jobs - a quick and dirty path tracer to showcase and test the jobs system
Some additional snippets are included below.
Create a window
#include "iris/core/context.h"
#include "iris/events/event.h"
#include "iris/graphics/window.h"
#include "iris/graphics/window_manager.h"
void go(iris::Context context)
{
auto *window = context.window_manager().create_window(800, 800);
auto running = true;
do
{
auto event = window->pump_event();
while (event)
{
if (event->is_key(iris::Key::ESCAPE))
{
running = false;
break;
}
event = window->pump_event();
}
window->render();
} while (running);
}
int main(int argc, char **argv)
{
iris::start(argc, argv, go);
}
Design
Versioning
The public API of iris is versioned using semver. This means that when upgrading you can expect the following outcomes:
- Major version -> your project could no longer compile/link
- Minor version -> your project may not function the same as before
- Patch version -> your project should function the same, if you were not relying on the broken behaviour.
The internal API could change frequently and should not be used. As a rule of thumb the public API is defined in any header file in the top-level folders in inlcude/iris
and any subfolders are internal.
Compile/Runtime choices
Iris provides the user with several runtime choices e.g. rendering backend and physics engine. These are all runtime decisions (see Managers) and implemented via classic class inheritance. Some choices don't make sense to make at runtime e.g. Semaphore
will be implemented with platform specific primitives so there is no runtime choice to make. To remove the overheard of inheritance and make this a simple compile time choice we define a single header (semaphore.h) with the API and provide several different implementations (macos, windows). Cmake can then pick the appropriate one when building. We use the pimpl idiom to keep implementation details out of the header.
Context & Managers
To start the engine you call iris::start
to which you pass a callback. Iris will perform all necessary startup and then call your callback passing to it an engine Context
. This Context
object contains everything required to use the engine. In order to easily facilitate the runtime selection of components iris makes use of several manager classes. A manager class can be thought of as a factory class with state. Default managers are registered for you on the Context
. It may seem like a lot of machinery to have to registers managers but the advantage is a complete decoupling of the implementation from Context
. It is therefore possible to provide your own implementations of these components, register, then use them.
Memory management
Iris manages the memory and lifetime of primitives for the user. If the engine is creating an object and returns a pointer it can be assumed that the pointer is not null and will remain valid until explicitly returned to the engine by the user.
core
The directory contains primitives used throughout the engine. Details on some key parts are defined below.
Start
The start
function allows iris to perform all engine start up and tear down before handing over to a user supplied function. All iris functions are undefined if called outside the provided callback.
Error handling
In iris errors are handled one of two ways, depending on the nature of the error:
- Invariants that must hold but are not recoverable - in this case
expect
is used andstd::abort
is called on failure. This is analogous to an assert and thy are stripped in release. Example: failing to allocate a graphics api specific buffer. - Invariants that must hold but are recoverable - in this case
ensure
is used and an exception is thrown on failure. This allows someone further up the stack to catch and recover. Example: loading a texture from a missing file.
It's not always clear cut when which should be used, the main goal is that all potential errors are handled in some way. See error_handling.h for expect
and ensure
documentation.
events
These are user input events e.g. key press, screen touch. They are captured by a Window
and can be pumped and then processed. Note that every tick all available events should be pumped.
auto event = window->pump_event();
while (event)
{
// handle event here
event = window->pump_event();
}
graphics
All rendering logic is encapsulated in graphics. API agnostic interfaces are defined and implementations can be selected at runtime.
A simplified breakdown of the graphics design is below. The public interface is what users should use. The arrow is (mostly) used to denote ownership.
+--------------+ +---------------------+
.--->| GraphicsMesh |--->| Graphics primitives |
| +--------------+ +---------------------+
|
| +----------------+ +------------------+
.--->| ShaderCompiler |--->| GraphicsMaterial |
| +----------------+ +------------------+
|
|
+----------+ +------------------+
| OSWindow | | GraphicsRenderer |
+----------+ +------------------+
| |
| |
private interface | |
~~~~~~~~~~~~~~~~~~~~~~~~~|~~~~~~~~~~~~~~|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
public interface | |
| |
| |
+---------------+ +--------+ +----------+ +----------+
| WindowManager |--->| Window |--->| Renderer |---->| render() |
+---------------+ +--------+ +----------+ +----------+
|
| +-----------------------+ +------------+ +---------------+
'-->| set_render_pipeline() |<-----| RenderPass |<------| Render Target |
+-----------------------+ | +------------+ | +---------------+
| | +-------------------------+
| '--| Post Processing Effects |
| +-------------------------+
| +--------------+
|--| Render Graph |
| +--------------+
|
| +-------+
'-| Scene |
+-------+
|
| +--------------+
'--->| RenderEntity |
| +--------------+
| | +------+
| '--->| Mesh |
| | +------+
| | +----------+
| '--->| Skeleton |
| +----------+
| +-------+
'--->| Light |
+-------+
render_graph
The render graph allows a user to define the effect of shaders in a shader language agnostic way. This is then compiled into the appropriate shader code (GLSL, HLSL, MSL) for the current window. Scene
creates and owns a RenderGraph
which can be used for any RenderEntity
in that Scene
(it is undefined to use RenderGraph
object in a Scene
that did not create it).
A RenderGraph
owns a RenderNode
which is the root of the graph. To configure the output of the shader the inputs of the RenderNode
should be set. An example:
Basic bloom Note that bloom is a provided as a built in post-processing effect - this is just used to illustrate the flexibility of the render graph.
Render
Main scene ~~~~~~~~ -----.
|
.------------------------'
|
| +---------------------+
|---------------. | Arithmetic Node | +----------------------+ +--------------+
| | |=====================| | Conditional Node | | Render Node |~~~~~~ -.
| +-----------+ '->O value1 | |======================| |==============| |
| | Threshold |--->O value2 |----->O input_value1 |->O colour_input | |
| +-----------+ .->O arithmetic operator | .--->O input_value2 | +--------------| |
| | +---------------------+ |.-->O output_value1 | |
| +-----+ | ||.->O output_value2 | |
| | DOT |-------' +------+ |||.>O conditional_operator | |
| +-----+ | 1.0f |----------------'||| +----------------------+ |
| +------+ ||| |
|-------------------------------------------'|| |
| +-------------+ || |
| | Zero colour |-----------'| |
| +-------------+ | |
| | |
| +---------+ | |
| | GREATER |----------------' |
| +---------+ |
| |
| .-----------------------------------------------------------------------------------------------'
| |
| |
| | +------------+ +--------------+
| | | Blur Node | | Render Node |
| | |============| |==============| ~~~~~ --.
| '--->O input_node |---->O colour_input | |
| +------------+ +--------------+ |
| |
| .--------------------------------------'
| |
| | +---------------------+
| | | Arithmetic Node | +--------------+
| | |=====================| | Render Node |
| '-->O value1 | |==============|~~~~~~~~~~> Screen
'-------------->O value2 |------->O colour_input |
.-->O arithmetic operator | +--------------+
+-----+ | +---------------------+
| ADD |----'
+-----+
jobs
Iris doesn't use separate threads for each component (e.g. one thread for rendering and another for physics) instead it provides an API for executing independent jobs. This allows for a more scalable approach to parallelism without having to worry about synchronisation between components.
A job
represents a function call and can be a named function or a lambda.
Note that a key part of the design is to allow jobs to schedule other jobs with either method.
Provided in the engine are two implementations of the job_system
:
Threads
This uses std::async
to create threads for each job. This is a simple and robust implementation that will work on any supported platform.
Fibers
There are two problems with the threading implementation:
- Overheard of OS scheduling threads
- If a job calls
wait_for_jobs()
it will block, meaning we lose one thread until it is complete
Fibers attempts to overcome both these issues. A Fiber is a userland execution primitive and yield themselves rather than relying on the OS. When the FiberJobSystem starts it creates a series of worker threads. When a job is scheduled a Fiber is created for it and placed on a queue, which the worker threads pick up and execute. The key difference between just running on the threads is that if a Fiber calls wait_for_jobs()
it will suspend and place itself back on the queue thus freeing up that worker thread to work on something else. This means fibers are free to migrate between threads and will not necessarily finish on the thread that started it.
Fibers are supported on Win32 natively and on Posix iris has an x86_64 implementation. They are not currently supported on iOS.
log
Iris provides a logging framework, which a user is under no obligation to use. The four log levels are:
- DEBUG
- INFO
- WARN
- ERROR
Logging is stripped in release. Internally iris uses an engine specific overload of the logging functions which are disabled by default unless you use start_debug()
instead if start()
.
Logging can be configured to use different outputters and formatters. Currently supported are:
- stdout outputter
- file outputter
- basic text formatter
- ansi terminal colouring formatter
- emoji formatter
To log use the macros defined in log.h
. The format of a log message is tag, message, args. This allows a user to filter out certain tags.
LOG_DEBUG("tag", "position: {} health: {}", iris::Vector3{1.0f, 2.0f, 3.0f}, 100.0f);
networking
Networking consists of a series of layered primitives, each one building on the one below and providing additional functionality. A user can use any (or none) of these primitives as they see fit.
Socket/ServerSocket
Socket
and ServerSocket
are the lowest level primitives and provide an in interface for transferring raw bytes. There are currently two implementations of these interfaces:
UdpSocket
/UdpServerSocket
- unreliable networking protocolSimulatedSocket
/SimulatedServerSocket
- aSocket
adaptor that allows a user to simulate certain networking conditions e.g. packet drop and delay
Channels
A Channel
provides guarantees over an unreliable networking protocol. It doesn't actually do any sending/receiving but buffers Packet
objects and only yields them when certain conditions are met. Current channels are:
UnreliableUnorderedChannel
- provides no guaranteesUnreliableSequencedChannel
- packets are in order, no duplicates but may have gapsReliableOrderedChannel
- packets are in order, no gaps, no duplicates and guaranteed to arrive
ClientConnectionHandler/ServerConnectionHandler
ClientConnectionHandler
and ServerConnectionHandler
implement a lightweight protocol providing:
- Making a connection
- Handshake
- Clock sync
- Sending/receiving data
physics
Iris comes with bullet physics out the box. The physics_system
abstract class details the provided functionality.
scripting
Iris supports Lua out of the box. The recommended way to use it is with the ScriptRunner
primitive.
iris::ScriptRunner runner{std::make_unique<iris::LuaScript>(R"(
function go()
print('hello')
end)")};
runner.execute("go");
The return type of execute
will be deduced based on the supplied template arguments, this allows for intuitive handling of void, single and multi argument functions.
iris::ScriptRunner runner{std::make_unique<iris::LuaScript>(R"(
function func1()
print('hello')
end
function func2()
return 1
end
function func3()
return 'hello', 2.0
end)")};
// no arguments so execute returns void
runner.execute("func1");
// single type, so supplied type is returned
const std::int32_t r1 = runner.execute<std::int32_t>("func2");
// multiple types, so tuple of supplied types are returned
const std::tuple<std::string, float> r2 = runner.execute<std::string, float>("func3");
Lua scripts call also use (as well as return) Vector3
and Quaternion
types. See tests for more examples.