Files
test/Assets/PerfectWorld/Scripts/World/LitModelHolder.cs
T
Le Duc Anh 050bf5e9c8 v
2026-02-21 23:33:08 +07:00

444 lines
14 KiB
C#

using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BrewMonster;
using BrewMonster.Scripts;
using Cysharp.Threading.Tasks;
using UnityEngine;
public class LitModelHolder : MonoSingleton<LitModelHolder>
{
#region Fields
[SerializeField] private AddressableObject[] addressableObjects;
private CECHostPlayer _hostPlayer;
[Header("Distance Streaming")]
[Tooltip("The distance from the host player to the object to be loaded immediately.")]
[SerializeField] private float _loadImmediateDistance = 200f;
[Tooltip("Objects need to go outside of this distance to be unloaded.")]
[SerializeField] private float _unloadDistance = 400f;
[Header("Performance")]
[SerializeField] private float _checkIntervalSeconds = 0.25f;
[Tooltip("How long a candidate object need to be in the immediate loading range to be loaded.")]
[SerializeField] private float _candidateWaitTimeSeconds = 1f;
[Tooltip("The minimum distance the host player needs to move to update the streaming.")]
[SerializeField] private float _minHostMoveToUpdate = 2.0f;
#endregion
#region Private Fields
// when an object enters the immediate loading range, we add it to this dictionary.
// The key is the object, the value is the enter timestamp.
private Dictionary<AddressableObject, float> _candidatesForLoading = new Dictionary<AddressableObject, float>();
private HashSet<AddressableObject> _loadedObjects = new HashSet<AddressableObject>();
private List<AddressableObject> _objectsToUnload = new List<AddressableObject>();
private Vector3 _lastHostPosOxz;
private bool _hasLastHostPos = false;
private Vector3 _currentHostPos;
private Vector3 _currentHostPosOxz;
private bool _hostPosReady = false;
private AddressableObject _currentObjectToCheck; // the object that we're currently checking for loading/unloading.
private bool _needToProcessLoadAndUnload = false;
private float _realTimeSinceStartUp;
private CancellationTokenSource _cts;
#endregion
#region Unity Lifecycle
protected override async void Initialize()
{
_currentHostPos = Vector3.zero;
_currentHostPosOxz = Vector3.zero;
await StartStreamingProcess();
}
private void Update()
{
// run every frame.
// We have to update the MainThread data here. Since other processes run on a different thread.
_realTimeSinceStartUp = Time.realtimeSinceStartup;
UpdateHostPlayerPosition();
// if load flag is set, process the load and unload objects.
if (_needToProcessLoadAndUnload)
{
ProcessLoadAndUnloadObjects();
_needToProcessLoadAndUnload = false;
}
}
private void OnDestroy()
{
if (_cts != null)
{
_cts.Cancel();
_cts.Dispose();
}
if (addressableObjects == null)
{
return;
}
// go through candidate list and loaded list, unload all objects in those lists.
foreach (var kvp in _candidatesForLoading)
{
kvp.Key.UnloadAsset();
}
foreach (var obj in _loadedObjects)
{
obj.UnloadAsset();
}
}
#endregion
#region Private Functions
private async UniTask StartStreamingProcess()
{
if (_cts != null)
{
_cts.Cancel();
_cts.Dispose();
}
_cts = new CancellationTokenSource();
CancellationToken destroyToken = _cts.Token;
// Wait until Addressables are initialized.
while (!AddressableManager.Instance.IsInitialized())
{
await Task.Delay(100, destroyToken);
}
while (_hostPlayer == null)
{
_hostPlayer = GetHostPlayer();
await Task.Delay(100, destroyToken);
continue;
}
while (!_hostPlayer.IsAllResReady())
{
await Task.Delay(100, destroyToken);
continue;
}
// run this once at the beginning of the game. So we can load all the objects that are close to the host player.
await LoadObjectBaseOnDistance(destroyToken);
// Start distance-based streaming loop. This will be running continuously in the background.
UniTask.RunOnThreadPool(async () =>
{
await StreamByDistanceLoop(destroyToken : destroyToken);
}
).Forget();
}
/// <summary>
/// Immediately load the objects that are close to the host player. <br/>
/// Run this once at the beginning of the game. So we can load all the objects that are close to the host player.
/// </summary>
/// <param name="destroyToken"></param>
/// <returns></returns>
private async UniTask LoadObjectBaseOnDistance(CancellationToken destroyToken)
{
if (_unloadDistance < _loadImmediateDistance)
{
_unloadDistance = _loadImmediateDistance;
}
while (_hostPlayer == null)
{
_hostPlayer = GetHostPlayer();
if (_hostPlayer != null) break; // we found the host player.
await UniTask.Delay(10);
continue;
}
await Task.Delay(100, destroyToken); // wait for the host player to be initialized.
_currentHostPosOxz = _hostPlayer.GetPosVector3();
_currentHostPosOxz.y = 0;
LoadAllObjectsNearPosition(_currentHostPosOxz, destroyToken);
}
/// <summary>
/// This function will load all the objects that are close to the host player immediately. <br/> <br/>
/// <b>Use this once at the beginning of the game. Or when player is revived/GOTO/Teleported.</b>
/// </summary>
/// <returns></returns>
private void LoadAllObjectsNearPosition(Vector3 targetPosition, CancellationToken destroyToken)
{
if (addressableObjects == null || addressableObjects.Length == 0)
{
return;
}
if (_unloadDistance < _loadImmediateDistance)
{
_unloadDistance = _loadImmediateDistance;
}
float immediateSqr = _loadImmediateDistance * _loadImmediateDistance;
float paddingSqr = _unloadDistance * _unloadDistance;
List<AddressableObject> objectsToLoad = new List<AddressableObject>();
targetPosition.y = 0; // only consider Oxz plane
// go through all the objects, check for the immediateSqr and load the objects that are in the range.
TickStreaming(targetPosition, immediateSqr, paddingSqr, destroyToken);
}
/// <summary>
/// This function will be running continuously in the background.
/// </summary>
/// <param name="destroyToken"></param>
/// <returns></returns>
private async UniTask StreamByDistanceLoop(CancellationToken destroyToken)
{
if (_unloadDistance < _loadImmediateDistance)
{
_unloadDistance = _loadImmediateDistance;
}
float immediateSqr = _loadImmediateDistance * _loadImmediateDistance;
float paddingSqr = _unloadDistance * _unloadDistance;
int intervalMs = Mathf.Max(10, Mathf.RoundToInt(_checkIntervalSeconds * 1000f));
float minMoveSqr = _minHostMoveToUpdate * _minHostMoveToUpdate;
while (!destroyToken.IsCancellationRequested)
{
if (!_hostPosReady)
{
continue;
}
if (_hasLastHostPos)
{
if ((_currentHostPosOxz - _lastHostPosOxz).sqrMagnitude < minMoveSqr)
{
await Task.Delay(intervalMs, destroyToken);
continue;
}
}
_hasLastHostPos = true;
_lastHostPosOxz = _currentHostPosOxz;
TickStreaming(_currentHostPosOxz, immediateSqr, paddingSqr, destroyToken);
await Task.Delay(intervalMs, destroyToken);
}
}
/// <summary>
/// The main loop of the distance streaming.
/// Go through all the objects and check if they are in the immediate loading range or padding range.
/// </summary>
/// <returns></returns>
private void TickStreaming(Vector3 hostPos, float immediateSqr, float paddingSqr, CancellationToken destroyToken)
{
if (addressableObjects == null || addressableObjects.Length == 0)
{
return;
}
int count = addressableObjects.Length;
for (int i = 0; i < count; i++)
{
_currentObjectToCheck = addressableObjects[i];
if (_currentObjectToCheck == null)
{
continue;
}
float distSqr = (_currentObjectToCheck.ObjectPositionOxz - hostPos).sqrMagnitude;
if (distSqr <= immediateSqr)
{
if (_currentObjectToCheck.IsLoaded || _currentObjectToCheck.IsLoading)
{
continue;
}
if (!_candidatesForLoading.ContainsKey(_currentObjectToCheck))
{
_candidatesForLoading[_currentObjectToCheck] = _realTimeSinceStartUp;
}
}
else if (distSqr > paddingSqr)
{
if (_currentObjectToCheck.IsLoaded || _currentObjectToCheck.IsLoading)
{
if (!_objectsToUnload.Contains(_currentObjectToCheck))
{
_objectsToUnload.Add(_currentObjectToCheck);
}
}
}
}
_needToProcessLoadAndUnload = true;
}
private void ProcessLoadAndUnloadObjects()
{
// load the objects that are in the loading range long enough. (_candidateWaitTimeSeconds)
foreach (var kvp in _candidatesForLoading)
{
kvp.Key.LoadAsset().Forget();
_loadedObjects.Add(kvp.Key);
}
// remove the loaded objects from the candidates for loading list.
foreach (var obj in _loadedObjects)
{
_candidatesForLoading.Remove(obj);
}
// unload the objects that are too far away.
for (int i = 0; i < _objectsToUnload.Count; i++)
{
_objectsToUnload[i].UnloadAsset();
// remove the object from the candidates for loading and loaded objects lists.
_loadedObjects.Remove(_objectsToUnload[i]);
_candidatesForLoading.Remove(_objectsToUnload[i]);
}
}
/// <summary>
/// Get the Host Player instance.
/// </summary>
/// <returns></returns>
private CECHostPlayer GetHostPlayer()
{
return CECGameRun.Instance.GetHostPlayer();
}
private void UpdateHostPlayerPosition()
{
_hostPlayer = GetHostPlayer();
if (_hostPlayer != null)
{
_currentHostPosOxz = _hostPlayer.GetPosVector3(false);
_currentHostPosOxz.y = 0;
_hostPosReady = true;
}
}
#endregion
#region Public Functions
public async UniTask LoadAllObjectsNearTargetPosition(Vector3 targetPosition = default, bool useHostPlayerPosition = false)
{
if (!useHostPlayerPosition)
{
_currentHostPosOxz = targetPosition;
_currentHostPosOxz.y = 0;
}
else
{
while (_hostPlayer == null)
{
UpdateHostPlayerPosition();
await Task.Delay(10);
continue;
}
}
UniTask.RunOnThreadPool(() => {LoadAllObjectsNearPosition(_currentHostPosOxz, _cts.Token);}).Forget();
}
#endregion
// Keep this part of the code for the initial setup of the addressable objects.
// We have to run this once on every scene.
#if UNITY_EDITOR
[Space(10)]
[Header("FOR EDITOR SETUP ONLY")]
[SerializeField] private GameObject[] originalObjects;
[ContextMenu("Setup Addressable Objects")]
public void SetupAddressableObjects()
{
foreach (var originalObject in originalObjects)
{
var addressableObject = originalObject.GetComponent<AddressableObject>();
if (addressableObject == null)
{
addressableObject = originalObject.AddComponent<AddressableObject>();
}
if (addressableObject != null)
{
addressableObject.GetAssetPath();
}
}
}
[ContextMenu("Validate Addressable Objects")]
private void ValidateAddressableObjects()
{
if (addressableObjects == null || addressableObjects.Length == 0)
{
Debug.LogWarning("[LitModelHolder] addressableObjects is empty.");
return;
}
int nullCount = 0;
int emptyPathCount = 0;
int multiChildCount = 0;
var pathCounts = new System.Collections.Generic.Dictionary<string, int>();
for (int i = 0; i < addressableObjects.Length; i++)
{
AddressableObject obj = addressableObjects[i];
if (obj == null)
{
nullCount++;
continue;
}
if (string.IsNullOrEmpty(obj.assetPath))
{
emptyPathCount++;
}
else
{
pathCounts.TryGetValue(obj.assetPath, out int c);
pathCounts[obj.assetPath] = c + 1;
}
// If this is > 1 it often indicates duplicate instantiation under the anchor.
if (obj.transform.childCount > 1)
{
multiChildCount++;
}
}
int duplicatePathKeys = 0;
foreach (var kvp in pathCounts)
{
if (kvp.Value > 1)
{
duplicatePathKeys++;
}
}
Debug.Log($"[LitModelHolder] ValidateAddressableObjects:\n" +
$"- total={addressableObjects.Length}\n" +
$"- nullEntries={nullCount}\n" +
$"- emptyAssetPath={emptyPathCount}\n" +
$"- duplicateAssetPathKeys={duplicatePathKeys}\n" +
$"- anchorsWithMoreThanOneChild={multiChildCount}");
}
#endif
}