Developer Hints - Gothic-UnZENity-Project/Gothic-UnZENity GitHub Wiki

Module structure

The game is built in different modules for:

  1. Decide at boot time whether to load VR, Flat, or other integrations (Adapter pattern)
  2. 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.

image

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.

SceneLoading

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:

  1. Load all VOBs inside the whole world (10k elements)
  2. World chunks are sliced based on lights on the ground which needs to be calculated
  3. 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:

  1. 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.
  2. World chunk creation is calculated once in the PreCaching scene. During runtime, we only need to load the data again.
  3. 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.

SceneLoading

Loading Gothic's world.zen world mesh creation improves from ~45 sec to ~10 sec with this logic.

General

General architecture

  • 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:

VM initialization

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:

External function execution from VM

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:

  1. Control logic flow - External functions are executed immediately. (e.g. AI_SetWalkmode(), Wld_IsMobAvailable())
  2. 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.

Animation

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:

  1. BIP01 and sub-bones will be handled by Animation component
  2. Inside BIP01 is a Collider/Rigidbody element, which walks with the animation, but physics based (as not directly handled via animation)
  3. 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
  4. 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 handling

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.

  1. .mds -> IModelScript - Contains animation and mesh information for animated objects.
  2. .mdl -> IModel - Consists of .mdh and .mdm information.
  3. .mdh -> IModelHierarchy - Contains bone informations for meshes.
  4. .mdm -> IModelMesh - Contains mesh (and optional bone informations)
  5. .mrm -> IMultiResolutionMesh - Contains the actual mesh render information.

The named files are tightly coupled within ZenKit. With this correlation:

ZenKit Mesh/Animation classes and 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. ZenKit Mesh/Animation classes and correlation

AnimationStates: AnimationStates

Lock picking

Diagram of implemented Lock Picking solution. Interaction class at GUZ.VR.Components.VobItem.VRLockPickInteraction ZenKit Mesh/Animation classes and correlation

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:

  1. Uber Logger to display them in Uber Console
  2. Unity Debug.Log*() itself to display them on Unity Console
  3. 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 Uber Console example

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)