This article is focused on details of UNIGINE execution sequence. Here you will find what is happening under the hood of UNIGINE engine.
A high-level overview of UNIGINE workflow is provided in the Engine Architecture article.
The following diagrams show the order of execution of UNIGINE engine internal code and scripts of you project in the single-threaded and multi-threaded modes respectively. The main stages are:
UNIGINE Runtime Scripts
UNIGINE has three different runtime scripts that have different lifetime:
- World script. The world script takes effect only when the world is loaded. It is automatically created together with a new world, named after it (<world_name>.usc) and is stored in the same folder. You can put your logic inside the automatically created world script file (by using UnigineScript API only), or you can inherit WorldLogic class and implement your logic (C++ and C# APIs).
The world script *.usc file is automatically created with the new world and has the name of your project. When you create a new C++ / C# project, it has already inherited world logic class with implemented methods to put your logic code inside.
- System script. It exists during the application life cycle. Your custom logic can be put in the automatically created system script unigine.usc file (by using UnigineScript API only), or you can inherit SystemLogic class and implement your logic (C++ and C# APIs).
UnigineScript unigine.usc system script file is created automatically in the your project's folder. When you create a new C++ / C# project, it has already inherited system logic class with implemented methods to put your logic code inside.
- Editor script. This script takes effect only when the editor is loaded. You can put your logic inside the editor script file (by using UnigineScript API only), or you can inherit EditorLogic class and implement your logic (C++ and C# APIs).
When you create a new C++ / C# project, it has already inherited editor logic class with implemented methods to put your logic code inside.
Engine initialization starts when the main executable application (32-bit (x86) or 64-bit version of the .exe file, debug or release one) loads the corresponding UNIGINE library (.dll, .so or .dylib). From this point on, the following events take place:
- A UNIGINE memory allocator is initialized for faster and more optimal allocations if compared to the default system allocator. In the engine code, it is set via the USE_MEMORY directive.
- C++/C# API is initialized.
- Paths to an application directory (<UNIGINE SDK>/bin/) and to a user's home directory (if any) are set. On Windows, the home directory is C:/Users/<username>/. On Linux it is /home/<username>/.
- Command-line options are parsed, namely: options that set paths to the data folder, plugin directories, to the log and configuration files and also a project name.
- Four (4) threads are created: a sound, a world, a render and a file system thread.
A path where all application data will be saved by default:
- If you pass a project name via the command line or on engine initialization, all data (such as log files, cache and configuration files) will be stored in the user's home directory as follows:
- On Windows, in the C:/Users/<username>/<project_name>/ folder
- On Linux, in the /home/<username>/.<project_name>/ folder
- Otherwise, data will be stored in the application folder (<UNIGINE SDK>/bin/ with the binary executable file).
- If you pass a project name via the command line or on engine initialization, all data (such as log files, cache and configuration files) will be stored in the user's home directory as follows:
- A log file is created. If not specified in the command-line options, the default log file is placed in the application directory under the name log.html.
- Remaining command-line options are parsed (the ones, that weren't parsed previously). These options specify basic video settings, such as a graphics API to be used for rendering (DirectX or OpenGL; also you can disable the graphics API), size of the application window and so on.
- A path to the project *.cache files is set. Usually the cache files include compiled shaders, system and editor scripts.
- External plugin libraries specified in the -extern_plugin command line option and plugin libraries stored in the plugin directories specified in the -plugin_path option are loaded. A custom plugin library can be loaded at any moment of UNIGINE runtime.
- The file system is initialized. If project files are packed into UNG or ZIP archives protected by a password, the engine checks if the password matches with the password set for the binary file (if any) and loads these archives.
- The dedicated asset management subsystem of the Engine's file system is initialized and performs scanning for all runtime files.
- Render and sound managers that will automatically organize and handle rendering and sounds are initialized.
- The application window is created based on the specified settings.
- All engine subsystems (such as visualizer, physics, sound, render, pathfinding subsystems and so on) are created.
- Sound and file system threads are run.
- External defines passed in the -extern_define command line option are set.
- The init() functions of all the loaded plugins are called.
- Console commands from the command line are queued for further execution.
- The system script is loaded and started. Basically, the system script performs housekeeping necessary to start and keep the UNIGINE-based application going. It stays loaded during the whole UNIGINE runtime.
The default system script when initialized does the following:
You can take the default system script located in the data/core/unigine.usc and replace it with a custom one. In this script, you can set your own splash screen for the project, specify custom modules or material libraries to load and so on. For big projects it also makes sense to specify the world you want to load right in your system script.
- A localization file is loaded.
- The main menu is initialized.
- A splash screen is set, if necessary. It is better to set a splash screen before loading the world. In this case, displaying a splash screen will give the engine time to load resources and compile shaders. By default, a standard UNIGINE screen is shown.
OpenGL does not support shader caching, so they are re-compiled each time the application starts. DirectX, on the contrary, supports shader caching, so subsequent application start-ups on the same hardware will be much faster.
Shaders actually can be loaded any time during execution, but their compilation may take time.
- A standard property library is loaded.
If you use a custom system script, it will perform the logic you implemented.
- Console commands from the command line that were queued previously are run.
If you want to run console commands from the system script, they will be run after the queued console commands.
- If the render_manager_check_shaders
console variable is specified in the command line, shaders are compiled and cached. For example, if you load custom material libraries in the init() function of the system script, on this step, all these libraries can be compiled and you won't need to load them in run-time.
However, a shader cache file is large and its loading on engine initialization will drop performance.
After all these steps are taken, the engine enters the main loop.
When UNIGINE enters the main loop, all its actions can be divided in three stages, which are performed one after another in a cycle. They are:
In the performance profiler, the total time that the main loop took is displayed by the Total counter.
There are 2 modes for execution sequence of the main loop:
At most times, UNIGINE finishes all its stages in the main loop faster then the GPU can actually render the frame. That is why double buffering is used: it enables to render frames faster by swapping GPU buffers (the back and front ones) into which rendering is performed.
When all scripts have been updated and all calculations on the CPU have been completed, the GPU is still rendering the frame calculated on the CPU. So, the CPU has to wait until the GPU finishes rendering the frame and the rendering buffers are swapped. The period of such waiting time is represented by the Present counter in the performance profiler.
If the Present time is too high, it may signal that a GPU bottleneck exists, and art content needs to be optimized. But if by that the frame rate is consistently high, it means you still have the CPU resources available to crunch more numbers.
How much time has passed from the moment when all scripts have been updated and all calculations on the CPU have been completed, to the moment when the GPU has finished rendering the frame is also depends on whether the vertical synchronization (VSync) is enabled or not (can be done via the System menu). If it isenabled, the CPU waits when the GPU finishes rendering and the vertical synchronization is performed. In this case, the Present counter value will be higher.
The 4 schemes below demonstrate the following:
- The first 2 schemes show calculation and rendering of the frame when VSync is disabled (in both cases the monitor vertical retrace is ignored):
- On the 1st scheme, the CPU calculates the frame faster than the GPU can render it. So, the CPU waits for the GPU (the Present time is high).
- On the 2nd scheme, the CPU calculations are performed slower than the GPU renders the frame. So, the GPU has to wait while the CPU finishes its calculations. In this case, the Present time will be small.
- Schemes 3 and 4 show calculation and rendering of the frame when VSync is enabled (the monitor vertical retrace is taken into account):
- On the 3rd scheme, the CPU calculates the frame faster than the GPU can render it and the CPU waits for the GPU. However, in this case, both the CPU and the GPU also wait for VSync.
- On the 4th scheme, the CPU calculates the frame slower than the GPU renders it. In this case, the GPU waits not only the CPU finishes its calculations, but also VSync.
The update part includes the following:
- The FPS value starts to be calculated.
Calculation of FPS starts only with the second rendered frame, while the very first one is skipped.
- All pending console commands that were called during the previous frame are executed. The commands are executed in the beginning of the update() cycle, but before the scripts are updated, because otherwise they may violate the current process of rendering or physical calculations.
- If the video_restart console command was executed previously (not in the current update stage), world shaders are created. When video is restarted (for example, when the video mode is changed), the application window calls thedestroy() methods of the world, system, editor scripts and plugins. However, it is performed not in the current update stage but earlier.
- A plugin update() function is called. What happens during it solely depends on the content of this custom function.
- The editor script that handles all editor-related GUI and logic is updated.
- The system script is updated. The default system script update() function performs the following:
- The system script handles the mouse. It controls whether the mouse is grabbed when clicked (by default), the mouse cursor disappears when not moved for some time (set by the MOUSE_SOFT definition), or not handled by the system (set by the MOUSE_USER definition, which allows input handling by some custom module).
- Main menu logic is updated (if the MENU_USER definition is not set).
- Other system-related user input is handled. For example, if the world state should be saved or restored, or a screenshot of the current UNIGINE window contents is to be made.
- If the GPU Monitor plugin is initialized (theHAS_GPU_MONITOR definition is set), the plugin output is displayed in the application window and additional console commands become available.
- GUI and ambient sound sources are updated.
- If the world is loaded (it can be done from the console or via the system script), the world script gets updated. In the world script update() function you can code frame-by-frame behavior of your application (see details here).
The world and its world script are updated in the following order:Physics, if any, and non-rendering game logic can be implemented separately in a flush() function that is called with a fixed frame rate (while theupdate() function is called each frame).
- The world script update() function is executed: node parameters are updated, transformations for non-physical nodes are set and so on.
- State of nodes existing in the world is updated (mostly for visible nodes): skinned animation is played, particle systems spawn new particles and so on. Triggered world callbacks are added to a stack (they will be executed later).
- The world script render() function is called.
- The editor script render() function is called.
- The system script render() function is executed, if necessary (see details here). It can access the updated data on node states and correct the behavior accordingly in the same frame.
- The world spatial tree is updated.
- A plugin render() function is called, if it exists.
In the performance profiler, the total time of update stage is displayed by the Update counter.
As soon as the update stage is completed, UNIGINE can start rendering the world. In parallel, physics and game logic is calculated. This approach enables to effectively balance the load between CPU and GPU, and thus allows for higher framerate in the UNIGINE-based application.
In detail, here is how the render stage works in the single-threaded mode:
- UNIGINE renders the graphics scene (the world) and the sound scene, as they should be in the current frame. The graphics scene is sent to GPU, while the sound scene is sent to the sound card. As soon as the CPU finishes preparation of data and feeds rendering commands to the GPU, the GPU becomes busy with rendering the frame.
In the performance profiler, the total time of rendering stage is displayed by the Render counter. After that, CPU is free so we can load it with calculations we need.
- The physics module calls the plugin flush() function if it exists.
The physics module calls the flush() function of the world script to be executed. In the flush function you can:
The flush() function is not called each frame. The physics module has its own fixed frame rate, which does not depend on the rendering frame rate. Each of such physics frames (or ticks), a number of calculation iterations are performed (flush() is called before each iteration).
- The physics module is updated: internal physical simulation starts. During this step, Unigine performs collision detection for all objects that have physical bodies and collision shapes.
In the performance profiler, the total time of flush() together with physics simulation is displayed by the Physics counter.
- The pathfinding module is updated. In the thread performance profiler, the total time of pathfinding is displayed by the PathFind counter.
- Plugins' GUI (if any) is rendered.
- At last and atop of all GUI is rendered, if required. In the performance profiler, the total time of interface rendering is displayed by the Interface counter.
The swap stage is the last one in the main loop. It includes the following:
- If the video_grab console command is executed previously, on this stage, the taken screenshot will be saved in folder where allapplication data is stored.
- The plugin swap() function is called, if it exists.
- Synchronization of physics with the rendered world. Results of the physical calculations are applied to the world. That is, on the previous step we have calculated how physical bodies with collision shapes have changed their position and orientation (due to our flush-based logic or interaction). Now these transformations can be finally applied to nodes, i.e. rendered meshes.
As synchronization of physics follows the rendering stage, applied physical transformations will be visible on the screen only in the next frame.
- Values shown in the performance profiler are updated.
After the swap() is completed, the application window initiates GPU buffers swapping as described above.
The following scheme demonstrates calculation and rendering of a frame with VSync enabled:
In the multi-threaded mode, the execution pipeline is implemented differently at some points:
- In the update stage, the world script uses all available threads to update visible nodes, instead of updating them one by one. By that, UNIGINE analyzes node dependences, so these operations are fully thread-safe. The world_threaded command sets the mode: multiple-threaded or single mode. Child nodes under 1 parent are always updated in 1 thread. To benefit from the advantages of multi-threading, computationally heavy nodes (like particles systems) should have different or no parents.
As each Node Reference is handled as a root node without any parent, it is automatically optimized for multi-threading.
- Also at the end of the update stage, multi-threaded physics and pathfinding starts to be updated in their separate threads. Then they perform their tasks on all available threads in parallel to rendering. Use physics_threaded and pathfind_threaded commands to select the mode of the calculations. Nodes with computationally heavy bodies (like clothes and ropes) should not have one parent; otherwise, they will be updated in one thread.
- The swap stage in this scenario includes waiting for all additional threads to finish their tasks. After that, physics and pathfinding threads are synchronized and calculations are applied to the world.
The main loop iteration is over.
When UNIGINE stops execution of the application, it does the following:
- If the editor script is run, it calls its shutdown() function.
- The world script calls the shutdown() function.
- UNIGINE calls the shutdown() function of the system script. Here, for example, you can set a splash screen with credits to be shown when exiting the UNIGINE-based application.
- The plugin shutdown() function is called.
- If the user has tweaked any settings, the engine saves changes into the configuration file.
- World, sound and file system threads are terminated.
- All resources allocated for UNIGINE are freed.
- All paths set for the application are destroyed.
- C++/C# API is shut down.
- Memory allocator is shut down.
Correlation between Rendering and Physics Framerates
The rendering framerate usually varies, while, as we have already mentioned before, physics simulation framerate is fixed. This means that your update() and flush() functions from the world script are called with different frequency.
The picture above describes what happens, when the physics framerate is fixed to 60 FPS, and the rendering framerate varies. In general, there are three possible cases:
- The rendering framerate is much higher. In this case, physical calculations are done once for two or more frames. This does not raise any problems, as positions of moving objects are interpolated between the calculations.
- The rendering framerate is the same or almost the same. This situation is also OK, the calculations are performed once per frame; the physics keeps pace with the graphics and vice versa.
- The rendering framerate is much lower. This is where the problems begin. First, as you see from the picture, the physics should be calculated twice or even more times per frame, and that does not speed up the overall rendering process. Second, you cannot set the physics framerate too low, as in this case the calculations will lose too much precision.
There is no point in setting the physics framerate too high, too, because if the calculations take more than 40 ms, physics is not computed in full. Hence, if additional iterations are needed, they are skipped.
Limiting Rendering FPS to Physics One
The rendering FPS can be limited to the physics one, if the rendering FPS is higher. For that engine.physics.isFixed(1) flag is set in code. Such FPS limitation allows calculating physics each rendered frame (rather than interpolate it when this flag is set to 0). In this mode, there is no twitching of physical objects if they have non-linear velocities. (If the rendering FPS is lower than the physics one, this flag has no effect.)