diff --git a/Assets/Gothic-Core/Scripts/Domain/Culling/AbstractCullingDomain.cs b/Assets/Gothic-Core/Scripts/Domain/Culling/AbstractCullingDomain.cs
index d2bf573b2..1ef6e9226 100644
--- a/Assets/Gothic-Core/Scripts/Domain/Culling/AbstractCullingDomain.cs
+++ b/Assets/Gothic-Core/Scripts/Domain/Culling/AbstractCullingDomain.cs
@@ -1,6 +1,4 @@
-using System.Collections.Generic;
using Gothic.Core.Services.Config;
-using Gothic.Core.Extensions;
using Reflex.Attributes;
using UnityEngine;
@@ -18,16 +16,27 @@ protected enum State
WorldLoaded
}
+ ///
+ /// Logical culling state of a tracked object. Unknown forces the first event/initial sweep to apply a state.
+ ///
+ 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 Objects = new();
-
- // Temporary spheres during async world loading calls.
- protected List Spheres = new();
+ // Main camera transform, set once the world finished loading.
+ protected Transform ReferencePoint;
protected abstract void VisibilityChanged(CullingGroupEvent evt);
@@ -40,8 +49,6 @@ public virtual void Init()
public virtual void PreWorldCreate()
{
- Objects.ClearAndReleaseMemory();
- Spheres.ClearAndReleaseMemory();
CullingGroup.Dispose();
CullingGroup = new CullingGroup();
@@ -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;
}
diff --git a/Assets/Gothic-Core/Scripts/Domain/Culling/NpcMeshCullingDomain.cs b/Assets/Gothic-Core/Scripts/Domain/Culling/NpcMeshCullingDomain.cs
index 6bc6a9c0e..8e2e8c732 100644
--- a/Assets/Gothic-Core/Scripts/Domain/Culling/NpcMeshCullingDomain.cs
+++ b/Assets/Gothic-Core/Scripts/Domain/Culling/NpcMeshCullingDomain.cs
@@ -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
@@ -17,33 +18,67 @@ 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 _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 _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 _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();
+ 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);
+ }
}
///
@@ -51,75 +86,127 @@ public void AddCullingEntry(GameObject go)
///
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();
}
+ ///
+ /// 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.
+ ///
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();
- 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);
+ ///
+ /// 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.
+ ///
+ 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);
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
}
///
@@ -127,17 +214,19 @@ protected override void VisibilityChanged(CullingGroupEvent evt)
///
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;
}
}
}
@@ -145,9 +234,9 @@ public void Update()
public void UpdateVobPositionOfVisibleNpcs()
{
- foreach (var npc in _visibleNpcs.Values)
+ foreach (var index in _visibleNpcs)
{
- var container = npc.GetComponent().Container;
+ var container = _loaders[index].Container;
container.Vob.Position = container.PrefabProps.Bip01.position.ToZkVector();
container.Vob.Rotation = container.PrefabProps.Bip01.rotation.ToZkMatrix();
@@ -156,12 +245,13 @@ public void UpdateVobPositionOfVisibleNpcs()
public List GetVisibleNpcs()
{
- return _visibleNpcs.Values.Select(i => i.GetComponent().Container).ToList();
- }
+ var visibleNpcs = new List(_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;
}
}
}
diff --git a/Assets/Gothic-Core/Scripts/Domain/Culling/VobMeshCullingDomain.cs b/Assets/Gothic-Core/Scripts/Domain/Culling/VobMeshCullingDomain.cs
index c7c726a92..6b86c931c 100644
--- a/Assets/Gothic-Core/Scripts/Domain/Culling/VobMeshCullingDomain.cs
+++ b/Assets/Gothic-Core/Scripts/Domain/Culling/VobMeshCullingDomain.cs
@@ -1,11 +1,11 @@
using System;
using System.Collections;
using System.Collections.Generic;
-using System.Linq;
using Gothic.Core.Adapters.Vob;
using Gothic.Core.Debugging;
using Gothic.Core.Logging;
using Gothic.Core.Manager;
+using Gothic.Core.Models.Config;
using Gothic.Core.Models.Container;
using Gothic.Core.Services.Caches;
using Gothic.Core.Services.Context;
@@ -14,9 +14,9 @@
using MyBox;
using Reflex.Attributes;
using UnityEngine;
-using ZenKit;
using ZenKit.Vobs;
using Logger = Gothic.Core.Logging.Logger;
+using NumericsVector3 = System.Numerics.Vector3;
namespace Gothic.Core.Domain.Culling
{
@@ -28,37 +28,62 @@ public class VobMeshCullingDomain : AbstractCullingDomain
[Inject] private readonly ResourceCacheService _resourceCacheService;
[Inject] private readonly ContextGameVersionService _contextGameVersionService;
-
- // Stored for resetting after world switch
- private CullingGroup _cullingGroupSmall => CullingGroup;
- private CullingGroup _cullingGroupMedium;
- private CullingGroup _cullingGroupLarge;
- // Stored for later index mapping SphereIndex => GOIndex
- private List _objectsSmall => Objects;
- private readonly List _objectsMedium = new();
- private readonly List _objectsLarge = new();
+ private const int _initialBucketCapacity = 1024;
- // Stored for later position updates for moved Vobs
- private List _spheresSmall => Spheres;
- private List _spheresMedium = new();
- private List _spheresLarge = new();
+ ///
+ /// One CullingGroup per VOB size class. The sphere array is shared by reference with the CullingGroup
+ /// and grows by doubling. The used entry amount is communicated via SetBoundingSphereCount() -
+ /// i.e. add/remove won't allocate a new array each time.
+ ///
+ private class CullingBucket
+ {
+ public CullingGroup Group;
+ public BoundingSphere[] Spheres = new BoundingSphere[_initialBucketCapacity];
+ public GameObject[] Objects = new GameObject[_initialBucketCapacity];
+ public ObjectState[] States = new ObjectState[_initialBucketCapacity];
+ // Grabbed VOBs are ignored by culling until they're released and at rest again.
+ public bool[] Paused = new bool[_initialBucketCapacity];
+ public int Count;
+
+ public readonly MeshCullingGroup Config;
+ public readonly Color GizmoColor;
+
+ public CullingBucket(MeshCullingGroup config, Color gizmoColor)
+ {
+ Config = config;
+ GizmoColor = gizmoColor;
+ }
- // Do not trigger FC or OC while in that range of an object.
- private const float _gracePeriodCullingDistance = 5f;
+ public void Grow()
+ {
+ var newSize = Spheres.Length * 2;
+ Array.Resize(ref Spheres, newSize);
+ Array.Resize(ref Objects, newSize);
+ Array.Resize(ref States, newSize);
+ Array.Resize(ref Paused, newSize);
+ }
- private enum VobList
- {
- Small,
- Medium,
- Large
+ public void Reset()
+ {
+ Spheres = new BoundingSphere[_initialBucketCapacity];
+ Objects = new GameObject[_initialBucketCapacity];
+ States = new ObjectState[_initialBucketCapacity];
+ Paused = new bool[_initialBucketCapacity];
+ Count = 0;
+ }
}
- // Grabbed Vobs will be ignored from Culling until Grabbing stopped and velocity = 0
- private Dictionary> _pausedVobs = new();
- private Dictionary _pausedVobsToReenable = new();
- private Dictionary _pausedVobsToReenableCoroutine = new();
+ // Small / Medium / Large
+ private CullingBucket[] _buckets;
+ // O(1) lookup for removal, pausing and position updates.
+ private readonly Dictionary _vobIndices = new();
+
+ // Released VOBs we wait for to come to rest, before we re-enable culling for them.
+ private readonly Dictionary _pausedVobsToReenable = new();
+ private readonly Dictionary _pausedVobsToReenableCoroutine = new();
+ private readonly List _vobsAtRestScratch = new();
public override void Init()
@@ -68,9 +93,12 @@ public override void Init()
base.Init();
- // Unity demands CullingGroups to be created in Awake() or Start() earliest.
- _cullingGroupMedium = new CullingGroup();
- _cullingGroupLarge = new CullingGroup();
+ _buckets = new[]
+ {
+ new CullingBucket(ConfigService.Dev.SmallVOBMeshCullingGroup, new Color(.9f, 0, 0)) { Group = CullingGroup },
+ new CullingBucket(ConfigService.Dev.MediumVOBMeshCullingGroup, new Color(.5f, 0, 0)) { Group = new CullingGroup() },
+ new CullingBucket(ConfigService.Dev.LargeVOBMeshCullingGroup, new Color(.2f, 0, 0)) { Group = new CullingGroup() }
+ };
_unityMonoService.StartCoroutine(StopVobTrackingBasedOnVelocity());
}
@@ -80,250 +108,260 @@ public override void Init()
///
public void OnDrawGizmos()
{
- if (!Application.isPlaying || !ConfigService.Dev.ShowVOBMeshCullingGizmos)
+ if (!Application.isPlaying || !ConfigService.Dev.ShowVOBMeshCullingGizmos || _buckets == null)
{
return;
}
- Gizmos.color = new Color(.9f, 0, 0);
- if (_spheresSmall != null)
+ foreach (var bucket in _buckets)
{
- for (var i = 0; i < _spheresSmall.Count; i++)
+ Gizmos.color = bucket.GizmoColor;
+ for (var i = 0; i < bucket.Count; i++)
{
- if (_objectsSmall[i].TryGetComponent(out VobCullingGizmo gizmoComp) && gizmoComp.ActivateGizmo)
+ if (bucket.Objects[i] != null &&
+ bucket.Objects[i].TryGetComponent(out VobCullingGizmo gizmoComp) && gizmoComp.ActivateGizmo)
{
- Gizmos.DrawWireSphere(_spheresSmall[i].position, _spheresSmall[i].radius);
+ Gizmos.DrawWireSphere(bucket.Spheres[i].position, bucket.Spheres[i].radius);
}
}
}
+ }
- Gizmos.color = new Color(.5f, 0, 0);
- if (_spheresMedium != null)
- {
- for (var i = 0; i < _spheresMedium.Count; i++)
- {
- if (_objectsMedium[i].TryGetComponent(out VobCullingGizmo gizmoComp) && gizmoComp.ActivateGizmo)
- {
- Gizmos.DrawWireSphere(_spheresMedium[i].position, _spheresMedium[i].radius);
- }
- }
+ public override void PreWorldCreate()
+ {
+ if (!ConfigService.Dev.EnableVOBMeshCulling)
+ return;
+
+ base.PreWorldCreate();
+ // The base class recreated its CullingGroup. Realign the small bucket and recreate the other ones.
+ _buckets[0].Group = CullingGroup;
+ for (var i = 1; i < _buckets.Length; i++)
+ {
+ _buckets[i].Group.Dispose();
+ _buckets[i].Group = new CullingGroup();
}
- Gizmos.color = new Color(.2f, 0, 0);
- if (_spheresLarge != null)
+ foreach (var bucket in _buckets)
{
- for (var i = 0; i < _spheresLarge.Count; i++)
- {
- if (_objectsLarge[i].TryGetComponent(out VobCullingGizmo gizmoComp) && gizmoComp.ActivateGizmo)
- {
- Gizmos.DrawWireSphere(_spheresLarge[i].position, _spheresLarge[i].radius);
- }
- }
+ bucket.Reset();
}
+
+ _vobIndices.Clear();
+ _pausedVobsToReenable.Clear();
+ _pausedVobsToReenableCoroutine.Clear();
}
- public override void PreWorldCreate()
+ ///
+ /// Set main camera once world is loaded fully.
+ /// Doesn't work at loading time as we change scenes etc.
+ ///
+ public override void PostWorldCreate()
{
- base.PreWorldCreate();
-
- Logger.LogWarningEditor(
- "FIXME - As the VOBs aren't loaded yet, we need to fetch the LocalBounds from mesh cache " +
- "which needs to be created before the game starts. " +
- "Currently, each VOB is of >small< size aka Bounds.default", LogCat.Vob);
+ if (!ConfigService.Dev.EnableVOBMeshCulling)
+ return;
- _objectsMedium.ClearAndReleaseMemory();
- _objectsLarge.ClearAndReleaseMemory();
+ base.PostWorldCreate();
- _spheresMedium.ClearAndReleaseMemory();
- _spheresLarge.ClearAndReleaseMemory();
+ foreach (var bucket in _buckets)
+ {
+ bucket.Group.targetCamera = Camera.main;
+ bucket.Group.SetDistanceReferencePoint(ReferencePoint);
+ bucket.Group.SetBoundingDistances(new[]
+ { bucket.Config.CullingDistance, bucket.Config.CullingDistance * HysteresisFactor });
+ bucket.Group.SetBoundingSpheres(bucket.Spheres);
+ bucket.Group.SetBoundingSphereCount(bucket.Count);
+ }
- _cullingGroupMedium.Dispose();
- _cullingGroupLarge.Dispose();
- _cullingGroupMedium = new CullingGroup();
- _cullingGroupLarge = new CullingGroup();
+ // Route every bucket's events to VobChanged with that bucket. The closures are created once here
+ // (not per frame), so adding a size class (see the FIXME on GetBucket) needs no extra handler.
+ foreach (var bucket in _buckets)
+ {
+ var captured = bucket;
+ captured.Group.onStateChanged = evt => VobChanged(evt, captured);
+ }
- _pausedVobs.Clear();
- _pausedVobsToReenable.Clear();
- _pausedVobsToReenableCoroutine.Clear();
+ // Apply the initial state for all VOBs ourselves, as CullingGroup events aren't reliable for
+ // spheres which start inside the first distance band.
+ foreach (var bucket in _buckets)
+ {
+ for (var i = 0; i < bucket.Count; i++)
+ {
+ ApplyStateByDistance(bucket, i);
+ }
+ }
}
+ // All buckets are wired via per-bucket closures in PostWorldCreate; the single-group abstract handler
+ // from the base is unused for the multi-bucket VOB mesh case.
protected override void VisibilityChanged(CullingGroupEvent evt)
{
- VobChanged(evt, _objectsSmall, VobList.Small);
}
- private void VobMediumChanged(CullingGroupEvent evt)
+ ///
+ /// Band 0 - [0...cullingDistance) - VOB is enabled.
+ /// Band 1 - [cullingDistance...*1.15) - Hysteresis zone: VOB keeps its current state to avoid border flickering.
+ /// Band 2 - [cullingDistance*1.15...inf) - VOB is disabled.
+ ///
+ /// We deliberately ignore evt.isVisible: Frustum culling of Renderers is done by Unity/URP itself.
+ /// SetActive() based frustum culling would just duplicate it with main thread costs on every head turn.
+ ///
+ private void VobChanged(CullingGroupEvent evt, CullingBucket bucket)
{
- VobChanged(evt, _objectsMedium, VobList.Medium);
+ if (bucket.Paused[evt.index])
+ return;
+
+ if (evt.currentDistance == 0)
+ SetVobState(bucket, evt.index, true);
+ else if (evt.currentDistance >= 2)
+ SetVobState(bucket, evt.index, false);
}
- private void VobLargeChanged(CullingGroupEvent evt)
+ private void ApplyStateByDistance(CullingBucket bucket, int index)
{
- VobChanged(evt, _objectsLarge, VobList.Large);
+ var sphere = bucket.Spheres[index];
+ var distance = Vector3.Distance(ReferencePoint.position, sphere.position) - sphere.radius;
+
+ SetVobState(bucket, index, distance < bucket.Config.CullingDistance);
}
- ///
- /// Band explanation:
- /// Band 0 - (0m...~5m) - Grace period where a VOB won't get culled (no FC, no OC) - e.g. to ensure a ladder is still in our hands or a light behind us still shines.
- /// Band 1 - (5m...~100m) - Frustum Culling (FC) and Occlusion Culling (OC) will happen - ensure proper performance for culled out, unseen objects.
- /// Band 2 - [~100m-∞) - Items inside last band will always be culled out.
- ///
- private void VobChanged(CullingGroupEvent evt, List vobObjects, VobList vobListType)
+ private void SetVobState(CullingBucket bucket, int index, bool active)
{
- var pausedVobs = _pausedVobs
- .Where(i => i.Value.Item1 == vobListType)
- .Select(i => i.Value.Item2);
-
- if (pausedVobs.Contains(evt.index))
- {
+ var desiredState = active ? ObjectState.Enabled : ObjectState.Disabled;
+ if (bucket.States[index] == desiredState)
return;
- }
- var go = vobObjects[evt.index];
+ bucket.States[index] = desiredState;
- switch (evt.currentDistance)
- {
- case 0: // grace period band - ignore FC and OC, plainly enable the vob!
- vobObjects[evt.index].SetActive(true);
- GlobalEventDispatcher.VobMeshCullingChanged.Invoke(go);
- break;
- default:
- var setActive = evt.hasBecomeVisible || (evt.isVisible && !evt.hasBecomeInvisible);
- vobObjects[evt.index].SetActive(setActive);
+ var go = bucket.Objects[index];
+ go.SetActive(active);
- if (setActive)
- {
- GlobalEventDispatcher.VobMeshCullingChanged.Invoke(go);
- }
-
- break;
+ if (active)
+ {
+ GlobalEventDispatcher.VobMeshCullingChanged.Invoke(go);
}
}
public void AddCullingEntry(VobContainer container)
{
- if (container.Go == null)
+ var go = container.Go;
+ if (go == null)
return;
- // FIXME - Particles (like leaves in the forest) will be handled like big vobs, but could potentially
- // being handled as small ones as leaves shouldn't be visible from 100 of meters away.
+ // Without culling, we simply enable (and therefore lazy-initialize) every VOB.
+ if (!ConfigService.Dev.EnableVOBMeshCulling)
+ {
+ GlobalEventDispatcher.VobMeshCullingChanged.Invoke(go);
+ return;
+ }
+
var bounds = GetLocalBounds(container);
if (!bounds.HasValue)
{
// e.g. ITMICELLO which has no mesh and therefore no cached Bounds.
- // Logger.LogError($"Couldn't find mesh for >{obj}< to be used for CullingGroup. Skipping...");
return;
}
- var sphere = GetSphere(container.Go, bounds.Value);
- var size = sphere.radius * 2;
+ var sphere = GetSphere(go, bounds.Value);
+ var bucket = GetBucket(sphere.radius * 2);
- if (size <= ConfigService.Dev.SmallVOBMeshCullingGroup.MaximumObjectSize)
+ if (bucket.Count == bucket.Spheres.Length)
{
- _objectsSmall.Add(container.Go);
- _spheresSmall.Add(sphere);
-
- // Each time we add an entry, we need to recreate the array for the CullingGroup.
+ bucket.Grow();
if (CurrentState == State.WorldLoaded)
- _cullingGroupSmall.SetBoundingSpheres(_spheresSmall.ToArray());
+ bucket.Group.SetBoundingSpheres(bucket.Spheres);
}
- else if (size <= ConfigService.Dev.MediumVOBMeshCullingGroup.MaximumObjectSize)
- {
- _objectsMedium.Add(container.Go);
- _spheresMedium.Add(sphere);
- // Each time we add an entry, we need to recreate the array for the CullingGroup.
- if (CurrentState == State.WorldLoaded)
- _cullingGroupMedium.SetBoundingSpheres(_spheresMedium.ToArray());
- }
- else
- {
- _objectsLarge.Add(container.Go);
- _spheresLarge.Add(sphere);
+ var index = bucket.Count++;
+ bucket.Spheres[index] = sphere;
+ bucket.Objects[index] = go;
+ bucket.States[index] = ObjectState.Unknown;
+ bucket.Paused[index] = false;
+ _vobIndices[go] = (bucket, index);
- // Each time we add an entry, we need to recreate the array for the CullingGroup.
- if (CurrentState == State.WorldLoaded)
- _cullingGroupLarge.SetBoundingSpheres(_spheresLarge.ToArray());
+ if (CurrentState == State.WorldLoaded)
+ {
+ bucket.Group.SetBoundingSphereCount(bucket.Count);
+ ApplyStateByDistance(bucket, index);
}
}
public void RemoveCullingEntry(VobContainer container)
{
- if (container.Go == null)
+ var go = container.Go;
+ if (go == null || !ConfigService.Dev.EnableVOBMeshCulling)
return;
- var index = _objectsSmall.IndexOf(container.Go);
- if (index >= 0)
- {
- _objectsSmall.RemoveAt(index);
- _spheresSmall.RemoveAt(index);
-
- // Each time we add an entry, we need to recreate the array for the CullingGroup.
- if (CurrentState == State.WorldLoaded)
- _cullingGroupSmall.SetBoundingSpheres(_spheresSmall.ToArray());
-
+ if (!_vobIndices.Remove(go, out var entry))
return;
- }
-
- index = _objectsMedium.IndexOf(container.Go);
- if (index >= 0)
+
+ var (bucket, index) = entry;
+ var lastIndex = bucket.Count - 1;
+
+ // Swap-remove: move the last entry into the freed slot to keep the arrays dense and all indices stable.
+ if (index != lastIndex)
{
- _objectsMedium.RemoveAt(index);
- _spheresMedium.RemoveAt(index);
-
- // Each time we add an entry, we need to recreate the array for the CullingGroup.
- if (CurrentState == State.WorldLoaded)
- _cullingGroupMedium.SetBoundingSpheres(_spheresMedium.ToArray());
-
- return;
+ bucket.Spheres[index] = bucket.Spheres[lastIndex];
+ bucket.Objects[index] = bucket.Objects[lastIndex];
+ bucket.States[index] = bucket.States[lastIndex];
+ bucket.Paused[index] = bucket.Paused[lastIndex];
+ _vobIndices[bucket.Objects[index]] = (bucket, index);
}
- index = _objectsLarge.IndexOf(container.Go);
- if (index >= 0)
+ bucket.Objects[lastIndex] = null;
+ bucket.Count--;
+
+ if (CurrentState == State.WorldLoaded)
+ bucket.Group.SetBoundingSphereCount(bucket.Count);
+
+ // Drop any pending physics tracking for the removed VOB.
+ CancelStopTrackVobPositionUpdates(go);
+ }
+
+ // FIXME - Particles (like leaves in the forest) will be handled like big vobs, but could potentially
+ // being handled as small ones as leaves shouldn't be visible from 100 of meters away.
+ private CullingBucket GetBucket(float size)
+ {
+ foreach (var bucket in _buckets)
{
- _objectsLarge.RemoveAt(index);
- _spheresLarge.RemoveAt(index);
-
- // Each time we add an entry, we need to recreate the array for the CullingGroup.
- if (CurrentState == State.WorldLoaded)
- _cullingGroupLarge.SetBoundingSpheres(_spheresLarge.ToArray());
-
- return;
+ if (size <= bucket.Config.MaximumObjectSize)
+ return bucket;
}
+
+ // Bigger than the large bucket's maximum size? Still large.
+ return _buckets[^1];
}
- private BoundingSphere GetSphere(GameObject go, Bounds bounds)
+ private BoundingSphere GetSphere(GameObject go, Bounds localBounds)
{
- var bboxSize = bounds.size;
- var worldCenter = go.transform.TransformPoint(bounds.center);
+ var worldCenter = go.transform.TransformPoint(localBounds.center);
- // Get the biggest dimension for calculation of object size group.
- var maxDimension = Mathf.Max(bboxSize.x, bboxSize.y, bboxSize.z);
- var sphere = new BoundingSphere(worldCenter, maxDimension / 2); // Radius is half the size.
+ // The sphere needs to enclose the whole (potentially rotated) bbox - i.e. its half diagonal,
+ // not just half of its biggest dimension.
+ var scaledExtents = Vector3.Scale(localBounds.extents, go.transform.lossyScale);
+ var radius = scaledExtents.magnitude;
- return sphere;
+ return new BoundingSphere(worldCenter, radius);
}
///
/// Fetch Mesh Bounds which are in local space. We will later "move" the bbox to the current world space.
- ///
- /// TODO If performance allows it, we could also look dynamically for all the existing meshes inside GO
- /// TODO and look for maximum value for largest mesh. But it should be fine for now.
///
private Bounds? GetLocalBounds(VobContainer container)
{
- var totalBounds = new Bounds();
- AddLocalBounds(container.Vob, ref totalBounds);
+ Bounds? totalBounds = null;
+ AddLocalBounds(container.Vob, container.Vob.Position, ref totalBounds);
- return totalBounds == default ? null : totalBounds;
+ return totalBounds;
}
///
/// VOBs can contain child-VOBs which might be Particles, Lights, etc.
/// We therefore need to sum up the overall Bounds to ensure Culling kicks in correctly.
+ /// Child bounds are offset by their position relative to the root VOB. (Their rotation is ignored,
+ /// but the resulting sphere uses the full bbox diagonal and therefore stays conservative enough.)
///
- private void AddLocalBounds(IVirtualObject vob, ref Bounds totalBounds)
+ private void AddLocalBounds(IVirtualObject vob, NumericsVector3 rootPosition, ref Bounds? totalBounds)
{
Bounds additionalBounds = default;
@@ -333,7 +371,7 @@ private void AddLocalBounds(IVirtualObject vob, ref Bounds totalBounds)
additionalBounds = GetLocalLightBounds((ILight)vob);
break;
default:
- switch (vob.Visual.Type)
+ switch (vob.Visual?.Type)
{
// We don't support Decal and Pfx so far.
case VisualType.Decal:
@@ -347,11 +385,15 @@ private void AddLocalBounds(IVirtualObject vob, ref Bounds totalBounds)
break;
}
- totalBounds.Encapsulate(additionalBounds);
+ if (additionalBounds != default)
+ {
+ additionalBounds.center += (vob.Position - rootPosition).ToUnityVector();
+ Encapsulate(ref totalBounds, additionalBounds);
+ }
foreach (var childVob in vob.Children)
{
- AddLocalBounds(childVob, ref totalBounds);
+ AddLocalBounds(childVob, rootPosition, ref totalBounds);
}
// Fire VOBs children are inside a .zen file
@@ -366,24 +408,32 @@ private void AddLocalBounds(IVirtualObject vob, ref Bounds totalBounds)
return;
}
+ // VobTree positions are local to the fire VOB already.
foreach (var childFireVob in fireWorld!.RootObjects)
{
- AddLocalBounds(childFireVob, ref totalBounds);
+ AddLocalBounds(childFireVob, NumericsVector3.Zero, ref totalBounds);
}
}
}
- private Bounds GetLocalLightBounds(ILight light)
+ private static void Encapsulate(ref Bounds? totalBounds, Bounds additionalBounds)
{
- // FIXME - #1 - Lights shine for the whole mesh they belong to again. :-/
- // FIXME - #2 - When inside a light range, turning to our back will disable the light.
- return new Bounds(Vector3.zero, Vector3.one * light.Range / 100 * 2);
+ if (totalBounds.HasValue)
+ {
+ var bounds = totalBounds.Value;
+ bounds.Encapsulate(additionalBounds);
+ totalBounds = bounds;
+ }
+ else
+ {
+ totalBounds = additionalBounds;
+ }
}
- // TODO - Not yet implemented.
- private Bounds? GetLocalParticleBounds(EventParticleEffect particle)
+ private Bounds GetLocalLightBounds(ILight light)
{
- return null;
+ // FIXME - Lights shine for the whole mesh they belong to again. :-/
+ return new Bounds(Vector3.zero, Vector3.one * light.Range / 100 * 2);
}
private Bounds GetLocalMeshBounds(IVirtualObject vob)
@@ -423,37 +473,6 @@ private Bounds GetLocalMeshBounds(IVirtualObject vob)
}
}
- ///
- /// Set main camera once world is loaded fully.
- /// Doesn't work at loading time as we change scenes etc.
- ///
- public override void PostWorldCreate()
- {
- base.PostWorldCreate();
-
- var mainCamera = Camera.main!;
-
- _cullingGroupMedium.targetCamera = mainCamera;
- _cullingGroupLarge.targetCamera = mainCamera;
-
- _cullingGroupMedium.SetDistanceReferencePoint(mainCamera.transform);
- _cullingGroupLarge.SetDistanceReferencePoint(mainCamera.transform);
-
- _cullingGroupMedium.onStateChanged = VobMediumChanged;
- _cullingGroupLarge.onStateChanged = VobLargeChanged;
-
- _cullingGroupSmall.SetBoundingDistances(new[]
- { _gracePeriodCullingDistance, ConfigService.Dev.SmallVOBMeshCullingGroup.CullingDistance });
- _cullingGroupMedium.SetBoundingDistances(new[]
- { _gracePeriodCullingDistance, ConfigService.Dev.MediumVOBMeshCullingGroup.CullingDistance });
- _cullingGroupLarge.SetBoundingDistances(new[]
- { _gracePeriodCullingDistance, ConfigService.Dev.LargeVOBMeshCullingGroup.CullingDistance });
-
- _cullingGroupSmall.SetBoundingSpheres(_spheresSmall.ToArray());
- _cullingGroupMedium.SetBoundingSpheres(_spheresMedium.ToArray());
- _cullingGroupLarge.SetBoundingSpheres(_spheresLarge.ToArray());
- }
-
public void StartTrackVobPositionUpdates(GameObject go)
{
if (!ConfigService.Dev.EnableVOBMeshCulling)
@@ -464,40 +483,14 @@ public void StartTrackVobPositionUpdates(GameObject go)
CancelStopTrackVobPositionUpdates(rootGo);
- // Entry is already in list
- if (_pausedVobs.ContainsKey(rootGo))
+ if (!_vobIndices.TryGetValue(rootGo, out var entry))
{
- return;
- }
-
- // Check Small list
- var index = _objectsSmall.IndexOf(rootGo);
- var vobType = VobList.Small;
-
- // Check Medium list
- if (index == -1)
- {
- index = _objectsMedium.IndexOf(rootGo);
- vobType = VobList.Medium;
- }
-
- // Check Large list
- if (index == -1)
- {
- index = _objectsLarge.IndexOf(rootGo);
- vobType = VobList.Large;
- }
-
- if (index == -1)
- {
- // Dynamically spawned items (loot panel, backpack fill) are not registered in the culling lists.
- // This is expected — just skip tracking for them.
- Logger.LogWarning($"VOB {rootGo.name} not in culling list — skipping position tracking (dynamically spawned).",
+ Logger.LogError($"Couldn't find object in Culling list {rootGo.name}. Culling updates will break.",
LogCat.Vob);
return;
}
- _pausedVobs.Add(rootGo, new Tuple(vobType, index));
+ entry.Bucket.Paused[entry.Index] = true;
}
///
@@ -506,16 +499,12 @@ public void StartTrackVobPositionUpdates(GameObject go)
///
private void CancelStopTrackVobPositionUpdates(GameObject rootGo)
{
- if (_pausedVobsToReenableCoroutine.ContainsKey(rootGo))
+ if (_pausedVobsToReenableCoroutine.Remove(rootGo, out var coroutine))
{
- _unityMonoService.StopCoroutine(_pausedVobsToReenableCoroutine[rootGo]);
- _pausedVobsToReenableCoroutine.Remove(rootGo);
+ _unityMonoService.StopCoroutine(coroutine);
}
- if (_pausedVobsToReenable.ContainsKey(rootGo))
- {
- _pausedVobsToReenable.Remove(rootGo);
- }
+ _pausedVobsToReenable.Remove(rootGo);
}
///
@@ -526,7 +515,7 @@ public void StopTrackVobPositionUpdates(GameObject go)
{
if (!ConfigService.Dev.EnableVOBMeshCulling)
return;
-
+
// Meshes are always 1...n levels below initially created VobLoader GO. Therefore, we need to fetch its parent for track updates.
var rootGo = go.GetComponentInParent().gameObject;
@@ -547,15 +536,7 @@ private IEnumerator StopTrackVobPositionUpdatesDelayed(GameObject rootGo)
{
yield return new WaitForSeconds(1f);
_pausedVobsToReenableCoroutine.Remove(rootGo);
-
- // GO may have been destroyed (e.g., loot panel closed) during the 1-second delay.
- if (rootGo == null)
- yield break;
-
- if (!_pausedVobsToReenable.ContainsKey(rootGo))
- {
- _pausedVobsToReenable.Add(rootGo, rootGo.GetComponentInChildren());
- }
+ _pausedVobsToReenable.TryAdd(rootGo, rootGo.GetComponentInChildren());
}
///
@@ -568,53 +549,37 @@ private IEnumerator StopVobTrackingBasedOnVelocity()
{
while (true)
{
- for (var i = _pausedVobsToReenable.Keys.Count - 1; i >= 0; i--)
+ if (_pausedVobsToReenable.Count != 0)
{
- var key = _pausedVobsToReenable.Keys.ElementAt(i);
- var rigidBody = _pausedVobsToReenable[key];
- if (rigidBody == null)
+ _vobsAtRestScratch.Clear();
+
+ foreach (var pausedVob in _pausedVobsToReenable)
{
- _pausedVobsToReenable.Remove(key);
- continue;
+ if (pausedVob.Value.linearVelocity == Vector3.zero)
+ {
+ _vobsAtRestScratch.Add(pausedVob.Key);
+ }
}
- if (rigidBody.linearVelocity != Vector3.zero)
+
+ foreach (var go in _vobsAtRestScratch)
{
- continue;
+ _pausedVobsToReenable[go].isKinematic = true;
+ _pausedVobsToReenable.Remove(go);
+ ResumeCullingAtCurrentPosition(go);
}
-
- // Item may not be in _pausedVobs if it was dynamically spawned and never registered in culling lists.
- if (_pausedVobs.ContainsKey(key))
- UpdateSpherePosition(key);
-
- rigidBody.isKinematic = true;
- _pausedVobs.Remove(key);
- _pausedVobsToReenable.Remove(key);
}
yield return null;
}
}
- private void UpdateSpherePosition(GameObject go)
+ private void ResumeCullingAtCurrentPosition(GameObject go)
{
- var grabbed = _pausedVobs[go];
- var vobType = grabbed.Item1;
- var index = grabbed.Item2;
-
- // We need to find the GO's correlated Sphere in the right VobArray.
- var sphereList = vobType switch
- {
- VobList.Small => _spheresSmall,
- VobList.Medium => _spheresMedium,
- VobList.Large => _spheresLarge,
- _ => throw new ArgumentOutOfRangeException()
- };
-
- // Index may be stale if other VOBs were removed from the list after this item was grabbed.
- if (index >= sphereList.Count)
+ if (!_vobIndices.TryGetValue(go, out var entry))
return;
- sphereList[index] = new BoundingSphere(go.transform.position, sphereList[index].radius);
+ entry.Bucket.Spheres[entry.Index].position = go.transform.position;
+ entry.Bucket.Paused[entry.Index] = false;
}
public override void OnApplicationQuit()
@@ -622,9 +587,10 @@ public override void OnApplicationQuit()
if (!ConfigService.Dev.EnableVOBMeshCulling)
return;
- _cullingGroupSmall.Dispose();
- _cullingGroupMedium.Dispose();
- _cullingGroupLarge.Dispose();
+ foreach (var bucket in _buckets)
+ {
+ bucket.Group.Dispose();
+ }
}
}
}
diff --git a/Assets/Gothic-Core/Scripts/Domain/Culling/VobSoundCullingDomain.cs b/Assets/Gothic-Core/Scripts/Domain/Culling/VobSoundCullingDomain.cs
index 3264bdbae..9dfc21f20 100644
--- a/Assets/Gothic-Core/Scripts/Domain/Culling/VobSoundCullingDomain.cs
+++ b/Assets/Gothic-Core/Scripts/Domain/Culling/VobSoundCullingDomain.cs
@@ -1,3 +1,4 @@
+using System;
using UnityEngine;
using ZenKit.Vobs;
@@ -5,9 +6,22 @@ namespace Gothic.Core.Domain.Culling
{
public class VobSoundCullingDomain : AbstractCullingDomain
{
- public void AddCullingEntry(GameObject go, ISound vob)
+ private const int _initialCapacity = 64;
+
+ // Shared by reference with the CullingGroup. Grows by doubling. The used entry amount is communicated
+ // via SetBoundingSphereCount() - i.e. add/remove won't reallocate (and copy) the array each time.
+ private BoundingSphere[] _spheres = new BoundingSphere[_initialCapacity];
+ private GameObject[] _objects = new GameObject[_initialCapacity];
+ private int _count;
+
+
+ public override void PreWorldCreate()
{
- AddCullingEntryInternal(go, vob);
+ base.PreWorldCreate();
+
+ _spheres = new BoundingSphere[_initialCapacity];
+ _objects = new GameObject[_initialCapacity];
+ _count = 0;
}
///
@@ -15,21 +29,27 @@ public void AddCullingEntry(GameObject go, ISound vob)
/// 1. If In World loading state, we add all entries to the list based on rootVob position (e.g., a soundVob directly below levelCompo)
/// 2. If After Loading, then added entries are subVobs (e.g., Cauldron->Sound) and we enlarge the cullingArray now.
///
- private void AddCullingEntryInternal(GameObject go, ISound vob)
+ public void AddCullingEntry(GameObject go, ISound vob)
{
- Objects.Add(go);
-
+ if (_count == _spheres.Length)
+ {
+ Array.Resize(ref _spheres, _count * 2);
+ Array.Resize(ref _objects, _count * 2);
+
+ if (CurrentState == State.WorldLoaded)
+ CullingGroup.SetBoundingSpheres(_spheres);
+ }
+
+ var index = _count++;
+ _objects[index] = go;
// FIXME - First call of VisibilityChanged() always provides visible=false? Is the pos+radius correct?
- var sphere = new BoundingSphere(go.transform.position, vob.Radius / 100f); // Gothic's values are in cm, Unity's in m.
- Spheres.Add(sphere);
+ _spheres[index] = new BoundingSphere(go.transform.position, vob.Radius / 100f); // Gothic's values are in cm, Unity's in m.
+ // Sub-VOB sounds (e.g. Cauldron->Sound) are added after the world finished loading.
if (CurrentState == State.WorldLoaded)
- {
- // Each time we add an entry, we need to recreate the array for the CullingGroup.
- CullingGroup.SetBoundingSpheres(Spheres.ToArray());
- }
+ CullingGroup.SetBoundingSphereCount(_count);
}
-
+
///
/// We only check for distance band 0 - visible, and 0 - invisible (or to be more precise here: audible/inaudible)
///
@@ -37,8 +57,8 @@ protected override void VisibilityChanged(CullingGroupEvent evt)
{
// A higher distance level means "inaudible" as we only leverage: 0 -> in-range; 1 -> out-of-range.
var inAudibleRange = evt.currentDistance == 0;
- var go = Objects[evt.index];
-
+ var go = _objects[evt.index];
+
go.SetActive(inAudibleRange);
if (inAudibleRange)
@@ -59,7 +79,8 @@ public override void PostWorldCreate()
// Hint: As there are non-spatial sounds (always same volume wherever we are),
// we need to disable the sounds at exactly the spot we are.
CullingGroup.SetBoundingDistances(new[] { 0f });
- CullingGroup.SetBoundingSpheres(Spheres.ToArray());
+ CullingGroup.SetBoundingSpheres(_spheres);
+ CullingGroup.SetBoundingSphereCount(_count);
CullingGroup.onStateChanged = VisibilityChanged;
}
}
diff --git a/Assets/Gothic-Core/Scripts/Services/Culling/AbstractCullingService.cs b/Assets/Gothic-Core/Scripts/Services/Culling/AbstractCullingService.cs
index ad58ebeee..d4d787022 100644
--- a/Assets/Gothic-Core/Scripts/Services/Culling/AbstractCullingService.cs
+++ b/Assets/Gothic-Core/Scripts/Services/Culling/AbstractCullingService.cs
@@ -12,6 +12,11 @@ public virtual void Init()
RegisterEventHandlers();
}
+ public void PreWorldCreate()
+ {
+ Domain.PreWorldCreate();
+ }
+
public void OnApplicationQuit()
{
UnregisterEventHandlers();
diff --git a/Assets/Gothic-Core/Scripts/Services/Culling/NpcMeshCullingService.cs b/Assets/Gothic-Core/Scripts/Services/Culling/NpcMeshCullingService.cs
index 6f3f7fbc1..0b3c6f17f 100644
--- a/Assets/Gothic-Core/Scripts/Services/Culling/NpcMeshCullingService.cs
+++ b/Assets/Gothic-Core/Scripts/Services/Culling/NpcMeshCullingService.cs
@@ -21,6 +21,15 @@ public void AddCullingEntry(GameObject go)
_npcDomain.AddCullingEntry(go);
}
+ ///
+ /// The time-based routine of an NPC changed. The culling domain moves spheres of culled NPCs to the new
+ /// scheduled waypoint, so that off-screen NPCs progress their daily routine.
+ ///
+ public void NotifyNpcRoutineChanged(NpcContainer npc)
+ {
+ _npcDomain.OnNpcRoutineChanged(npc);
+ }
+
public void Update()
{
_npcDomain.Update();