using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using BrewMonster; using BrewMonster.Network; using BrewMonster.Scripts; using Cysharp.Threading.Tasks; using UnityEngine; public class LitModelHolder : MonoSingleton { #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 _candidatesForLoading = new Dictionary(); private HashSet _loadedObjects = new HashSet(); private List _objectsToUnload = new List(); 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(); } } #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(); } /// /// Immediately load the objects that are close to the host player.
/// Run this once at the beginning of the game. So we can load all the objects that are close to the host player. ///
/// /// 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. //In c++ version. clone object position is used as the host object position when force navigate. if(_hostPlayer.IsInForceNavigateState()) { _currentHostPosOxz = _hostPlayer.GetNavigatePlayer().GetNavigateModelPosition(); } else { _currentHostPosOxz = _hostPlayer.GetPosVector3(); } _currentHostPosOxz.y = 0; LoadAllObjectsNearPosition(_currentHostPosOxz, destroyToken); } /// /// This function will load all the objects that are close to the host player immediately.

/// Use this once at the beginning of the game. Or when player is revived/GOTO/Teleported. ///
/// 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 objectsToLoad = new List(); 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); } /// /// This function will be running continuously in the background. /// /// /// 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; _loadImmediateDistance = EC_Game.GetSettingViewDistance().fShow; _unloadDistance = EC_Game.GetSettingViewDistance().fHide; immediateSqr = _loadImmediateDistance * _loadImmediateDistance; paddingSqr = _unloadDistance * _unloadDistance; TickStreaming(_currentHostPosOxz, immediateSqr, paddingSqr, destroyToken); await Task.Delay(intervalMs, destroyToken); } } /// /// 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. /// /// private void TickStreaming(Vector3 hostPos, float immediateSqr, float paddingSqr, CancellationToken destroyToken) { if (addressableObjects == null || addressableObjects.Length == 0) { return; } int count = addressableObjects.Length; lock (_objectsToUnload) { 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)) { //BMLogger.Log($"LitModelHolder: Added object to candidates for loading: {_currentObjectToCheck.assetPath}"); _candidatesForLoading[_currentObjectToCheck] = _realTimeSinceStartUp; } } else if (distSqr > paddingSqr) { if (_currentObjectToCheck.IsLoaded || _currentObjectToCheck.IsLoading) { if (!_objectsToUnload.Contains(_currentObjectToCheck)) { //BMLogger.Log($"LitModelHolder: Added object to unload: {_currentObjectToCheck.assetPath}"); _objectsToUnload.Add(_currentObjectToCheck); } } } } } _needToProcessLoadAndUnload = true; } private void ProcessLoadAndUnloadObjects() { _currentIdxAsset = 0; _maxIdxAsset = _candidatesForLoading.Count; // double check if we really need to load and unload objects. if (_maxIdxAsset <= 0) { CallBackAssetLoadingDone(); return; } // load the objects that are in the loading range long enough. (_candidateWaitTimeSeconds) foreach (var kvp in _candidatesForLoading) { kvp.Key.LoadAsset(CallBackAssetLoadingDone).Forget(); _loadedObjects.Add(kvp.Key); } _candidatesForLoading.Clear(); // 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]); } _objectsToUnload.Clear(); } int _currentIdxAsset = 0; // The current counter for loaded assets. int _maxIdxAsset = 0; // Limit the number of assets that have finished loading. /// /// isLitToReady is a condition used by _hostPlayer to wait until the Lit assets have finished loading. /// This function counts the number of assets successfully loaded from the Addressable system, /// and once the required number is reached, it sets _hostPlayer.isLitToReady = true. /// private void CallBackAssetLoadingDone() { _currentIdxAsset++; if (_currentIdxAsset >= _maxIdxAsset) { if (_hostPlayer != null) { _hostPlayer.isLitToReady = true; } _currentIdxAsset = -1; _maxIdxAsset = 0; } } /// /// Get the Host Player instance. /// /// private CECHostPlayer GetHostPlayer() { return CECGameRun.Instance.GetHostPlayer(); } private void UpdateHostPlayerPosition() { _hostPlayer = GetHostPlayer(); if (_hostPlayer != null) { if(_hostPlayer.IsInForceNavigateState()) { _currentHostPosOxz = _hostPlayer.GetNavigatePlayer().GetNavigateModelPosition(); } else { _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(); if (addressableObject == null) { addressableObject = originalObject.AddComponent(); } 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(); 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 }