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();