Nameless Engine
|
This is a manual - a step-by-step guide to introduce you to various aspects of the engine. More specific documentation can be always found in the class/function/variable documentation in the source code or on this page (see left panel for navigation), every code entity is documented so you should not get lost.
Note that this manual contains many sections and instead of scrolling this page you can click on a little arrow button in the left (navigation) panel, the little arrow button becomes visible once you hover your mouse cursor over a section name, by clicking on that litte arrow you can expand the section and see its subsections and quickly jump to needed sections.
This manual expects that you have a solid knowledge in writing programs using C++ language.
First of all, read the repository's README.md
file, specifically the Prerequisites
section, make sure you have all required things installed.
The engine relies on CMake as its build system. CMake is a very common build system for C++ projects so generally most of the developers should be familiar with it. If you don't know what CMake is or haven't used it before it's about the time to learn the basics of CMake right now. Your project will be represented as several CMake targets: one target for standalone game, one target for tests, one target for editor and etc. So you will work on CMakeLists.txt
files as with usual C++! There are of course some litte differences compared to the usual CMake usage, for example if you want to define external dependencing in your CMake file you need to do one additional step (compared to the usual CMake usage) that will be covered later in a separate section.
Because Windows limits paths to ~255 characters it's impossible to operate on files that have long paths, thus, you need to make sure that path to your project directory will not be very long, this does not mean that you need to create your project in disk root (for ex. C:\myproject) - there's no need for that but the shorter the path to the project the most likely you won't have weird issues with your project. If you need exact numbers a path with 30-40 characters will be good but you can try using longer paths. Just don't forget about this path limitation thing.
Currently we don't have a proper project generator but it's planned. Once the project generator will be implemented this section will be updated.
Right now you can clone the repository and don't forget to update submodules. Once you have a project with CMakeLists.txt
file in the root directory you can open it in your IDE. For example Qt Creator or Visual Studio allow opening CMakeLists.txt
files as C++ projects, other IDEs also might have this functionality.
Note for Visual Studio users:
If you use Visual Studio the proper way to work on
CMakeLists.txt
files as C++ projects is to open up Visual Studio without any code then pressFile
->Open
->Cmake
and select theCMakeLists.txt
file in the root directory (may be changed in the new VS versions). Then a tab calledCMake Overview Pages
should be opened in which you might want to clickOpen CMake Settings Editor
and inside of that changeBuild root
to just${projectDir}\build\
to store built data inside of thebuild
directory (because by default Visual Studio stores build in an unusual pathout/<build mode>/
). When you openCMakeLists.txt
in Visual Studio near the green button to run your project you might see a textSelect Startup Item...
, you should press a litte arrow near this text to expand a list of available targets to use. Then select a target that you want to build/use and that's it, you are ready to work on the project.
Note for Windows users:
Windows users need to run their IDE with admin privileges when building the project for the first time (only for the first build) when executing a post-build script the engine creates a symlink next to the built executable that points to the directory with engine/editor/game resources (called
res
). Creating symlinks on Windows requires admin privileges. When releasing your project we expect you to put an actual copy of yourres
directory next to the built executable but we will discuss this topic later in a separate section.
Before you go ahead and dive into the engine yourself make sure to read a few more sections, there is one really important section further in the manual that you have to read, it contains general tips about things you need to keep an eye out!
Let's looks at the directories/files that a typical project will have:
build
this directory generally used in CMake projects to store built binaries.docs
this directory stores documentation, generally it contains a Doxyfile
config that doxygen
(generates documentation pages from source code comments) uses and maybe a hand-written documentation page (a manual, like the one your are reading right now).ext
this directory is used to store external dependencies, generally it stores git submodules but sometimes it also stores non-submodule directories.res
this directory stores all resources (shaders, images, 3D objects, sound, etc.), if you look in this directory you might see subdirectories like editor
, engine
, game
and test
, each one stores resources for one specific thing, editor
stores editor resources, game
stores resources that your game needs, test
stores resources used in automated testing and etc.src
this directory stores all source code: source code for the editor, engine, your game, etc..clang-format
this file is used by clang-format
a code formatter that automatically formats our source code according to the config file, we will talk about code formatting in one of the next sections..clang-tidy
this file is used by clang-tidy
a static analyzer that checks our code for common mistakes/bugs and also controls some code style, we will talk about static analyzers in one of the next sections.CMakeLists.txt
is a top-level CMake file that we open in our IDEs, it adds other CMake files from the src
directory.The src
directory contains a directory per CMake target, for example: editor
(executable CMake target), editor_lib
(library CMake target) and some additional helper directories such as .cmake
(contains helper CMake functions) and .scripts
(contains Go scripts that we use in our CMake files).
Inside of each CMake target directory will be a target-specific CMakeLists.txt
file (for example: src/editor_lib/CMakeLists.txt
), directories for source code and maybe a .generated
directory that contains reflection code (we will talk about source code directories and reflection in one of the next sections).
Note that generally you should add all functionality of your game into a library target (so that it can be used in other executable targets such as tests
) while executable targets will generally will only have main.cpp
.
Your game target CMakeLists.txt
is fully yours, it will have some configuration code inside of it but you are free to change it as you want (you can disable/change doxygen
, clang-tidy
, reflection generation and anything else you don't like).
src/engine_lib
is divided into 2 directories: public and private. You are free to include anything from the public
directory, you can also include header files from the private
directory in some special/advanced cases but generally there should be no need for that. Note that this division (public/private) is only conceptual, your project already includes both directories because some engine public
headers use private
headers and thus both included in cmake targets that use the engine (in some cases it may cause your IDE to suggest lots of headers when attempting to include some header from some directory so it may be helpful to just look at the specific public directory to look for the header name if you feel overwhelmed).
Inside of the public
directory you will see other directories that group header files by their purpose, for example io
directory stands for in/out
which means that this directory contains files for working with disk (loading/saving configuration files, logging information to log files that are stored on disk, etc.).
You might also notice that some header files have .h
extension and some have .hpp
extension. The difference here is only conceptual: files with .hpp
extension don't have according .cpp
file, this is just a small hint for developers.
You are not required to use the private
/public
directory convention, moreover directories that store executable cmake targets just use src
directory (you can look into engine_tests
or editor
directories to see an example) so you can group your source files as you want.
The engine uses GLM (a well known math library, hosted at https://github.com/g-truc/glm). Although you can include original GLM headers it's highly recommended to include the header math/GLMath.hpp
instead of the original GLM headers when math is needed. This header includes main GLM headers and defines engine specific macros so that GLM will behave as the engine expects.
You should always prefer to include math/GLMath.hpp
instead of the original GLM headers and only if this header does not have needed functionality you can include original GLM headers afterwards.
The engine uses clang-format
and clang-tidy
a classic pair of tools that you will commonly find in C++ projects. If you don't know what they do it's a good time to read about them on the Internet.
The engine does not require you to use them but their usage is highly recommended.
clang-format
can be used in your IDE to automatically format your code (for example) each time you press Ctrl+S. If you want to make sure that your IDE is using our .clang-format
config you can do the following check: in your source code create 2 or more consecutive empty lines, since our .clang-format
config contains a rule MaxEmptyLinesToKeep: 1
after you format the file only 1 empty line should remain. The action with which you format your source code depends on your IDE settings that you might want to configure, generally IDEs have a shortcut to "format" your source code but some have option to automatically use "format" action when you are saving your file.
clang-tidy
has a lot of checks enabled and is generally not that fast as you might expect, because of this we have clang-tidy
enabled only in release builds to speed up build times for debug builds. This means that if your game builds in debug mode it may very well fail to build in release mode due to some clang-tidy
warnings that are treated as errors. Because of this it's highly recommended to regularly (say once or twice a week) build your project in release mode to check for clang-tidy
warnings/errors.
Have you used Godot game engine? If the answer is "yes" you're in luck, this engine uses a similar node system for game entities. We have a base class Node
and derived nodes like SpatialNode
, MeshNode
and etc.
If you don't know what node system is or haven't used Godot, here is a small introduction to the node system:
Generally game engines have some sort of ECS (Entity component system) in use. There are various forms of ECS like data-driven, object-oriented and etc. Entities can be represented in different ways in different engines: an entity might be a complex class like Unreal Engine's
Actor
that contains both data and logic or an entity might be just a number (a unique identifier). Components can also be represented in different ways: a component might be a special class that implements some specific logic like Unreal Engine'sCharacterMovementComponent
that implements functionality for your entity to be able to move, swim, fly and etc. so it contains both data and logic or a component may just group some data and have no logic at all. Node system is an ECS-like system that is slightly similar to how Unreal Engine's entity-component framework works. If you worked with Unreal Engine imagine that there are no actors but only components and world consists of only components - that's roughly how node system works. In node system each entity in the game is a node. A node contains both logic and data. The base classNode
implements some general node functionality such as: being able to attach child nodes or attach to some parent node, determining if the node should receive user input or if it should be called every frame to do some per-frame logic and etc. We have various types of nodes likeSpatialNode
that has a location/rotation/scale in 3D space orMeshNode
that derives fromSpatialNode
but adds functionality to display a 3D geometry. Nodes attach other nodes as child nodes thus creating a node hierarchy or a node tree. A node tree is a game level or a game map. Your game's character is also a node tree because it will most likely be built of a mesh node, a camera node, a collision node, maybe your custom derived node that handles some character specific logic and maybe something else. Your game's UI is also a node tree because it will most likely have a container node, various button nodes and text nodes. When you combine everything together you attach your character's node tree and your UI node tree to your game map's node tree thus creating a game level. That's how node system works.
Nodes are generally used to take place in the node hierarchy, to be part of some parent node for example. Nodes are special and they use special garbage collected smart pointers (we will talk about them in one of the next sections) but not everything should be a node. If you want to implement some class that does not need to take a place in the node hierarchy, does not belong to some node or does not interact with nodes, for example a class to send bug reports, there's really no point in deriving you bug reporter class from Node
, although nobody is stopping you from using nodes for everything and it's perfectly fine to do so, right now we want to smoothly transition to other important thing in your game. Your class/object might exist apart from node system and you can use it outside of the node system. For example, you can store your class/object in a GameInstance
.
If you used Unreal Engine the
GameInstance
class works similarly (but not exactly) to how Unreal Engine'sUGameInstance
class works.
In order to start your game you create a Window
, a window creates GameManager
- the heart of your game, the most important manager of your game, but generally you don't interact with GameManager
directly since it's located in the src/engine_lib/private
directory, the GameManager
creates essential game objects such as renderer, physics engine, audio manager and your GameInstance
. While the window that your have created is not closed the GameInstance
lives and your game runs. Once the user closes your window or your game code submits a "close window" command the GameManager
starts to destroy all created systems: World
(which despawns and destroys all nodes), GameInstance
, renderer and other things.
When GameInstance
is created and everything is setup for the game to start GameInstance::onGameStarted
is called. In this function you generally create/load a game world and spawn some nodes. Generally there is no need to store pointers to nodes in your GameInstance
since nodes are self-contained and each node knows what to do except that nodes sometimes ask GameInstance
for various global information by using the static function Node::getGameInstance
.
So "global" (world independent) classes/objects are generally stored in GameInstance
but sometimes they can be represented by a node.
Have you used other game engines? Generally game engines have an editor application that allows placing your custom objects on the map and construct levels. The general process goes like this: you create a new class in the code, compile the code, open up the editor and the editor is able to see you newly created class and allows you to place objects of the class on the level/map. The ability to see/analyze your code is what reflection is about.
Generally reflection comes down to writing additional code to your class, this additional code allows other systems to see/analyze your class/objects (see functions/fields, even private).
Reflection helps us save time and ease up some processes. Let's consider an example: you want to have a save file for your game where you store player's progress. For this you can use a ConfigManager
and save your variables like this:
but each time you need to save a new variable in the save file you need to append new code to save/load functions. With reflection you don't need to care about this. Here is the same example as above but it uses reflection:
now when you add a new variable you just need to mark it as RPROPERTY(Serialize)
and that's it, it will be saved and loaded from save file on the disk. Thanks to reflection the engine is able to see fields marked as RPROPERTY(Serialize)
and when you ask the engine to serialize an object of PlayerConfig
type it will save all RPROPERTY(Serialize)
fields to the save file.
We will talk about serialization (using various ways) in more details with more detailed examples in one of the next sections.
Note that reflection that we use relies on code generation, this means that when you create a class and add reflection specific code (include generated header, add RPROPERTY
and etc.) your IDE will mark them as errors since the generated header is not created yet and thus macros like RPROPERTY
are unknown. You need to ignore those errors, finish writing reflection specific code and compile your program. During the compilation, as one of the first build steps the reflection generator will analyze all source files in your project and see if some "generated" headers needs to be generated, then it generates them in src/*yourtarget*/.generated
directory and after the reflection generator your compiler comes in and compiles your program, it will find no issues since the generated headers now exist and all included macros are known. After the compilation your IDE should also start to see new generated headers and will hide previously shown errors.
Let's analyze the example from above:
#include "PlayerConfig.generated.h"
*yourheadername*.generated.h
needs to be included as the last #include
in your header, it contains reflection macros/code#include "*yourheadername*.generated_impl.h"
in your .cpp filenamespace mygame RNAMESPACE()
RNAMESPACE
is a reflection macro (R stands for Reflected) that is used to mark a namespace to be added to reflection databaseclass RCLASS(Guid("9ae433d9-2cba-497a-8061-26f2683b4f35")) PlayerConfig
RCLASS
is a reflection macro that is used to mark class to be added to reflection database, if you have a struct you need to use RSTRUCT
macroGuid
is a property of RCLASS
macro that is used to specify a unique GUID that the class should be associated with, for example when you serialize an object of this class into a file on the disk this GUID will be saved in the file to mark that PlayerConfig
class is saved here, you can generate a GUID by simply searching something like "generate GUID" on the Internet, any GUID will be fine but if some type already uses this GUID the engine will let you know at startup, in debug builds the engine checks for GUID uniquenessRPROPERTY(Serialize)
RPROPERTY
is a macro that marks a field to be added to reflection database as part of the class, you can have this macro defined without other properties but in this case we use a property named Serialize
- this property tells the engine that when an object of this class is being serialized the value of this field should also be serialized, additionally when you deserialize a file that value will be deserialized into this fieldmygame_PlayerConfig_GENERATED
*namespace*_*typename*_GENERATED
should be specified in the end of your type for reflection to work correctly, if you use nested types/namespaces this macro name will contain them all: *outerouter*_*outerinner*_*typename*_GENERATED
File_PlayerConfig_GENERATED
File_*headername*_GENERATED
should be specfiied in the end of your header file for reflection to work correctlyNote that in order to use reflection you don't need to derive from Serializable
in this example we derive from Serializable
for serialization/deserialization functionality. Also note that Node
class derives from Serializable
, this means that all Node
and derived classes have serialization/deserialization functionality.
For more information about reflection generator see our reflection generator fork: https://github.com/Flone-dnb/Refureku
Documentation for the original reflection generator: https://github.com/jsoysouvanh/Refureku/wiki
Let's sum up what you need to do in order to use reflection:
*filename*.generated.h
as the last include in your .h file (where filename is file name without extension).*filename*.generated_impl.h
as the last include in your .cpp file (where filename is file name without extension).RNAMESPACE()
macro, for example: namespace mygame RNAMESPACE()
.RCLASS
/RSTRUCT
, for example: class RCLASS() PlayerConfig
.*namespace*_*typename*_GENERATED
or just *typename*_GENERATED
if you don't have a namespace, if you use nested types/namespaces this macro name will contain them all: *outerouter*_*outerinner*_*typename*_GENERATED
, for example: mygame_PlayerConfig_GENERATED
.File_*filename* _GENERATED
(where filename is file name without extension), for example: File_PlayerConfig_GENERATED
.After this you can use reflection macros like RPROPERTY
/RFUNCTION
. We will talk about reflection macros/properties in more details in one of the next sections.
Note
Steps from above describe basic reflection usage and generally this will not be enough for engine-related things such as serialization and some editor features. Other sections of this manual will describe additional steps that you need to apply to your type in order to use various engine-related features that use reflection.
For more examples see src/engine_tests/io/ReflectionTest.h
.
If you changed something in your header file and your IDE now shows errors in GENERATED
macros just compile your project, it will run reflection generator and update reflection code, then the errors should disappear.
If you created/changes something in your header file related to reflection (renamed reflection macro, changed class name, etc.) and your compiler gives you and error about reflection files/macros, look at the build log and make sure that the reflection generator was actually run. Some IDEs like JetBrains Rider (maybe fixed) have an issue where they don't run pre-build steps of CMake targets (Qt Creator and Visual Studio 2022 were tested and they run reflection generator correctly). It's essential that reflection generator is run before the compiler, see the build log, when reflection generator is running you will see something like this in the build log:
If you get a compilation error like ‘Cannot open include file: ’....generated.h': No such file or directorythis might either mean that the reflection generator was not run (see solution from the above) or that your CMake target does not have reflection generator enabled. Open your
CMakeLists.txtfile and make sure it has
add_refurekucommand if there is no such command then reflection generator is not enabled and you might want to look at
CMakeLists.txt` of other targets to see how they use this command (not all CMake targets use reflection generator, generally only library targets use it).
If you added the reflection code but your project fails to compile at the linking stage make sure your .cpp
file includes *filename*.generated_impl.h
as its last include file.
In some rare cases you need to manually delete generated reflection files. For example if you made a typo and wrote RPROPERTY(Serializable)
(while the correct macro/property is RPROPERTY(Serialize)
), started compiling your project, the reflection generator run but there is a compilation error related to the reflection code. In this case even if you rename your macro/property to be correct you still might not be able to compile your project, if this is the case, go to the directory with generated code at src/*yourtarget*/.generated
find a generated file named after your header file and delete 2 files *yourfile*.generated.h
and *yourfile*.generated_impl.h
(where yourfile is the file in which you had a mistake) then try to compile your project, it should succeed.
You can delete .generated
directory but before building your project you need to re-run CMake configuration so that CMake will create a new .generated
directory with a fresh new config files inside of it.
The engine uses the Error
class (misc/Error.h
) to handle and propagate errors. Some engine functions return it and you should know how to use them.
The Error
class stores 2 things:
This class has some handy constructors, for example:
Error
object from a message string, it will be used as "initial error message",Error
object from a Windows HRESULT
object, in such constructor the Error
class will convert it to an error description, append the error code of the HRESULT
object and use it as "initial error message",Error
object from an error code returned by Windows GetLastError
function, just like with HRESULT
, it will be converted to an error description.When you construct an Error
object it will use std::source_location
to capture the name of the source file it was constructed from, plus a line where this Error
object was constructed.
After constructing an Error
object you can use Error::addEntry
to capture the name of the source file this function is called from, plus a line where this function was called from.
To get the string that contains the initial error message and all captured source "entries" you can use Error::getFullErrorMessage
.
To show a message box with the full error message and additionally print it to the log you can use Error::showError
.
Let's see how this works together in an example of window creation:
The engine has a simple Logger
class (io/Logger.h
) that you can use to write to log files and to console (note that logging to console is disabled in release builds).
On Windows log files are located at %localappdata%/nameless-engine/*yourtargetname*/logs
. On Linux log files are located at ~/.config/nameless-engine/*yourtargetname*/logs
.
Here is an example of Logger
usage:
You can also combine std::format
with logger:
Then your log file might look like this:
As you can see each log entry has a file name and a line number included.
Looking at your logs and finding if there were any warnings/errors might be tiresome if you have a big game with lots of systems (even if you just use Ctrl+F), to instantly identify if your game had warnings/errors in the log when you close your game the last log entry will be summary of total warnings/errors logged (if there was any, if there was no warnings/errors nothing will be logged in the end), it might look like this:
You will see this message in the console and in the log files as the last message if there were any warnings and/or errors logged. So pay attention to the logs/console after closing your game.
In order for your game to be able to access files in the res
directory when you build your project the engine creates symlinks to the res
directory next to the build binaries of you game. This means that if you need to access some file from the res
directory, in your app you can just type "res/game/somepath"
. For release builds the engine will not create symlinks and will require you to copy your res
directory manually but we will talk about this in more details in one of the next sections.
Instead of hardcoding you paths like "res/game/somepath"
you should use ProjectPaths
(misc/ProjectPaths.h
). This class provides static functions to access various paths, here are a few examples:
See misc/ProjectPaths.h
for more.
By default in Debug builds memory leak checks are enabled, look for the output/debugger output tab of your IDE after running your project. If any leaks occurred it should print about them. You can test whether the memory leak checks are enabled or not by doing something like this:
run your program that runs this code and after your program is finished you should see a message about the memory leak in the output/debugger output tab of your IDE.
Some engine functions return raw pointers. Generally, when the engine returns a raw pointer to you this means that you should not free/delete it and it is guaranteed to be valid for the (most) time of its usage. For more information read the documentation for the functions you are using.
The engine uses the following garbage collector: https://github.com/Flone-dnb/sgc. The main reason why we need a garbage collector is to resolve cyclic references. The game has a node hierarchy that can change dynamically, nodes can reference other nodes that exist in the world and cyclic references could occur easily.
The garbage collector library provides a smart pointer sgc::GcPtr<T>
that acts as a std::shared_ptr<T>
, the library also has sgc::GarbageCollector::get().collectGarbage()
function that is used to resolve cyclic references and free objects that are stuck in cyclic references. By default the engine runs garbage collection regularly so you don't have to care about it (and you don't need to call sgc::GarbageCollector::get().collectGarbage()
from your game code). Here is an example on how to use these sgc::GcPtr
objects:
And here is a cyclic reference example that will be resolved by the garbage collector:
Because the engine runs garbage collection regularly we want to minimize the amount of gc
pointers to minimize the amount of work that the garbage collector will do. If we would have used only gc
pointers and used them a lot, the garbage collection would probably cause stutters or freezes that players would not appreciate.
"When should I use `gc` pointers" you might ask? The answer is simple: start by thinking with std::unique_ptr
s, if you need more than just one owner think of std::shared_ptr
s for the part of the code you are planning/developing, if you know that cyclic reference can occur - use gc
pointers, otherwise - don't, because there would be no need for that. For example, we use gc
pointers in the node system because we know that nodes can reference other nodes and can dynamically change references so cyclic references could occur easily.
Of course, not everything will work magically. There is one or two cases where garbage collector can fail (leak memory), but don't worry, if this will happen the engine will let you know by logging an error message with possible reasons on why that happend so pay attention to the logs and always read the documentation for the functions you are using.
Probably the most common mistake that could cause the garbage collection to fail is related to containers, because it's the most common mistake that developers could make, let's see how to avoid it. Imagine you want to store an array of gc
pointers or store them in another container like std::unordered_map
, you could write something like this:
but due to garbage collector limitations this might cause memory leaks. Don't worry, when these leaks will happen the engine will log an error message with possible reasons on why that happend and possible solutions, so you will instantly know what to do. The right way to store gc
pointers in a container will be the following:
Not all STL containers have gc
alternative, see the garbage collector's repository for more information.
It's really important that you know how to properly use the GC objects.
Use the link to the garbage collector's repository (see above) and make sure to read the README
file (especially the limitations and thread safety sections).
Here's what you don't need to care about in your game's code:
sgc::GarbageCollector
because the engine runs garbage collector regularly and in some special situations.GameInstance
class has function to interact with the garbage collector, such as:
getGarbageCollectorRunIntervalInSec
to get the time interval after which the GC runs again.setGarbageCollectorRunInterval
to set the time interval after which the GC runs again.GameInstance
class for more details...Just note that you don't need to directly use sgc::GarbageCollector
(if you maybe saw this somewhere in examples), the engine will use it, you on the other hand should use GameInstance
functions for garbage collection.
The engine has 2 kind tasks you might be interested in:
deferred task
is a function/lambda that is executed on the main thread,thread pool task
is a function/lambda that is executed inside of a thread pool (non-main thread), you can submit a function/lambda to a thread pool to run asynchronous logicThe engine guarantees that:
GameInstance
is destroyed so you don't need to check whether the game is closing or not in your deferred/thread pool tasksNode*
) and use them in deferred tasks without risking to hit deleted memorySubmitting a deferred task from a non-main thread
where in deferred task you operate on a gc
controlled object such as Node can be dangerous as you may operate on a deleted (freed) memory. The engine roughly speaking does the following:
and if you are submitting a deferred task from a non-main thread you might get into the following situation:
In this case use additional checks in the beginning of your deferred task to check if the node you want to use is still valid:
The engine uses a left handed coordinate system. +X is world "forward" direction, +Y is world "right" direction and +Z is world "up" direction. These directions are stored in Globals::WorldDirection
(misc/Globals.h
).
Rotations are applied in the following order: ZYX, so "yaw" is applied first, then "pitch" and then "roll". If you need to do manual math with rotations you can use MathHelpers::buildRotationMatrix
that builds a rotation matrix with correct rotation order.
1 world unit is expected to be equal to 1 meter in your game.
Let's first make sure you know how to create a window, your main.cpp
should generally look like this:
And your game instance would generally look like this:
Now let's see how we can create a world in onGameStarted
:
The code from above creates a new world with just 2 nodes: a root node and a mesh node. As you can see you specify a callback function that will be called once the world is created.
Unfortunatelly you would also need a camera to see your world but we will discuss this in the next section, for now let's talk about world creation.
If you instead want to load some level/map as your new world you need to use loadNodeTreeAsWorld
instead of createWorld
, see an example:
Light source nodes (such as point/spot/directional) are stored in game/node/light
. Just spawn one of these nodes and configure their setting using setLight...
functions and setWorldLocation
/setWorldRotation
. Here is an example:
Using EnvironmentNode
you can configure environment settings such as ambient light, skybox, etc. Here is an example:
Note that your world can only have 1 active environment node. If you will spawn another environment node you will get a warning in the logs that will tell you that settings of your newly spawned environment node will be ignored.
In this section we will start with the most simplest character node: a node that has a camera and can fly around using WASD and mouse movement.
Let's create new files FlyingCharacter.h
and FlyingCharacter.cpp
inside of our new directory named private/node
to our library target (we pick our library target since generally library targets will contain all functionality (so that it can be used in multiple executable targets such as tests
) while executable targets generally will only contain main.cpp
). Open CMakeLists.txt
of your library target and add new files to PROJECT_SOURCES
:
Now make sure CMake configuration was run, for example, Qt Creator runs CMake configuration after your make modifications to a CMakeLists.txt
file and save it.
Now let's create our character node. We have two choises: we can derive our flying character node from CameraNode
(it derives from SpatialNode
so it has a location/rotation/scale in 3D world) or derive from SpatialNode
and attach a CameraNode
to it. Since more complicated characters will require multiple nodes such as: collision node, mesh node, camera node that will be somehow attached we will derive from SpatialNode
and demonstrate how you can attach a CameraNode
.
We will start with the most basic node header file:
As you can see we created 2 constructors, engine's node classes have 2 constructors (1 without a name and 1 with a name) so we will also define 2 constructors just to be consistent with the engine (although you are not required to do this).
Now the .cpp file:
Engine's nodes do the same thing as we did here: their constructor without parameters is delegating constructor that calls constructor with a name and provides a default node name (and does nothing). The actual construction logic happens in the constructor that accepts a node name.
Now let's add reflection to our node so that the editor will be able to recognize our node and the engine will be able to save/load our node when we are using it as part of some node tree.
Note
FlyingCharacterNode_GENERATED
andFile_FlyingCharacter_GENERATED
are different, one defines aNode
class and the other defines a file name.
We followed instructions from one of the previous sections in the manual where we discussed reflection. Also don't forget to update our .cpp
file:
As we said earlier your IDE might highlight new code as errors since the reflection headers were not generated yet. It's compile our project so that reflection headers will be generated.
Node
If your compilation fails at ‘Cannot open include file: 'FlyingCharacter.generated.h’: No such file or directory` this might indicate that either the reflection generator was not run or your CMake target does not have reflection generator enabled - see one of the previous sections about reflection for possible solutions.
Now before moving to handling user input let's add a camera node. There are several ways on how we can do this and we will discuss various ways in one of the following sections, for now let's just create a camera node in our character's constructor. Let's create a new field in our class to store a gc
pointer to the camera node:
Note
We also added forward declaration of the camera node type
class CameraNode;
.
Since nodes only use gc
pointers we use it instead of some other smart pointer types.
Now let's create a camera node in our constructor:
Now let's compile our program and make sure we have a world, in our GameInstance::onGameStarted
create an empty world:
If you would compile and run your project now you still wouldn't be able to see anything. This is because we need to explicitly make character's camera active.
The documentation for CameraNode::makeActive
says that "Only spawned camera nodes can be primary (active), otherwise an error will be shown." let's then override Node::onSpawning
in our character. Add the following code to our character's header file:
The documentation for Node::onSpawning
says "This node will be marked as spawned before this function is called." this means that when Node::onSpawning
is called our node is already considered as spawned. Also the documentation says "If overriding you must call the parent's version of this function first (before executing your login) to execute parent's logic." so let's implement this function. In the .cpp
file of our character add the following code:
If you would compile and run your project now you will get an error saying "camera node "Player Camera" needs to be spawned in order to make it the active camera". If we would look at the documentation for Node::onSpawning
again, we will see that it also says "This function is called before any of the child nodes are spawned. If you need to do some logic after child nodes are spawned use @ref onChildNodesSpawned." and since the camera node is a child node of our character when character node is spawning its child nodes are still waiting to be spawned. Thus let's use onChildNodesSpawned
instead.
Replace onSpawning
function with onChildNodesSpawned
:
Now compile and run your project. There should be no errors but still black screen. This is because we forgot about the lighting. Let's create the Sun for our world. In our game instance add code to spawn a directional light:
You can also add other types of light sources or configure ambient lighting using EnvironmentNode
but we will stick with the minimum for now.
If you run your project now you should see a cube. Now close your project, look at your console/logs and make sure there are no warnings/errors reported in the end.
We can now continue and work on handling user input to allow our character to fly.
The usual way to handle user input is by binding to action/axis events and doing your processing once these events are triggered.
Each input event (action/axis event) is a pair:
unsigned int
, for example 0, 1, 2, ...)W
and ArrowUp
) that trigger the eventunsigned int
, for example 0, 1, 2, ...)W
and S
, ArrowUp
and ArrowDown
) that trigger that event and define +1.0 and -1.0 states of the axis eventAction events are used for input that can only have 2 states: pressed and not pressed (for example a jump action), and axis events are used for input that can have a "smooth"/floating state (from -1.0 to +1.0, think about gamepad thumbsticks or W
/S
button combination for moving forward/backward).
Action events are a perfect case for, say, Jump or Fire/Shoot event since those can only have 2 states: on and off. So when you use an action event your input callback will receive input as a bool
value (pressed/not pressed).
Axis events can be used for gamepad input and to replace 2 keyboard action events. You can have 2 action events: "moveForward" on W
and "moveBackward" on S
or just 1 axis event "moveForward" with W
as +1.0 input and S
as -1.0 input. So when you use an axis event your input callback will receive input as a float
value (from +1.0 to -1.0).
Let's consider 2 examples for axis events:
Note
A so-called "repeat" input is disabled in the engine. "Repeat" input happens when use hold some button, while you hold it the window keeps receiving "repeat" input events with this button but the engine will ignore them and so if you will hold a button only pressed/released events will be received by your code.
Note
We don't have gamepad support yet.
Mouse movement is handled using GameInstance::onMouseMove
function or Node::onMouseMove
function. There are other mouse related functions like onMouseScrollMove
that you might find useful.
In the next sections you will learn that you can bind to input events in game instance and in nodes. As it was said earlier input events use IDs to be distinguished. This means that you need to have an application-global collection of unique IDs for input events.
Prefer to describe input event IDs of your application in a separate file using enums, for example:
This file will store all input IDs that your game needs, even if you have switchable controls like "walking" or "in vehice" all input event IDs should be generally stored like that to make sure they all have unique IDs because all your input events will be registered in the same input manager.
Note
We will talk about switchable controls like "walking" or "in vehice" and how to handle them in one of the next sections.
Since input events are identified using unique IDs we should create a special struct for our input IDs:
Let's see how we can bind to input events in our GameInstance
class.
As you can see we are using game instance constructor and we don't create a separate function to "add" our input events. This is done intentionally because once input events were added you should not add the same input events again, thus no function - we don't want anybody to accidentally add input events again.
At this point you will be able to trigger registered action/axis events by pressing the specified keyboard buttons.
Note
Although you can process input events in
GameInstance
it's not really recommended because input events should generally be processed in nodes such as in your character node so that your nodes will be self-contained and modular.
Note
You can register/bind input events even when world does not exist or not created yet.
Note
You can bind to input events before registering input events - this is perfectly fine.
This is the most common use case for input events. The usual workflow goes like this:
GameInstance
constructor.Let's define 2 axis events in our game instance: one for moving right/left and one for moving forward/backward:
Now let's register and bind to those events:
In our node create 2 new private fields and one new function that we will use:
Now in our node's constructor let's bind to input events. We don't need to care about register/bind order because you can bind to input events before registering input events - this is perfectly fine.
As you can see we have decided to apply input movement not instantly but once a frame. The motivation for this is that we now have timeSincePrevFrameInSec
(also known as deltatime - time in seconds that has passed since the last frame was rendered) to eliminate a speed up that occurs on diagonal movement (length of the vector becomes ~1.41 on diagonal movement while we expect the length of the vector to be in the range [0.0; 1.0]).
In addition to this it may happen that a button is pressed/released multiple times during one frame (this is even more likely when use have a gamepad and use thumbsticks for movement) which will cause our input callbacks to be triggered multiple times during one frame, so if we process the movement instantly this might affect the performance. Note that movement input is special because we can process it like that but it does not mean that all other input should be handled like this, for example an action to Fire/Shoot should be processed instantly or an action Open/Interact should also be processed instantly, in addition "look" or "rotation" mouse events should also be processed instantly.
Compile and run your project now, you can try modifying movementSpeed
variable to fit your needs or even make it non-constant if you want.
Let's now make our character to look/rotate using mouse movement. Add a new protected function and a private field in your node:
And implementation:
Note
Make sure to use relative rotation and not world rotation.
And let's hide the cursor.
Compile and run. You should be able to rotate our character using mouse input.
As the final step let's finish by implementing "closeApp" action event, this one is very simple:
If you read the documentation for Node::setIsReceivingInput
you would know that we can change whether we receive input or not while spawned. This can be handy if your game has (for example) a car that your character can drive, once your character enters a car you can use PlayerNode::setIsReceivingInput(false)
and CarNode::setIsReceivingInput(true)
and when the character leaves a car do the opposite CarNode::setIsReceivingInput(false)
and PlayerNode::setIsReceivingInput(true)
. Although if your character can still use some of his controls while driving a car you might want to implement some states like bool bIsDrivingCar
and in some input callbacks in PlayerNode
check this variable to decide whether an action should be executed or not.
This will be enough for now. Later we will talk about using InputManager
in a slightly better way and also talk about saving/loading inputs.
Let's consider an example where you need to trigger some logic after some time in your GameInstance
:
As the documentation for Timer::setCallbackForTimeout
says your callback will be called on the main thread so can safely use all engine functions from it. Moreover, the engine does some additional work for you and guarantees that this callback will never be called on deleted GameInstance
(see function documentation for more details).
We store a Timer* pTimer = nullptr
in the header file of our game instance to reuse that timer. There's currently no removeTimer
function (and may even never be) so it might be a good idea to reuse your timers instead of creating new timers again and again. Returned raw pointer Timer*
is guaranteed to be valid while the game instance is valid.
Using timer callbacks in game instance is pretty safe and you generally shouldn't worry about anything. And as always if you have any questions you might look at the documentation of the timer class and at the timer tests at src/engine_tests/game/GameInstance.cpp
.
Same timer functions are used in nodes:
Some things about timers in nodes:
Timer::start
ed while the node is spawned any attempt to start a timer while the node is despawned (or not spawned yet) will result in an error being logged.Node::onDespawning
.Again, we store a Timer* pTimer = nullptr
in the header file of our node to reuse that timer. There's currently no removeTimer
function (and may even never be) so it might be a good idea to reuse your timers instead of creating new timers again and again. Returned raw pointer Timer*
is guaranteed to be valid while the node is valid.
As with game instance using timer callbacks in nodes is pretty safe and you generally shouldn't worry about anything. And as always if you have any questions you might look at the documentation of the timer class and at the timer tests at src/engine_tests/misc/Timer.cpp
.
In your game you would most likelly want to use callbacks to trigger custom logic in nodes when some event happens, for example when your character's collision hits/overlaps with something you might want the collision node to notify the character node to do some custom logic.
When you think of callbacks you might think of std::function
but the problem is that std::function
has no guarantees if the node is spawned or not, or if it was deleted (freed). In order to avoid issues like that the engine provides NodeFunction
- it's an std::function
wrapper used for Node
functions/lambdas with an additional check (compared to the usual std::function
): once the callback is called NodeFunction
will first safely check if the node, the callback function points to, is still spawned or not, and if not spawned then the callback function will not be called to avoid running functions on despawned nodes or hitting deleted memory.
That "safely check" means that the engine does not use your node for the check because the node might be deleted and calling any functions on it might lead to undefined behaviour.
So when you need callbacks for triggering custom logic in Node
s it's highly recommended to use the NodeFunction
instead of the std::function
.
Additionally, when you use asynchronous requests (for example when you make asynchronous requests to remote backend server) for triggering some Node
callbacks to process asynchronous results you should also use NodeFunction
. This would protect you from running into despawned/deleted nodes if the asynchronous result was received unexpectedly late (for example) so you don't need to check if the node is still valid or not. Your code then will look like this:
Every game has custom game events, in this engine some nodes can subscribe to a specific publisher object, this way subscriber nodes can be notified when a specific event happens. For example, if your character node wants to know when its collision hits something we can use the publisher-subscriber pattern. When the publisher object decides that the event has happend it notifies all subscriber nodes and calls NodeFunction
callbacks of all subscribed nodes.
In the engine the publisher-subscriber pattern is implemented by the NodeNotificationBroadcaster
class. Node
class already has a built-in functionality to use these broadcasters. You can use Node::createNotificationBroadcaster
to create such broadcasters, allow other nodes to subscribe by specifying their NodeFunction
callbacks and notify subscribers by calling the broadcast
method. Here is a simplified example:
Note that you don't need to unsubscribe when your subscribed node is being despawned/destroyed as this is done automatically. Each broadcast
call removes callbacks of despawned nodes.
Note
Because
broadcast
call simply loops over all registered callbacks extra care should be taken when you subscribe to pair events such as "on started overlapping" / "on finished overlapping" because the order of these events may be incorrect in some special situations. When subscribing to such "pair" events it's recommended to add checks in the beginning of your callbacks that make sure the order is correct (as expected), otherwise show an error message so that you will instantly notice that and fix it.
InputManager
allows creating input events and axis events, i.e. allows binding names with multiple input keys. When working with input the workflow for creating, modifying, saving and loading inputs goes like this:
As it was shown InputManager
can be acquired using GameInstance::getInputManager()
, so both game instance and nodes (using getGameInstance()->getInputManager()
) can work with the input manager.
Graphics settings are configured using the RenderSettings
class. In order to get render settings from a game instance you need to use the following approach:
When use change something in RenderSettings
(for example render resolution) that change is instantly saved on the disk in the renderer config so you don't need to save them manually, on the next startup last applied settings will be restored.
You can find renderer's config file at:
%localappdata%/nameless-engine/*yourtarget*/engine/render.toml
~/.config/nameless-engine/*yourtarget*/engine/render.toml
Note
You can change values in the specified config files to quicky change settings for testing purposes. Note that changes made in the config files will only be applied locally (only for your computer).
Note that some render settings might not be supported depending on the OS/renderer/hardware. Let's consider another example, this one uses anti-aliasing:
As always if you forget something or pass unsupported value the engine will let you know by logging an error so pay attention to the logs/console.
There are 2 ways to save/load custom data:
ConfigManager
(io/ConfigManager.h
) - this approach is generally used when you have just 1-2 simple variables that you want to save or when you don't want to / can't create a type and use reflectionConfigManager
and it provides great flexibilityLet's start with the most commonly used approach.
One of the previous sections of the manual taught you how to use reflection for your types so at this point you should know how to add reflection to your type. Now we will talk about additional steps that you need to do in order to use serialization/deserialization. As you might guess we will use some C++ types (that store some user data) and serialize them to file.
Reflection serialization uses TOML
file format so if you don't know how this format looks like you can search it right now on the Internet. TOML
format generally looks like INI
format but with more features.
Let's consider an example that shows various serialization features and then explain some details:
Let's now describe what needs to be do in order to use serialization and deserialization in your reflected type:
Serializable
(io/Serializable.h
) in order to have serialize
/deserialize
and similar functions. Note that base Node
class already derives from Serializable
so you might already derive from this class.Serializable::onAfterDeserialized
to do post-deserialization logic (just make sure to read this function's docs first).virtual ~T() override = default;
.Guid
property to your type's RCLASS
/RSTRUCT
macro with a random GUID (just search something like "generate GUID" on the Internet), this GUID will be saved in the file so that when the engine reads the file it will know what type to use. In debug builds when the engine starts it checks uniqueness of all GUIDs so you shouldn't care about GUID uniqueness.Serialize
property on RPROPERTY
macro.RPROPERTY(Serialize)
that have primitive types have initialization values, for example: long long iCharacterLevel = 0;
otherwise you might serialize a "garbage value" (since primitive types don't have initialization unlike std::string
for example) and then after deserialization be suprised to see a character of level -123215315115
or something like that.And here are some notes about serialization/deserialization:
Serialize
field then everything (saving/loading) will work fine, the new field just needs to have initialization value for old save files that don't have this value (again, if it's a primitive type just initialize it otherwise you are fine).Serialize
field then everything (saving/loading) will work fine and if some old save file will have that old removed/renamed field it will be ignored.If you look in the file src/engine_lib/public/io/properties/SerializeProperty.h
you might find that Serialize
has a constructor that accepts FieldSerializationType
which is FST_WITH_OWNER
by default and as the documentation says it means that "Field is serialized in the same file as the owner object". There is also an option to use FST_AS_EXTERNAL_FILE
or FST_AS_EXTERNAL_BINARY_FILE
like so:
And again, as the documentation says it means that "Field is serialized in a separate file located next to the file of owner object. The only difference between these two is that `FST_AS_EXTERNAL_FILE` serializes into a `TOML` file and `FST_AS_EXTERNAL_BINARY_FILE` serializes into a binary file using special binary field serilaizers. Some fields from engine types are stored as external binary files in order to provide smaller file size and faster deserialization (although sacrificing readability of the file). Only fields of types that derive from `Serializable` can be marked with this type". Generally we use the default FST_WITH_OWNER
but there might be situations where you might want to use additional options.
There are various types that you can mark as Serialize
as you can see you can mark some primitive types, std::string
, std::vector
, std::unordered_map
and more, but note that when we talk about containers (such as std::vector
) their serialization depends on the contained type. Here is a small description of some types that you can use for serialization:
bool
int
unsigned int
long long
unsigned long long
float
double
std::string
T
(where T
is any type that derives from Serializable)See the directory src/engine_lib/public/io/serializers
for available field serializers (you don't need to use them directly, they will be automatically picked when you use serialize
function).
If you can't find some desired type in available field serializers don't worry, you can write your own field serializer (it's not that hard) to support new field types to be serialized/deserialized but we will talk about this later in one of the next sections.
As it was previously said our reflection serialization system uses TOML
file format so if you need even more flexibility you can serialize toml::value
structures directly. Under the hood we use https://github.com/ToruNiina/toml11 for working with TOML
files so if you want to serialize something very complicated in a very special way you can read the documentation for this library and serialize toml::value
s yourself or use a combination of raw toml::value
s and our serialization system by using various overloads of Serializable::serialize
that use toml::value
.
In the end let's consider one more (this time very simplified) example where we serialize multiple objects into one file:
Currently used version of the reflection library has some issues with multiple inheritance. If you are deriving from two classes one of which is Serializable
(or a class/struct that derives from it) and the other is not an interface class (contains fields) then you might want to derive from Serializable
first and then from that non-interface class. Example:
Just keep this in mind when using reflection for serialization/deserialization.
ConfigManager
(just like reflection serialization) uses TOML
file format so if you don't know how this format looks like you can search it right now on the Internet. TOML
format generally looks like INI
format but with more features.
Here is an example of how you can save and load data using ConfigManager
:
As you can see ConfigManager
is a very simple system for very simple tasks. Generally only primitive types and some STL types are supported, you can of course write a serializer for some STL type by using documentation from https://github.com/ToruNiina/toml11 as ConfigManager
uses this library under the hood.
ConfigManager
also has support for backup files and some other interesting features (see documentation for ConfigManager
).
In CMake we modify our CMakeLists.txt
to add external dependencies. This generally comes down to something like this:
Note
This manual expects that you know what the code from above does as it's a usual CMake usage.
Note
As you can see we mark external dependencies with
SYSTEM
so that clang-tidy (enabled in release builds) will not analyze source code of external dependencies.
As it was said earlier you might need to do one additional step: if your target uses reflection (has add_refureku
command) then you also need to tell the reflection generator about included headers of your external dependency:
If you compile your project after adding a new external dependency and the compilation fails with something like this:
this is because you forgot to tell the reflection generator about some directory of your external dependency.
Generally you would import meshes using the editor but we will show how to do it in C++.
Note
We only support import from GLTF/GLB format.
In order to import your file you need to use MeshImporter
like so:
If the import process went without errors you can then find your imported model in form of a node tree inside of the resulting directory. You can then deserialize that node tree and use it in your game using the following code:
Usually the only thing that you need to do is to untick the "+Y up" checkbox in "Transform" section (since we use +Z as our UP axis).
The most simple example of procedurally generated geometry is the following:
If you would look into how PrimitiveMeshGenerator::createCube
is implemented you would see that it just constructs a MeshData
by filling all positions, normals, UVs, etc. In the same way you can create a procedural mesh by constructing a MeshData
object. After we assigned a new mesh data to our mesh node we can set materials and shaders to it.
Each MeshNode
uses a default engine material (if other material is not specified) which means that if we have a MeshNode
it already has a material.
You can also assign a new material to your MeshNode
:
As you can see you can specify custom shaders when creating a new material (use will talk about custom shaders in another section).
Please note that each "Material" here should not be considered as some "big thing" that you need to reuse on multiple meshes - no, each material here is just a collection of small parameters such as diffuse color. You can think of the material here as "material properties" or a "material instance" if you want. If you have multiple materials that use the same shaders it's perfectly fine because under the hood the engine will not duplicate any loaded shaders or similar resources so these materials will just reference 1 set of shaders.
Note
This also means that you cannot just simply clone/duplicate/share a material between multiple meshes. Unfortunatelly, at the time of writing this, we can't just use
std::shared_ptr
for materials because it might create a false assumption when you have 2 meshes that reference a single material, serialize and deserialize them - would result in both meshes having a separate (unique) material and they will no longer reference a single material. Thus we usestd::unique_ptr
to avoid false assumptions. But generally there wouldn't be such a need for this. When you import some mesh in the engine (this information is covered in another section) it's imported as a node tree (an asset file) where you generally only have a mesh node, then when this asset (node tree) is used in some level it's added to your level as external node tree and if you have multiple assets that were taken from the same node tree it's enough to modify the material in the asset's node tree to then see changes in all assets on the level.
In order to enable transparency and use Material::setOpacity
function you need to either create a material with transparency enabled (see example from above) or enable transparency using Material::setEnableTransparency
:
Note
Transparent materials have very serious impact on the performance so you might want to avoid using them.
In order to use textures in your material you need to first import the textures you want to use. Most of the time you will import new textures through the editor using its GUI but we will show how to do it in C++:
In the example above after the image is imported the directory res/game/player/textures/diffuse
will have multiple files with DDS
and KTX
extensions. Both formats are special GPU image formats with compression and mipmaps (if you heard about them). The DDS
files are used by the DirectX renderer and the KTX
files are used by the Vulkan renderer.
Let's now see how we can use this texture in our material:
As you can see we specify a path to the directory with DDS
and KTX
files relative to our res
directory and we don't need to point to a specific file because the engine will automatically use the appropriate file according to the currently used renderer.
Note if a texture is requested it will be loaded from disk, then if some other part of the game needs this texture it won't be loaded from disk again, it will just be used from the memory and finally when all parts of your game finish using a specific texture so that it's no longer used the texture will be automatically released from the memory.
Mesh nodes can have multiple materials assigned to different parts of the mesh. Both MeshNode::getMaterial
and MeshNode::setMaterial
have a default argument iMaterialSlot = 0
. Each parts of the mesh that needs to have a separate material defines its own material slot, default cube only uses 1 material so it only has 1 material slot.
In order to query available material slots use MeshNode::getAvailableMaterialSlotCount
. In order to create more material slots you need to define mesh that has multiple "parts". Information about these "parts" is stored in MeshData
, here is an example:
Now let's split the cube in 2 material slots so that 1 special face of the cube will use one material and other faces will use other material:
Generally you won't need to directly modify mesh data or material slots as this will happen automatically when you import some mesh from a (for example) GLTF/GLB file but it's good if you know what they are and where there are created/stored.
If you want to know your game's FPS or other similar statistics you can use Renderer::getRenderStatistics
. For example, you can display the FPS on your game's UI for debugging purposes:
The engine has https://github.com/Celtoys/Remotery integrated and you can use this profiler in order to detect slow parts of your game.
By default profiler is disabled in order to enable it you need to create a file at *project_root*/src/engine_settings.cmake
and add the following variable to it:
Then you need to re-run cmake configuration and if everything is correct during the configuration you might see a message like adding external dependency "Remotery"
. Note that when ENABLE_PROFILER
is set profiler will be enabled only in debug builds.
Compile and run your project, during initialization you should see a message profiler enabled
in the log.
Here are a few examples on how to use profiler:
You can use these macros interchangeably.
After you add profiling macros you need to run your app and open *project_root*/ext/Remotery/vis/index.html
in your browser. When your app is running with profiler enabled you will see time measurements for profiled functions.
Note
If you don't see any time measurements you might need to refresh the page, then wait 5-10 seconds and try again if nothing shows up.
In the browser page near the text "Main Thread" (in "Sample Timeline" panel) you can click on buttons "+" and "-" to show/hide hierarchy (inner time measurements). You can also click on "Pause" button in the top-right corner to pause receiving of the new data. You can also expand a panel named "Main Thread" (usually in the bottom-right corner) to view hierarchy of calls you selected in "Sample Timeline" and their time measurements.
Note
It's recommended to use profiler for a short amount of time to identify slow parts of your code because the profiler has proved to cause freezes at startup and sometimes memory leaks.
Your game has a ..._tests
target for automated testing (which relies on https://github.com/catchorg/Catch2) and generally it will be very useful to simulate user input. Here is an example on how to do that:
Once such function is called it will trigger register input bindings in your game instance and nodes.
There are also other on...
function in Window
that you might find handy in simulating user input.
For more examples see src/engine_tests/game/node/Node.cpp
, there are some tests that simulate user input.
If you want to distribute a version of your game you need to switch to the release
build mode in your IDE to switch CMake to release
build configuration (make sure CMake is now using a release
configuration). Then build your project as usual, it will take much longer since we have clang-tidy
enabled for release
builds. clang-tidy
can fail the build if it finds some issues in your code. It's expected that you build your game in release
mode regularly to fix clang-tidy
warnings/errors (if there are any).
Once your project is built in release
mode go to the directory where the root CMakeLists.txt
file is located and directories like ext
and res
. Depending on your setup you need to open the directory where built binaries are located, this is typically build
directory (or it may be called differently, for example out
). Inside of this directory you will see a directory named OUTPUT
- this is where all built CMake targets are located. Inside of the OUTPUT
directory you should find a directory named after your game's target. Inside of that directory will be located the final executable (or it will be located in the directory named Release
). If you would try to run that executable you will get an error saying that the res
directory is not found. This is expected as there are some additional steps that you need to do.
After you have your built executable you might notice a file named something like COPY_UPDATED_RES_DIRECTORY_HERE
next to your game's executable. This is a "reminder" file that you need to manually copy the res
directory from the root of your project directory next to the executable. Note that you need to make an actual copy, don't make symlinks and don't cut-paste it, you need to make a copy. This copy is a snapshot of the game's resources for this specific game version. After you've copied the res
directory you can remove the "reminder" file named COPY_UPDATED_RES_DIRECTORY_HERE
. At this point if you run the executable your game should start. The only thing left to do is to remove non-game related files from this directory.
In order to remove non-game related files next to the built executable there will be a directory named delete_nongame_files
, open it and you will find a Go script in it. Read the README.md
file next to the script file and run the script. It will interactively ask you if you want to delete non-game related files and etc. Once the script is finished it will tell you that you can delete the directory with this script as it's no longer needed. You should now try starting your game using the built executable to see if everything works after all non-game related files were deleted.
As you might have noticed next to the built executable of your game there is an ext
directory. This directory contains license files of all external dependencies that you have in your project's root ext
directory, plus there is a copy of engine's license file. You are required to distribute this directory as part of your build - do not delete this directory. You don't need to list these licenses in your EULA or somewhere else, you just need to distribute them as part of your build - nothing more.
That's it! Your game is ready to be distributed. You can archive the directory with the built executable and send it to a friend or upload it on the Internet.
At the time of writing this there is no compression/encryption of the game's resources. All game's resources are distributed as-is.
This part of the manual groups sections that you might want to re-read regularly in the future.
Prefer to start your custom nodes like this:
If you override some virtual
function in node it's very likely (read the documentation for the functions you are overriding) that you need to call the parent's version:
Don't forget about Node::getChildNodeOfType
and Node::getParentNodeOfType
as they might be useful.
Remember that World
is inaccessable when the node is not spawned and thus Node::getWorldRootNode
is nullptr
.
If you have a character node with some child nodes and you want the player to explore the world by walking on his feet or by riding a car you can use pCarNode->addChildNode(pCharacterNode)
to attach your already spawned player (which is attached to the world's root node) to the car node when the player gets in the car or detach it from the car by using something like getWorldRootNode()->addChildNode(pCharacterNode)
to make it attached to the world's root node again. By using addChildNode
you can not only add child nodes but also attach and detach existing ones even if they already have a parent.
The order in which Node::onBeforeNewFrame
are called on nodes is kind of random. If you need a specific node's onBeforeNewFrame
to be called before onBeforeNewFrame
of some other node consider using Node::setTickGroup
. For example if your game have a functionality to focus the camera on some world entity you might want to put the "focusing" logic in the later tick group to make sure that all world entities processed their movement before you rotate (focus) the camera.
This section contains a list of important things that you need to regularly check while developing a game to minimize the amount of bugs/crashes in your game. All information listed below is documented in the manual and in the engine code (just duplicating the most important things here, see more details in other sections of the manual or in the engine code documentation).
[Refureku] WARNING: Double registration detected
which are not captured by our logging system, these might occur in very special cases, report these if foundgc
pointers in std::function
gc
pointers in STL containers, store gc
pointers only in "gc containers"gc
pointers on types that use multiple inheritance is not supported and will cause exceptions, leaks and crashesgc
pointers), make sure to derive from the Serializable
class (or derived, for ex. Node
) first and only then from other non Serializable
classes (order matters, otherwise garbage data will be serialized instead of the actual data)RPROPERTY(Serialize)
fields, for example: otherwise you might serialize a garbage value and then deserialize it as a garbage value which might cause unexpected reactiongetWorldRootNode
for nullptr
before using it, nullptr
here typically means that the node is not spawned or the world is being destroyedNode
functions)NodeFunction
instead of std::function
when you need callbacks in nodesNodeNotificationBroadcaster
when you need publisher-subscriber patternnullptr
s (or deleted memory when on a non-main thread, use getSpawnDespawnMutex()
mutex for non-main thread functions)Node
member functions in async task make sure the task is finished in your Node::onDespawning,GameInstance
member functions in async task you only might care about world being changed, for this use promise/future or something else to guarantee that the callback won't be called on a deleted objectNodeFunction
instead of std::function
when you need to process asynchronous results in nodesgc
controlled object such as Node can be dangerous as you may operate on a deleted (freed) memory, in this case use additional checks in the beginning of your deferred task to check if the node you want to use is still valid: This section expects that you have knowledge in writing programs in HLSL and/or GLSL.
Note: currently we are looking for a solution that will make writing custom shaders easier but right now writing custom shaders is not that simple:
Right now if you want to go beyond what Material provides to you and achieve some special look of your meshes you would have to write shaders in both HLSL and GLSL if you want your game to support both DirectX and Vulkan renderers that we have because each graphics API (like DirectX or Vulkan) has its own shading language. If you know that you don't want Vulkan support and don't care about Linux and other non-Windows platforms then you might just write a shader in HLSL and ignore GLSL, this would mean that any attempt to run your game using Vulkan renderer will fail with an error.
Similar to clang-format
we use a special formatter for shaders: https://github.com/Flone-dnb/vscode-shader-formatter
Make sure you use it when writing shaders.
We will talk about creating a custom pixel/fragment shader but the same idea applies to creating custom vertex shaders. Here are the steps to create a new custom shader:
res
directory, for example: res/game/shaders/hlsl/CustomMeshNode.frag.hlsl
or in glsl
directory with .frag.glsl
extension if you want to create a GLSL shader.#include
an engine shader file that your shader "derives" from. For example if you want to create a custom shader for MeshNode
you need to include MeshNode.frag.hlsl
. For example: #include "../../../engine/shaders/hlsl/include/MeshNode.frag.hlsl"
.#include "../../../engine/shaders/glsl/include/MeshNode.frag.glsl"
.psMeshNode
or fsMeshNode
.psMeshNode
or fsMeshNode
, and pass any input parameters if your function has them.In order to compile your shader you need to use the ShaderManager
object, here is an example on how to do it using HLSL shaders:
For HLSL you would do the same thing (ShaderType::FRAGMENT_SHADER
is considered as "pixel shader" when compiling HLSL shaders).
You should not remove the code to compile your shaders (ShaderManager::compileShaders
) from your game. This code not only compiles the shaders but also adds them to the global "shader registry". If some shader was previously compiled then this means that the results of that compilation were cached and the next time you will call compileShaders
instead of compiling it again the results will be retrived from the cache. If you change your shader code or something else the cache might be automatically invalidated (inside ShaderManager::compileShaders
) and your shader will be automatically recompiled so if you do any changes in the shader file (or in any files that your shader includes) you just need to restart the game to see your changes.
Please note:
If you got an idea of displaying a splash screen using a separate
GameInstance
(before starting your game'sGameInstance
) in order to compile your shaders inside of that splash screen game instance it would be a bad idea becausecompileShaders
will be called twice (inside of your splash screen game instance and inside of your game's game instance) which means that even if no shader was changed the shader cache will be checked twice which might take some time if you have lots of shaders.
As you might have noticed in the res/engine/shaders/include
directory there are .glsl
shaders outside of the glsl
/hlsl
directory. These shaders contain code that can be used in both HLSL and GLSL. Before passing shader code to a shader compiler we parse the code from disk using a special but simple parser (see https://github.com/Flone-dnb/combined-shader-language-parser). It allows mixing HLSL and GLSL code. You can also use such functionality and have just one shader file instead of separate HLSL and GLSL files if you want.
Shader compilation for compute shaders is the same as from the previous section that described custom vertex/pixel/fragment shaders. What's different is how we interact with compute shaders.
After you have compiled your compute shader you need to create a special "interface" object to interact with your compute shader (specify input/output resources, dispatch it and etc.). Let's consider an example where you want to calculate some data (stored in a resource) that will be used during the rendering:
Here our shader will be run only once before the first frame is rendered, if you want to regularly run your shader you just need to resubmitForExecution
it in your onBeforeNewFrame
(for example). For more information, see function documentation.
If you looked in the engine shader files you might have noticed that some parts of the shader code are used only when a specific macro is defined (for example #ifdef PS_USE_DIFFUSE_TEXTURE
). This is how engine shaders do branching (mostly), so instead of doing an actual runtime if
the engine shader rely on predefined macros because runtime branching on GPUs can cause performance issues.
When you or the engine submits a shader to be compiled the engine creates a special object ShaderPack
. Then depending on the shader type (vertex/pixel/fragment/compute/etc) the engine retrieves a special collection of compatible macro combinations, for pixel/fragment shader these combinations may be:
For every combination of macros the engine compiles one shader variant with only specific macros defined and then stores all shader variants in the shader pack object. Shader pack is then saved on the disk (cached) to be used on the next startup (so that the engine will just read shader bytecode from disk instead of compiling the shaders again). You can see information about all compiled shaders and their variants if you look in the following directory:
In the shader cache directory you will find one directory per shader. Inside of the shader specific directory you will find multiple files but you should focus on the files with the .toml
extension. Each TOML file describes one shader variant and if you open that TOML file in your text editor you might learn some information about a shader (like which macros were defined and etc).
At runtime when, for example, some material is created it requests a pair of vertex and pixel/fragment shaders (it actually requests a graphics pipeline but we will omit this for simplicity). The engine then asks the renderer on which macros should be defined right now (depending on the current render settings) and plus the material also tells which macros it defines (for example PS_USE_MATERIAL_TRANSPARENCY
when transparency is enabled), then ShaderManager
looks for a shader pack for the specified shaders and returns a specific shader from the pack that corresponds with the requested macros. This is how a material receives its shader. If material changes its settings (like transparency) or something global (like render settings) is changed, if there is a shader macro that should be added/removed due to these changes, materials' shaders are being changed by getting another shader variant from the shader pack.
This is why you should not define some shader macros that are used in the engine shader files as they will be "defined" automatically when needed.
You can use your usual shader debugging software (PIX
, RenderDoc
, NVIDIA Nsight
, etc.) to debug your custom shaders. Just make sure your game is built in the Debug
mode.
Let's consider a simple example of passing a buffer from C++ into your custom shader which looks like this:
Note
If you want to use the same shader resource in both vertex and pixel/fragment shaders make sure that resource is using the same binding register/index in both shaders, when the engine finds a shader resource that was specified in both vertex and pixel/fragment shaders with the same name and the same binding register/index it will understand that it's the same resource and will avoid double-registration of the resource. It's recommended to move that resource's definition into a separate file and then include that file in your vertex and pixel/fragment shaders to guarantee that resource's binding register/index is the same. Engine shaders are using the same approach.
Then in C++:
As you can see we use alignas
to satisfy Vulkan aligning requirements and at the same time keep track of HLSL packing rules. If you only want to stick with some specific shading language (only GLSL or only HLSL) then you just need to keep track of your language specific packing rules.
Note that if you don't find a iVk...Alignment
variable matching your type's name this means that you should avoid using this type, this includes types such as vec3
and mat3
, instead use vec4
and mat4
so you will avoid a bunch of alignment/packing issues.
Generally if you specify alignas
to all fields (of a type that will be directly copied to the GPU) you should be pretty safe in terms of both Vulkan alignment requirements and HLSL packing rules.
In most cases there are only 2 things that you need to keep track of:
alignas
might introduce, for example:Note
If you are using Qt Creator IDE you can see field alignment (plus padding if there is one) by hovering your mouse cursor over a field's name, which is very useful for such cases.
In order to avoid this you might want to prefer to put "big types" (types with bigger alignment such as mat
s and vec
s) first and only then "small types" (such as float
s and etc). Otherwise you might have lots of unused padding bytes that might bloat your data.
StructuredBuffer
s:HLSL structured buffer will look like this:
If you store more than 1 element in this structured buffer your second element will be aligned incorrectly because in C++ you have that 12 bytes of padding at the end but HLSL StructuredBuffer
s are tightly-packed and there's no 12 bytes of padding in the end so your second element in the structured buffer will reference padding bytes in its color
field.
In order to avoid such issues just add explicit padding like float pad[3]
and vec3 pad
in C++ and HLSL.
Now let's tell the engine how to pass your buffer to shaders:
Then implement "updating" functions that will be automatically called by the engine when it needs to copy our data from RAM to VRAM:
Then in any place of your custom mesh node (even when it's not spawned yet) when you need to copy your data to shaders:
markShaderCpuWriteResourceToBeCopiedToGpu
will notify the engine if your node is spawned, otherwise it won't do anything so that your "update" functions will only be called while your node is spawned. After the engine was notified it will mark that resource as "needs update" and call your "update" functions before the next frame is submitted to be rendered (when the engine will be ready to update GPU resources).
If you assigned your custom shaders to the material of your CustomMeshNode
then we don't need to do anything else.
Similar approach is used for custom textures. First, define a shader:
Then assign a material that uses this pixel/fragment shader to your custom mesh node (use default vertex shader). Now, import a texture, use can use the editor or pure C++ for this step:
Once you have a texture in your res
directory you need to bind the file to the shader:
Now your mesh's pixel/fragment shader will have a custom texture binded.
When you use Serializable::serialize
function all fields marked with RPROPERTY(Serialize)
will be checked for serialization. Serialization for fields is achieved by implementing the IFieldSerializer
interface. Thanks to field serializers you can, for example, serialize fields that have primitive types (bool, int, long long, etc.) because there is a PrimitiveFieldSerializer
that implements IFieldSerializer
. Field serializers are located in the io/serializers
directory, you can look how they are implemented. Back to the beginning, when you use serialize
function the engine goes through each reflected field marked with Serialize
property and basically does this:
So if you want to add support for a new field type for serialization, you just need to implement IFieldSerializer
interface and register your serializer like so:
After this, when you use serialize
functions your serializer will be used.
In addition to the usual IFieldSerializer
serializers we also have IBinaryFieldSerializer
interface that works the same way but used for fields marked as FST_AS_EXTERNAL_BINARY_FILE
and serializes into a binary file for smaller file size and faster deserialization.
You can create custom reflection properties like Guid
or Serialize
that we currently have. All engine reflection properties are located at io/properties
so you can look at them to see an example.
You can find instructions for creating custom properties here: https://github.com/jsoysouvanh/Refureku/wiki/Create-custom-properties
Note:
Information described in this section might not be up to date, please notify the developers if something is not right / outdated.
Most of the game assets are stored in the human-readable TOML
format. This format is similar to INI
format but has more features. This means that you can use any text editor to view or edit your asset files if you need to.
When you serialize a serializable object (an object that derives from Serializable
) the general TOML structure will look like this (comments start with #):
Here is a more specific example:
Some Serializable::serialize
overloads allow you to specify Serializable* pOriginalObject
, when such object is specified each field of the object that is being serialized will be compared to this original object and only fields that were changed compared to the original object will be serialized. To keep the information about all other fields in this case we add an internal attribute like so:
This attribute points to a file located in the res
directory, specifically at res/test/custom_node.toml
. This all will work only if the original object was previously deserialized from a file located in the res
directory (see Serializable::getPathDeserializedFromRelativeToRes
).
If your node tree uses another (external) node tree that is located in the res
directory, this external node tree is saved in a special way, that is, only the root node of the external node tree is saved and information about all child nodes is stored as a path to the file that contains this node tree.
This means that when we reference an external node tree, only changes to external node tree's root node will be saved.