494 lines
19 KiB
C#
494 lines
19 KiB
C#
using UnityEngine;
|
|
using Cysharp.Threading.Tasks;
|
|
using System.Threading;
|
|
using BrewMonster.Network;
|
|
using BrewMonster.Scripts;
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
|
|
#if UNITY_EDITOR
|
|
using UnityEditor;
|
|
#endif
|
|
|
|
namespace BrewMonster
|
|
{
|
|
public class TerrainHolder : MonoBehaviour
|
|
{
|
|
[SerializeField] private AddressableObject[] _addressableObjects;
|
|
[SerializeField] private float _loadImmediateDistance => EC_Game.GetSettingViewDistance().fShow; //150f;
|
|
[SerializeField] private float _unloadDistance => EC_Game.GetSettingViewDistance().fHide; //300f;
|
|
[SerializeField] private float _minHostMoveToUpdate = 5f;
|
|
|
|
private List<AddressableObject> _candidatesForLoading = new List<AddressableObject>();
|
|
private List<AddressableObject> _objectsToUnload = new List<AddressableObject>();
|
|
|
|
private CECHostPlayer _hostPlayer;
|
|
private Vector3 _lastHostPosOxz;
|
|
private bool _hasLastHostPos = false;
|
|
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;
|
|
|
|
#region Unity Lifecycle
|
|
private void Awake()
|
|
{
|
|
_cts = new CancellationTokenSource();
|
|
|
|
StartStreamingProcess(_cts.Token);
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
UpdateGlobalDataForStreaming();
|
|
if (_needToProcessLoadAndUnload)
|
|
{
|
|
ProcessLoadAndUnload();
|
|
_needToProcessLoadAndUnload = false;
|
|
}
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
// unload all the addressable objects.
|
|
// foreach (var addressableObject in _addressableObjects)
|
|
// {
|
|
// if (addressableObject == null) continue;
|
|
// addressableObject.UnloadAsset();
|
|
// }
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
// cancel the streaming process.
|
|
if (_cts != null)
|
|
{
|
|
_cts.Cancel();
|
|
_cts.Dispose();
|
|
}
|
|
}
|
|
#endregion
|
|
|
|
#region private functions
|
|
// This function is expected to be called once at the beginning of a world (scene)
|
|
private void StartStreamingProcess(CancellationToken destroyToken)
|
|
{
|
|
if (destroyToken.IsCancellationRequested) return;
|
|
|
|
// Get the host player position from the UnityGameSession. This when user choose a role and enter a world (scene).
|
|
_currentHostPosOxz = new Vector3(UnityGameSession.Instance.GetRoleInfo().posx, 0f, UnityGameSession.Instance.GetRoleInfo().posz);
|
|
|
|
UniTask.RunOnThreadPool(async () =>
|
|
{
|
|
await StreamByDistanceLoop(destroyToken);
|
|
}).Forget();
|
|
}
|
|
|
|
/// <summary>
|
|
/// This is the main loop that keep checking the objects that are in the immediate loading range or padding range.
|
|
/// If the host moved too little, we will skip the streaming.
|
|
/// </summary>
|
|
/// <param name="destroyToken"></param>
|
|
/// <returns></returns>
|
|
private async UniTask StreamByDistanceLoop(CancellationToken destroyToken)
|
|
{
|
|
if (_addressableObjects == null || _addressableObjects.Length == 0)
|
|
return;
|
|
|
|
//if (_unloadDistance < _loadImmediateDistance)
|
|
// _unloadDistance = _loadImmediateDistance;
|
|
|
|
float immediateSqr = _loadImmediateDistance * _loadImmediateDistance;
|
|
float paddingSqr = _unloadDistance * _unloadDistance;
|
|
float minMoveSqr = _minHostMoveToUpdate * _minHostMoveToUpdate;
|
|
|
|
|
|
while (!destroyToken.IsCancellationRequested)
|
|
{
|
|
if (!_hostPosReady)
|
|
{
|
|
await UniTask.Delay(1000, cancellationToken: destroyToken);
|
|
continue;
|
|
}
|
|
|
|
if (_hasLastHostPos)
|
|
{
|
|
if ((_currentHostPosOxz - _lastHostPosOxz).sqrMagnitude < minMoveSqr)
|
|
{
|
|
await UniTask.Delay(1000, cancellationToken: destroyToken);
|
|
continue;
|
|
}
|
|
}
|
|
_hasLastHostPos = true;
|
|
_lastHostPosOxz = _currentHostPosOxz;
|
|
|
|
TickStreaming(_currentHostPosOxz, immediateSqr, paddingSqr);
|
|
}
|
|
|
|
}
|
|
|
|
private void TickStreaming(Vector3 targetPos, float immediateSqr, float paddingSqr)
|
|
{
|
|
if (_addressableObjects == null || _addressableObjects.Length == 0)
|
|
return;
|
|
|
|
|
|
int count = _addressableObjects.Length;
|
|
lock (_objectsToUnload)
|
|
{
|
|
targetPos.y = 0f; // we only consider the Oxz plane.
|
|
float distanceSqr = 0f;
|
|
for (int i = 0; i < count; i++)
|
|
{
|
|
_currentObjectToCheck = _addressableObjects[i];
|
|
|
|
if (_currentObjectToCheck == null) continue;
|
|
|
|
distanceSqr = (_currentObjectToCheck.ObjectPositionOxz - targetPos).sqrMagnitude;
|
|
if (distanceSqr <= immediateSqr)
|
|
{
|
|
if (_currentObjectToCheck.IsLoaded || _currentObjectToCheck.IsLoading)
|
|
{
|
|
continue;
|
|
}
|
|
if (!_candidatesForLoading.Contains(_currentObjectToCheck))
|
|
{
|
|
_candidatesForLoading.Add(_currentObjectToCheck);
|
|
}
|
|
}
|
|
else if (distanceSqr > paddingSqr)
|
|
{
|
|
if (_currentObjectToCheck.IsLoaded || _currentObjectToCheck.IsLoading)
|
|
{
|
|
if (!_objectsToUnload.Contains(_currentObjectToCheck))
|
|
{
|
|
_objectsToUnload.Add(_currentObjectToCheck);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_needToProcessLoadAndUnload = true;
|
|
//if (_hostPlayer != null)
|
|
//{
|
|
// Debug.LogError("ProcessLoadAndUnload Terain _hostPlayer.isTerrainToReady = false");
|
|
// _hostPlayer.isTerrainToReady = false;
|
|
//}
|
|
}
|
|
|
|
}
|
|
|
|
/// <summary>
|
|
/// process to call Load and Unload on each addressable object that we need to.
|
|
/// </summary>
|
|
private void ProcessLoadAndUnload()
|
|
{
|
|
_currentIdxAsset = 0;
|
|
_maxIdxAsset = _candidatesForLoading.Count;
|
|
for (int i = 0; i < _candidatesForLoading.Count; i++)
|
|
{
|
|
_candidatesForLoading[i].LoadAsset(CallBackAssetLoadingDone).Forget();
|
|
}
|
|
_candidatesForLoading.Clear();
|
|
|
|
for (int i = 0; i < _objectsToUnload.Count; i++)
|
|
{
|
|
_objectsToUnload[i].UnloadAsset();
|
|
}
|
|
_objectsToUnload.Clear();
|
|
}
|
|
|
|
int _currentIdxAsset = 0; // The current counter for loaded assets.
|
|
int _maxIdxAsset = 0; // Limit the number of assets that have finished loading.
|
|
|
|
/// <summary>
|
|
/// isLitToReady is a condition used by _hostPlayer to wait until the Terrain 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.
|
|
/// </summary>
|
|
private void CallBackAssetLoadingDone()
|
|
{
|
|
_currentIdxAsset++;
|
|
if (_currentIdxAsset >= _maxIdxAsset)
|
|
{
|
|
if (_hostPlayer != null)
|
|
{
|
|
_hostPlayer.isTerrainToReady = true;
|
|
}
|
|
_currentIdxAsset = -1;
|
|
_maxIdxAsset = 0;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Main-thread anchor for terrain streaming: host position, or navigate clone when force-navigate is active (same idea as LitModelHolder).
|
|
/// 地形流式锚点:普通用宿主;强制导航用导航克隆(与 LitModelHolder 一致)。
|
|
/// </summary>
|
|
private void UpdateGlobalDataForStreaming()
|
|
{
|
|
_realTimeSinceStartUp = Time.realtimeSinceStartup;
|
|
if (_hostPlayer == null)
|
|
_hostPlayer = CECGameRun.Instance.GetHostPlayer();
|
|
|
|
if (_hostPlayer == null)
|
|
{
|
|
_hostPosReady = false;
|
|
return;
|
|
}
|
|
//In c++ version. clone object position is used as the host object position when force navigate.
|
|
if (_hostPlayer.IsInForceNavigateState())
|
|
{
|
|
CECHostNavigatePlayer nav = _hostPlayer.GetNavigatePlayer();
|
|
if (nav != null && nav.TryGetNavigateModelPosition(out Vector3 clonePos))
|
|
{
|
|
_currentHostPosOxz = clonePos;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_currentHostPosOxz = _hostPlayer.GetPosVector3(false);
|
|
}
|
|
_currentHostPosOxz.y = 0f;
|
|
_hostPosReady = true;
|
|
}
|
|
#endregion
|
|
|
|
|
|
|
|
#if UNITY_EDITOR
|
|
private int MASK_TEXTURE_HASH = Shader.PropertyToID("_MaskTexture");
|
|
private int MASK_TEXTURE2_HASH = Shader.PropertyToID("_MaskTexture2");
|
|
private const string _terrainPathPrefix = "Assets/ModelRenderer/Art/Terrain";
|
|
private const string _terrainPathPrefixNotAsset = "ModelRenderer/Art/Terrain";
|
|
|
|
[Space(10)]
|
|
[Header("FOR EDITOR SETUP ONLY")]
|
|
[SerializeField] private GameObject[] _originalObjects;
|
|
[SerializeField] private string _worldName;
|
|
|
|
|
|
[ContextMenu("Update Asset Paths")]
|
|
private void UpdateAssetPaths()
|
|
{
|
|
for (int i = 0; i < _addressableObjects.Length; i++)
|
|
{
|
|
AddressableObject addressableObject = _addressableObjects[i];
|
|
if (addressableObject == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
string assetPath = addressableObject.assetPath;
|
|
assetPath = assetPath.Replace(".mesh", ".prefab");
|
|
assetPath = assetPath.Replace("Assets/ModelRenderer/Art/Terrain/", "");
|
|
|
|
addressableObject.assetPath = assetPath;
|
|
}
|
|
}
|
|
|
|
[ContextMenu("Setup Addressable Objects")]
|
|
private void SetupAddressableObjects()
|
|
{
|
|
if (_originalObjects == null || _originalObjects.Length == 0)
|
|
{
|
|
Debug.LogWarning("[TerrainHolder] _originalObjects is empty.");
|
|
return;
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(_worldName))
|
|
{
|
|
Debug.LogError("[TerrainHolder] _worldName is empty. Please set it before running setup.");
|
|
return;
|
|
}
|
|
|
|
string worldFolder = $"{_terrainPathPrefix}/{_worldName}";
|
|
EnsureFolderExists(worldFolder);
|
|
|
|
var createdAddressableObjects = new List<AddressableObject>(_originalObjects.Length);
|
|
|
|
for (int i = 0; i < _originalObjects.Length; i++)
|
|
{
|
|
GameObject currentTerrainObject = _originalObjects[i];
|
|
if (currentTerrainObject == null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Mesh mesh = GetFirstMesh(currentTerrainObject);
|
|
Material material = currentTerrainObject.GetComponent<Renderer>().sharedMaterial;
|
|
Texture2D maskTexture = material.GetTexture(MASK_TEXTURE_HASH) as Texture2D;
|
|
Texture2D maskTexture2 = material.GetTexture(MASK_TEXTURE2_HASH) as Texture2D;
|
|
if (mesh == null)
|
|
{
|
|
Debug.LogWarning($"[TerrainHolder] No Mesh found on '{currentTerrainObject.name}', skipping.");
|
|
continue;
|
|
}
|
|
|
|
string safeName = MakeSafeFileName(currentTerrainObject.name);
|
|
string meshAssetPath = AssetDatabase.GenerateUniqueAssetPath($"{worldFolder}/{safeName}.mesh");
|
|
string materialAssetPath = AssetDatabase.GenerateUniqueAssetPath($"{worldFolder}/{safeName}.mat");
|
|
string maskTextureAssetPath = AssetDatabase.GenerateUniqueAssetPath($"{worldFolder}/{safeName}_MaskTexture.png");
|
|
string maskTexture2AssetPath = AssetDatabase.GenerateUniqueAssetPath($"{worldFolder}/{safeName}_MaskTexture2.png");
|
|
|
|
string maskTextureAbsolutePath = Path.Combine(Application.dataPath, maskTextureAssetPath.Substring(7));
|
|
string maskTexture2AbsolutePath = Path.Combine(Application.dataPath, maskTexture2AssetPath.Substring(7));
|
|
|
|
|
|
AssetDatabase.CreateAsset(mesh, meshAssetPath);
|
|
AssetDatabase.CreateAsset(material, materialAssetPath);
|
|
|
|
AAssit.SaveTexture2DToPNG(maskTexture, maskTextureAbsolutePath);
|
|
AAssit.SaveTexture2DToPNG(maskTexture2, maskTexture2AbsolutePath);
|
|
AssetDatabase.Refresh();
|
|
|
|
// load mesh from asset
|
|
var meshCopy = AssetDatabase.LoadAssetAtPath<Mesh>(meshAssetPath);
|
|
var materialCopy = AssetDatabase.LoadAssetAtPath<Material>(materialAssetPath);
|
|
var maskTextureCopy = AssetDatabase.LoadAssetAtPath<Texture2D>(maskTextureAssetPath);
|
|
var maskTexture2Copy = AssetDatabase.LoadAssetAtPath<Texture2D>(maskTexture2AssetPath);
|
|
|
|
if (maskTextureCopy != null)
|
|
{
|
|
materialCopy.SetTexture(MASK_TEXTURE_HASH, maskTextureCopy);
|
|
}
|
|
if (maskTexture2Copy != null)
|
|
{
|
|
materialCopy.SetTexture(MASK_TEXTURE2_HASH, maskTexture2Copy);
|
|
}
|
|
|
|
// set the mesh to the current terrain object
|
|
currentTerrainObject.GetComponent<MeshFilter>().sharedMesh = meshCopy;
|
|
currentTerrainObject.GetComponent<Renderer>().sharedMaterial = materialCopy;
|
|
|
|
// Create the addressable anchor object with matching transform.
|
|
var newGo = new GameObject(currentTerrainObject.name);
|
|
Transform srcTr = currentTerrainObject.transform;
|
|
Transform dstTr = newGo.transform;
|
|
// dstTr.SetParent(srcTr.parent, true);
|
|
dstTr.position = srcTr.position;
|
|
dstTr.rotation = srcTr.rotation;
|
|
dstTr.localScale = srcTr.localScale;
|
|
|
|
var addressableObject = newGo.AddComponent<AddressableObject>();
|
|
addressableObject.assetPath = meshAssetPath;
|
|
|
|
createdAddressableObjects.Add(addressableObject);
|
|
}
|
|
|
|
// AssetDatabase.SaveAssets();
|
|
// AssetDatabase.Refresh();
|
|
|
|
_addressableObjects = createdAddressableObjects.ToArray();
|
|
// EditorUtility.SetDirty(this);
|
|
// if (gameObject.scene.IsValid())
|
|
// {
|
|
// EditorSceneManager.MarkSceneDirty(gameObject.scene);
|
|
// }
|
|
}
|
|
|
|
[ContextMenu("Load All Addressable Objects")]
|
|
private void LoadAllAddressableObjects()
|
|
{
|
|
for (int i = 0; i < _addressableObjects.Length; i++)
|
|
{
|
|
_addressableObjects[i].LoadAsset().Forget();
|
|
}
|
|
}
|
|
|
|
[ContextMenu("Unload All Addressable Objects")]
|
|
private void UnloadAllAddressableObjects()
|
|
{
|
|
for (int i = 0; i < _addressableObjects.Length; i++)
|
|
{
|
|
_addressableObjects[i].UnloadAsset();
|
|
}
|
|
}
|
|
|
|
[ContextMenu("Update Addressable Objects Position Mesh Base")]
|
|
private void UpdateAddressableObjectsPositionMeshBase()
|
|
{
|
|
Mesh mesh;
|
|
for (int i = 0; i < _addressableObjects.Length; i++)
|
|
{
|
|
mesh = _addressableObjects[i].GetComponentInChildren<MeshFilter>().sharedMesh;
|
|
|
|
if (mesh == null) continue;
|
|
|
|
Vector3[] vertices = mesh.vertices;
|
|
// calculate the center of the mesh
|
|
Vector3 center = Vector3.zero;
|
|
for (int j = 0; j < vertices.Length; j++)
|
|
{
|
|
center += vertices[j];
|
|
}
|
|
center /= vertices.Length;
|
|
|
|
_addressableObjects[i].ObjectPosition = center;
|
|
_addressableObjects[i].ObjectPositionOxz = new Vector3(center.x, 0, center.z);
|
|
_addressableObjects[i].NeedToUpdatePositionAtStartUp = false;
|
|
}
|
|
}
|
|
|
|
private static Mesh GetFirstMesh(GameObject go)
|
|
{
|
|
if (go == null) return null;
|
|
|
|
var mf = go.GetComponent<MeshFilter>();
|
|
if (mf != null && mf.sharedMesh != null)
|
|
{
|
|
return mf.sharedMesh;
|
|
}
|
|
|
|
var smr = go.GetComponent<SkinnedMeshRenderer>();
|
|
if (smr != null && smr.sharedMesh != null)
|
|
{
|
|
return smr.sharedMesh;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static void EnsureFolderExists(string folderPath)
|
|
{
|
|
if (AssetDatabase.IsValidFolder(folderPath))
|
|
{
|
|
return;
|
|
}
|
|
|
|
string[] parts = folderPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
|
|
if (parts.Length == 0 || parts[0] != "Assets")
|
|
{
|
|
throw new ArgumentException($"Folder path must start with 'Assets': {folderPath}");
|
|
}
|
|
|
|
string current = "Assets";
|
|
for (int i = 1; i < parts.Length; i++)
|
|
{
|
|
string next = $"{current}/{parts[i]}";
|
|
if (!AssetDatabase.IsValidFolder(next))
|
|
{
|
|
AssetDatabase.CreateFolder(current, parts[i]);
|
|
}
|
|
current = next;
|
|
}
|
|
}
|
|
|
|
private static string MakeSafeFileName(string name)
|
|
{
|
|
if (string.IsNullOrEmpty(name)) return "Unnamed";
|
|
char[] invalid = Path.GetInvalidFileNameChars();
|
|
foreach (char c in invalid)
|
|
{
|
|
name = name.Replace(c, '_');
|
|
}
|
|
return name.Trim();
|
|
}
|
|
#endif
|
|
}
|
|
}
|