Home

Awesome

<p align="center"> <img src="media/logo.png" /> </p>

IRIS

Iris is a cross-platform game engine written in modern C++

build C++20 License Platforms

Table of Contents

  1. Screenshots
  2. Features
  3. Dependencies
  4. Included third-party libraries
  5. Using iris
    1. Prebuilt libraries
    2. System install
    3. Build in project
  6. Building
    1. Options
    2. Command line
    3. Visual Studio Code / Visual Studio
  7. Examples
  8. Design
    1. Versioning
    2. Compile/Runtime choices
    3. Managers
    4. Memory management
    5. core
    6. events
    7. graphics
      1. render_graph
    8. jobs
    9. log
    10. networking
    11. physics
    12. scripting

Screenshots

trinket zombie physics

Features

Dependencies

The following dependencies are required to build iris:

  1. cmake > 3.18
  2. C++20 compiler

The following compilers have been tested

PlatformVersionCompiler
macOS14.0.5clang
macOS13.1.6Apple clang (Xcode)
linux14.0.5clang
linux12.1.0g++
windows19.32.31332msvc

Included third-party libraries

The following dependencies are automatically checked out as part of the build:

DependencyVersionLicense
assimp5.0.1License
bullet3.17License: Zlib
stbc0c9826License: MIT / License: Unlicense
googletest1.11.0License
directx-headers1.4.9License: MIT
lua5.4.3License: MIT
inja3.3.0License: MIT

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 optionDefault value
IRIS_BUILD_UNIT_TESTSON

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.

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:

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:

  1. Invariants that must hold but are not recoverable - in this case expect is used and std::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.
  2. 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:

  1. Overheard of OS scheduling threads
  2. 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:

  1. DEBUG
  2. INFO
  3. WARN
  4. 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:

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:

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:

ClientConnectionHandler/ServerConnectionHandler

ClientConnectionHandler and ServerConnectionHandler implement a lightweight protocol providing:

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.