Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions Assets/Gothic-Core/Scripts/Domain/Culling/AbstractCullingDomain.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
using System.Collections.Generic;
using Gothic.Core.Services.Config;
using Gothic.Core.Extensions;
using Reflex.Attributes;
using UnityEngine;

Expand All @@ -18,16 +16,27 @@ protected enum State
WorldLoaded
}

/// <summary>
/// Logical culling state of a tracked object. Unknown forces the first event/initial sweep to apply a state.
/// </summary>
protected enum ObjectState : byte
{
Unknown,
Enabled,
Disabled
}

// Objects can move between cullingDistance and cullingDistance*factor without a state change (hysteresis).
// Prevents SetActive() flickering at the culling border while the VR headset bobs around.
protected const float HysteresisFactor = 1.15f;

protected State CurrentState;

// Stored for resetting after world switch
protected CullingGroup CullingGroup;

// Stored for later index mapping SphereIndex => GOIndex
protected readonly List<GameObject> Objects = new();

// Temporary spheres during async world loading calls.
protected List<BoundingSphere> Spheres = new();
// Main camera transform, set once the world finished loading.
protected Transform ReferencePoint;

protected abstract void VisibilityChanged(CullingGroupEvent evt);

Expand All @@ -40,8 +49,6 @@ public virtual void Init()

public virtual void PreWorldCreate()
{
Objects.ClearAndReleaseMemory();
Spheres.ClearAndReleaseMemory();
CullingGroup.Dispose();
CullingGroup = new CullingGroup();

Expand All @@ -58,6 +65,7 @@ public virtual void PostWorldCreate()
var mainCamera = Camera.main!;
CullingGroup.targetCamera = mainCamera; // Needed for FrustumCulling and OcclusionCulling to work.
CullingGroup.SetDistanceReferencePoint(mainCamera.transform); // Needed for BoundingDistances to work.
ReferencePoint = mainCamera.transform;

CurrentState = State.WorldLoaded;
}
Expand Down
222 changes: 156 additions & 66 deletions Assets/Gothic-Core/Scripts/Domain/Culling/NpcMeshCullingDomain.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Gothic.Core.Adapters.Npc;
using Gothic.Core.Creator;
using Gothic.Core.Logging;
using Gothic.Core.Models.Container;
using Gothic.Core.Models.Vm;
using Gothic.Core.Extensions;
using Gothic.Core.Services.Npc;
using Reflex.Attributes;
using UnityEngine;
using ZenKit.Daedalus;
using Logger = Gothic.Core.Logging.Logger;

namespace Gothic.Core.Domain.Culling
Expand All @@ -17,137 +18,225 @@ public class NpcMeshCullingDomain : AbstractCullingDomain
[Inject] private readonly WayNetService _wayNetService;


// Sphere values to track and update when visible NPCs move.
private BoundingSphere[] _spheres;
private readonly Dictionary<int, Transform> _visibleNpcs = new();
// Real-world sized sphere around an NPC. The culling distance itself is handled via BoundingDistances.
private const float _npcSphereRadius = 1f;
private const int _initialCapacity = 256;

// Shared by reference with the CullingGroup. Grows by doubling. The used entry amount is communicated
// via SetBoundingSphereCount() - i.e. add/remove won't allocate a new array each time.
private BoundingSphere[] _spheres = new BoundingSphere[_initialCapacity];
private NpcLoader[] _loaders = new NpcLoader[_initialCapacity];
private ObjectState[] _states = new ObjectState[_initialCapacity];
private int _count;

private readonly Dictionary<NpcInstance, int> _indexByInstance = new();

// Indices of NPCs currently in visible range. The loader itself is resolved via _loaders[index],
// so a set of indices is the single source of truth (no parallel index->loader map to keep in sync).
private readonly HashSet<int> _visibleNpcs = new();


public override void PreWorldCreate()
{
base.PreWorldCreate();
_spheres = null;
_visibleNpcs.ClearAndReleaseMemory();

_spheres = new BoundingSphere[_initialCapacity];
_loaders = new NpcLoader[_initialCapacity];
_states = new ObjectState[_initialCapacity];
_count = 0;
_indexByInstance.Clear();
_visibleNpcs.Clear();
}

public void AddCullingEntry(GameObject go)
{
if (CurrentState != State.Loading)
var loader = go.GetComponent<NpcLoader>();
if (loader == null)
{
Logger.LogWarning($"CullingGroup for Sounds closed already. Can't add >{go.name}<", LogCat.Mesh);
Logger.LogError($"Can't add >{go.name}< to NPC culling as it has no NpcLoader component.", LogCat.Npc);
return;
}

Objects.Add(go);
if (_count == _spheres.Length)
{
Array.Resize(ref _spheres, _count * 2);
Array.Resize(ref _loaders, _count * 2);
Array.Resize(ref _states, _count * 2);

if (CurrentState == State.WorldLoaded)
CullingGroup.SetBoundingSpheres(_spheres);
}

var index = _count++;
_spheres[index] = new BoundingSphere(go.transform.position, _npcSphereRadius);
_loaders[index] = loader;
_states[index] = ObjectState.Unknown;
_indexByInstance[loader.Npc] = index;

// Normally NPC spheres are ~1 meters in radius. But we need to fake the volume, so that Culling always thinks
// we're "inside" the NPC and Frustum+Occlusion Culling isn't triggered.
// (@see VobSoundCullingManager where we also use it exactly that way, and it works.)
var sphere = new BoundingSphere(go.transform.position, ConfigService.Dev.NpcCullingDistance);
Spheres.Add(sphere);
// NPCs spawned at runtime (e.g. summoned monsters) are added after the world finished loading.
if (CurrentState == State.WorldLoaded)
{
CullingGroup.SetBoundingSphereCount(_count);
ApplyStateByDistance(index);
}
}

/// <summary>
/// Set main camera once world is loaded fully. Doesn't work at loading time as we change scenes etc.
/// </summary>
public override void PostWorldCreate()
{
base.PostWorldCreate();

if (ConfigService.Dev.EnableNpcMeshCulling)
{
base.PostWorldCreate();
var cullingDistance = ConfigService.Dev.NpcCullingDistance;

// For performance reasons, we initially used a List during creation.
// Now we move to an array which is copied by reference to CullingGroup and can be updated later via Update().
_spheres = Spheres.ToArray();
CullingGroup.SetBoundingSpheres(_spheres);

// As we "faked" the volume of NPCs, we will plainly disable them whenever we are out of their volume (aka range).
CullingGroup.SetBoundingDistances(new[] { 0f });

CullingGroup.SetBoundingSphereCount(_count);
CullingGroup.SetBoundingDistances(new[] { cullingDistance, cullingDistance * HysteresisFactor });
CullingGroup.onStateChanged = VisibilityChanged;
}
// If we disabled NPC culling, then we need to render them all now!
else

// Apply the initial state for all NPCs ourselves, as CullingGroup events aren't reliable for
// spheres which start inside the first distance band. Also handles disabled NPC culling (all visible).
for (var i = 0; i < _count; i++)
{
Objects.ForEach(obj => obj.SetActive(true));
ApplyStateByDistance(i);
}

// Cleanup
Spheres.ClearAndReleaseMemory();
}

/// <summary>
/// Band 0 - [0...cullingDistance) - NPC is enabled.
/// Band 1 - [cullingDistance...*1.15) - Hysteresis zone: NPC keeps its current state to avoid border flickering.
/// Band 2 - [cullingDistance*1.15...inf) - NPC is disabled.
/// </summary>
protected override void VisibilityChanged(CullingGroupEvent evt)
{
var go = Objects[evt.index];
if (evt.currentDistance == 0)
SetNpcState(evt.index, true);
else if (evt.currentDistance >= 2)
SetNpcState(evt.index, false);
}

// A higher distance level means "invisible" as we only leverage: 0 -> in-range; 1 -> out-of-range.
var isInVisibleRange = evt.currentDistance == 0;
var wasOutOfDistance = evt.previousDistance != 0;
private void ApplyStateByDistance(int index)
{
if (!ConfigService.Dev.EnableNpcMeshCulling)
{
SetNpcState(index, true);
return;
}

var loaderComp = go.GetComponent<NpcLoader>();
var npcData = loaderComp.Npc.GetUserData();
var isInitialized = loaderComp.IsLoaded;
var distance = Vector3.Distance(ReferencePoint.position, _spheres[index].position) - _spheres[index].radius;
SetNpcState(index, distance < ConfigService.Dev.NpcCullingDistance);
}

if (!isInVisibleRange && isInitialized)
private void SetNpcState(int index, bool isInVisibleRange)
{
var desiredState = isInVisibleRange ? ObjectState.Enabled : ObjectState.Disabled;
if (_states[index] == desiredState)
return;

_states[index] = desiredState;

var loader = _loaders[index];
var npcData = loader.Container;

if (!isInVisibleRange && loader.IsLoaded)
{
npcData.PrefabProps?.AnimationSystem.StopAllAnimations();
}

go.SetActive(isInVisibleRange);
GlobalEventDispatcher.NpcMeshCullingChanged.Invoke(npcData, loaderComp, isInVisibleRange, wasOutOfDistance);
loader.gameObject.SetActive(isInVisibleRange);

GlobalEventDispatcher.NpcMeshCullingChanged.Invoke(npcData, loader, isInVisibleRange, true);

// Alter position tracking of NPC
if (isInVisibleRange)
{
_visibleNpcs.TryAdd(evt.index, go.transform);
_visibleNpcs.Add(index);
}
// When an NPC gets invisible, we need to check for their next respawn from their initially spawned position.
// When an NPC gets invisible, we need to check for their next respawn from their scheduled routine position.
else
{
var props = npcData.Props;

npcData.PrefabProps?.AiHandler?.DisableNpc();
MoveToRoutineWayPoint(index, npcData);
_visibleNpcs.Remove(index);
}
}

if (props.RoutineCurrent != null)
{
var spawnedWayPointName = props.RoutineCurrent.Waypoint;
var wayNetPoint = _wayNetService.GetWayNetPoint(spawnedWayPointName);
/// <summary>
/// Called when the time-based routine of an NPC changed. Culled NPCs move their culling sphere
/// (and lazy-loading GO) to the new scheduled waypoint, so that the world progresses while not looking.
/// Visible NPCs walk to their new routine spot on their own.
/// </summary>
public void OnNpcRoutineChanged(NpcContainer npcData)
{
if (!_indexByInstance.TryGetValue(npcData.Instance, out var index))
return;

if (wayNetPoint is not null)
{
UpdatePosition(evt.index, wayNetPoint.Position);
}
}
_visibleNpcs.Remove(evt.index);
}
if (_states[index] == ObjectState.Enabled)
return;

MoveToRoutineWayPoint(index, npcData);
}

/// <summary>
/// While culled, the next visibility check needs to happen at the position where the routine schedule
/// expects the NPC - not where it was culled out.
/// </summary>
private void MoveToRoutineWayPoint(int index, NpcContainer npcData)
{
var props = npcData.Props;

// Corpses stay where they are.
if (props == null || props.BodyState == VmGothicEnums.BodyState.BsDead)
return;

if (props.RoutineCurrent == null)
return;

var wayNetPoint = _wayNetService.GetWayNetPoint(props.RoutineCurrent.Waypoint);
if (wayNetPoint == null)
return;

_spheres[index].position = wayNetPoint.Position;

// InitNpc() of a not-yet-loaded NPC spawns at this GO's position, so keep it in sync with the sphere.
// An already-loaded NPC must NOT have its live transform moved here: it would teleport the visible
// mesh to the routine waypoint mid-walk. Loaded NPCs are repositioned on re-enable via ReEnableNpc().
if (!_loaders[index].IsLoaded)
_loaders[index].transform.position = wayNetPoint.Position;
}

/// <summary>
/// Each frame, we update the visible NPCs' current position.
/// </summary>
public void Update()
{
foreach (var npc in _visibleNpcs)
foreach (var index in _visibleNpcs)
{
var rootTransform = _loaders[index].transform;

// NpcLoader.NPCRoot is only updated after a walking animation's loop is done.
// child[0] == NPCRoot/BIP01 -> We need to fetch this one as it's the walking animation root which updates every frame.
if (npc.Value.childCount > 0)
if (rootTransform.childCount > 0)
{
var child = npc.Value.GetChild(0);
var child = rootTransform.GetChild(0);
// It might be, that the NPC is not yet initialized. Therefore wait until the GO structure is fully loaded.
if (child.childCount > 0)
{
UpdatePosition(npc.Key, child.GetChild(0).position);
_spheres[index].position = child.GetChild(0).position;
}
}
}
}

public void UpdateVobPositionOfVisibleNpcs()
{
foreach (var npc in _visibleNpcs.Values)
foreach (var index in _visibleNpcs)
{
var container = npc.GetComponent<NpcLoader>().Container;
var container = _loaders[index].Container;

container.Vob.Position = container.PrefabProps.Bip01.position.ToZkVector();
container.Vob.Rotation = container.PrefabProps.Bip01.rotation.ToZkMatrix();
Expand All @@ -156,12 +245,13 @@ public void UpdateVobPositionOfVisibleNpcs()

public List<NpcContainer> GetVisibleNpcs()
{
return _visibleNpcs.Values.Select(i => i.GetComponent<NpcLoader>().Container).ToList();
}
var visibleNpcs = new List<NpcContainer>(_visibleNpcs.Count);
foreach (var index in _visibleNpcs)
{
visibleNpcs.Add(_loaders[index].Container);
}

private void UpdatePosition(int sphereKey, Vector3 position)
{
_spheres[sphereKey].position = position;
return visibleNpcs;
}
}
}
Loading