Developer Hints - Gothic-UnZENity-Project/Gothic-UnZENity GitHub Wiki
Module structure
The game is built in different modules for:
- Decide at boot time whether to load VR, Flat, or other integrations (Adapter pattern)
- Overwrite prefab logic. (e.g. UnZENity-Core//oCItem.prefab can be overwritten by UnZENity-VR//oCItem.prefab based on active module)
Current structure:
/Assets
|--- UnZENity modules
| |-- UnZENity-Core
| |-- Editor - Actions to set up UnZENity. MenuItem: UnZENity
| |-- Resources - Resources to load at runtime. e.g. Prefabs.
| |-- Scenes - Main scenes
| |-- Scripts - C# scripts with core logic. C# Module: UnZENity.Core
| |-- UnZENity-Flat/
| |-- Resources - Overwritten prefabs and other elements to load at runtime.
| |-- Scripts - C# Module: UnZENity.Flat
| |-- UnZENity-Gothic1/
| |-- Scripts - C# Module: UnZENity.G1
| |-- UnZENity-Gothic2/
| |-- Scripts - C# Module: UnZENity.G2
| |-- UnZENity-Lab/
| |-- Scripts - C# Module: UnZENity.Lab
| |-- UnZENity-Tests/
| |-- PlayMode - Integration tests while game is running. C# Module: UnZENity.Tests.PlayMode
| |-- UnZENity-VR/
| |-- Editor - Actions to alter VR context and initialize Hurricane VR. MenuItem: UnZENity
| |-- Resources - Overwritten prefabs and other elements to load at runtime.
| |-- Scripts - C# Module: UnZENity.VR
|--- Other modules
| |-- HurricaneVR - Installation of HVR (if available). Will be leveraged by UnZENity-VR module
| |-- Plugins - Used for Rider.Flow and other editor plugins if needed locally
| |-- Resources - Unused. But Unity is always recreating it ;-/
| |-- StreamingAssets - Json configuration for Players to define (e.g.) Gothic installation path after installation
| |-- TextMesh Pro - Resources like shaders/sprites to ensure TMP is working properly. (No C# scripts as they're included via package.json)
| |-- XR - OpenXR settings
C# modules (.asmdef)
The named UnZENity modules leverage Unity's C# modules to define proper dependencies. Please read Unity's documentation for more information: https://docs.unity3d.com/Manual/ScriptCompilationAssemblyDefinitionFiles.html
[!NOTE] If you need to add a dependency to a Unity package/module or another UnZENity module, please follow this guide --> Referencing another Assembly.
The dependency graph goes like this: Every module depends from UnZENity.Core and has no interaction with other UnZENity modules.
Core
<- Flat
<- VR
<- Lab
<- Tests
Scene loading
For the PreCaching and Loading scene, we work with async-await. It provides us a way to skip frames after x amount of WorldMeshes or VobItems being created.
Hint: async is not! async as another thread. The way we use it, it's nearly the same as Coroutine. i.e. we just define synchronously when to skip to the next frame.
Pre-Caching
Without pre-caching, loading the game takes about 30-60 seconds. This is due to the fact, that without pre-caching, we have time-consuming tasks:
- Load all VOBs inside the whole world (10k elements)
- World chunks are sliced based on lights on the ground which needs to be calculated
- TextureArrays are dynamically created, but data needs to be fetched before
As we want to reduce this time, we implemented PreCaching scene. Its mental modal is:
- We use it to calculate VOB bounds so that we can lazy load them at runtime later. i.e. each VOB is created in the normal game, but only with a LazyLoading component. Once Culling kicks in, we will now lazy load the object.
- World chunk creation is calculated once in the PreCaching scene. During runtime, we only need to load the data again.
- TextureArray information is stored in PreCaching scene. i.e. during runtime we still create the texture array, but the calculation about "what's in it" is done initially.
Loading Gothic's world.zen world mesh creation improves from ~45 sec to ~10 sec with this logic.
General
- Creator - Creator handles creation (loading) of objects. (e.g. NpcCreator, VobCreator)
- Manager - Manager handle changes on objects at runtime (e.g. WayNetManager to find WayPoint/FreePoint based on Vector3 position or NpcManager to handle Daedalus calls for checking inventory items)
- Properties - Attached to Prefabs. They will store all properties needed for an object. (e.g. NpcProperties, SpotProperties)
Prefab usage
We load all Gothic assets at runtime. Nevertheless, Prefabs can come in handy at some times. Therefore, we implemented a logic to merge GameObject structure including their components via Regex. Especially used for Vobs.
Example for merging GameObjects from source code documentation at AbstractMeshBuilder.TryGetExistingGoInsideNodeHierarchy()
/// Example GOs (from a Prefab):
/// BIP01
/// |- BIP01 CHEST_.*_1
/// Example node hierarchy:
/// BIP01
/// |- BIP01 CHESTLOCK
/// |- BIP01 CHEST_SMALL_1
/// Merged GOs:
/// BIP01
/// |- BIP01 CHEST_SMALL_1 - from prefab; but the order changed later, when glued together with new GOs. No issue on that so far.
/// |- BIP01 CHESTLOCK - from nodes; new
Daedalus Externals
This section gives a technical overview for understanding how the external Daedalus functions work in the application's context. It is divided in the initialization and the workflow when the game is running.
When the game is booted, the GUZBootstrapper.LoadGothicVM(gothicDir: string)
call initializes the ZenKit VM. The call looks as follows:
As can be seen, the VmGothicExternals
is GothicVR's internal layer for communicating with the ZenKit VM; which handles the execution of the Gothic Daedalus scripts.
The Daedalus scripting language used by Gothic has a list of external functions that can be called from within each script. These can be used e.g. to spawn NPCs, add items to the inventory or handle dialogs.
These functions have to be implemented by us, so that they can interact with our project's code. For this to work, the ZenKit VM is able to register our own implementations for each function that will be executed when being called from a Daedalus script.
In the above diagram you can see that the VmGothicExternals
registers each single function with the RegisterExternal<>("", ...)
method.
When executing the Daedalus scripts, the ZenKit VM will then look up which of our functions is registered for the specific one being called. It will then execute our registered function and pass the vmPtr
object which can be used to retrieve the function's parameters. The process is being described in the following diagram:
The above call will be executed when the invocation of an external function is found in a Daedalus script while evaluating it in the ZenKit VM.
Note: When no Daedalus function with the same name is explicitly registered, the default handler
DefaultExternal
is called which currently logs the function's name so that missing functions can be easily identified in the game's logs.
AI handling
Ai consists of two elements:
- Control logic flow - External functions are executed immediately. (e.g. AI_SetWalkmode(), Wld_IsMobAvailable())
- Execute an animation - These animations will be put into an ActionQueue and will be executed sequentially. (e.g. AI_GotoWP(), AI_UseMob(), AI_Wait())
func void ZS_WalkAround ()
{
AI_SetWalkmode (self,NPC_WALK); // Execute immediately while parsing
if (Wld_IsMobAvailable (self,"BED")) // Immediately
{
AI_GotoWP (self,self.wp); // QueueAction - Put into Queue and execute sequentially
AI_AlignToWP (self); // QueueAction
AI_UseMob (self, "BED",1);
}
AI_Wait (self, 1); // QueueAction
};
QueueActions (animations) can become quite complex (e.g. AI_UseMob() requires 1/ turning to Mob, 2/ walking to Mob, 3/ executing animation on mob). We therefore put them into Command pattern (wiki). It means, that every QueueAction handles it's state on it's own and tells the Queue owner, when it's done and another Action can be triggered.
More information about AnimationQueue mechanism at ataulien/Inside-Gothic - Action-Queue
Root motion handling (deprecated)
Gothic delivers root motions via BIP01 bone. We use this information to leverage physics based walking.
It includes:
- BIP01 and sub-bones will be handled by Animation component
- Inside BIP01 is a Collider/Rigidbody element, which walks with the animation, but physics based (as not directly handled via animation)
- This Rigidbody's physics based movement (e.g. grounding) is copied to root (on top of BIP01) to provide this change to the whole animated object
- In the end, the full BIP01 root motion is copied to root, to ensure the animated object isn't snapping back to 0.
Root motion corrections: Gothic animations don't necessarily start at BIP01=(0,0,0) Therefore we need to calculate the offset. I.e. first frame's BIP01 is handled as (0,0,0) and followings will be subtracted with it. (Otherwise e.g. walking will hick up as NPC will spawn slightly in front of last animation loop.)
Gothic assets loading
We fully rely on ZenKit to import gothic assets. To consume data within Unity (C#) we leverage ZenKitCS as C -> C# interface.
Meshes
Visible assets are called meshes. There are multiple ways from Gothic data to print them on screen.
If you have a name of an object (e.g. HUM_BODY_NAKED0 or CHESTBIG_OCCHESTLARGE) you should try to load it's mesh files in the following order.
- .mds -> IModelScript - Contains animation and mesh information for animated objects.
- .mdl -> IModel - Consists of .mdh and .mdm information.
- .mdh -> IModelHierarchy - Contains bone informations for meshes.
- .mdm -> IModelMesh - Contains mesh (and optional bone informations)
- .mrm -> IMultiResolutionMesh - Contains the actual mesh render information.
The named files are tightly coupled within ZenKit. With this correlation:
Features - Misc
Animation system
As we load assets at runtime, we can't use Unity's Timeline API or Animator (Mecanim) solution. Legacy Animations are possible, but they
don't offer the flexibility we need in terms of layering and blending. We therefore need to create our own Animation system. The following
diagram highlights some related classes and their correlation. They are located inside UnZENity-Core module at GUZ.Core.Animations.
AnimationStates:
Lock picking
Diagram of implemented Lock Picking solution. Interaction class at GUZ.VR.Components.VobItem.VRLockPickInteraction
Log handling
We leverage Uber Logger as a structured approach to categorize logs.
Uber Logger is a wrapper around Debug.Log*() and is only activated on Editor builds. It's deactivated in builds automatically
(or better: It's performance consuming Console/Debug view is disabled in builds):
#define ENABLE_UBERLOGGING
which is not set in release builds by default.
// Logs created based on Categories/Modules
Logger.Log(string, LogModule);
Logger.LogWarning(string, LogModule);
Logger.LogError(string, LogModule);
// These logs will never show up inside a non-Development build as they are split out by the compiler.
// Use it for reoccurring and fps-consuming logs.
Logger.LogEditor(string, LogCat);
Logger.LogWarningEditor(string, LogCat);
Logger.LogErrorEditor(string, LogCat);
// Category/Module filter for logs inside Uber Console
public enum LogCat
{
Ai,
Animations,
Dialog,
DxMusic,
Npc,
PreCaching,
ZenKit,
ZSpy,
[...]
}
In the background, all the logs, pushed to these functions are handled by:
- Uber Logger to display them in Uber Console
- Unity Debug.Log*() itself to display them on Unity Console
- FileLoggingHandler to store them in our UnZENity.log file
Hint: You can still use Debug.Log*() which will then be handled by Uber Logger as uncategorized (No Channel) and can't be separated during debug sessions.
Uber Logger Console can be activated via UnZENity --> Debug --> Uber Console
Q&A
Q: There are multiple sources of Configuration files (Gothic.ini, GothicGame.ini, GameSettings.json, DeveloperConfig.ScriptableObject). How do I access them?
A: There's a single source of truth to access all Configurations. Basically call GameGlobals.Config.*
to access the defined data classes (.Gothic, .GothicGame, .Root, .Dev).
Q: What is the default font size?
A: We chose 12 as the TMP_Text font size for Gothic UI texts. This is based on a small UI element which will otherwise break text Menu.d -> INSTANCE MENU_ITEM_DAY(C_MENU_ITEM_DEF)