using System; using System.Collections; using System.Collections.Generic; using System.IO; using UnityEngine; using UnityEngine.AddressableAssets; using UnityEngine.ResourceManagement.AsyncOperations; using UnityEngine.Rendering; public class GeoTileAddressablesLoader : MonoBehaviour { [Header("References")] [SerializeField] private Transform player; [SerializeField] private Transform tilesParent; [Header("Paths")] [SerializeField] private string tileBundleFolderName = "TileBundles"; [SerializeField] private string manifestFileName = "TileManifest.json"; [SerializeField] private string buildingManifestFileName = "TileBuildingsManifest.json"; [SerializeField] private string buildTargetFolderOverride = ""; [Header("Streaming")] [SerializeField] private float loadRadiusMeters = 1500f; [SerializeField] private float unloadRadiusMeters = 2000f; [SerializeField] private float updateInterval = 0.5f; [SerializeField] private int maxConcurrentLoads = 2; [SerializeField] private bool verboseLogging = false; [SerializeField] private bool useEmbeddedBuildingsInTilePrefabs = true; [Header("Buildings (2km blocks)")] [SerializeField] private bool loadBuildingsFor2kmBlocks = true; [SerializeField] private int buildingBlockSizeInTiles = 2; private TileManifest manifest; private TileManifest buildingManifest; private readonly Dictionary tiles = new Dictionary(); private readonly Dictionary loaded = new Dictionary(); private readonly HashSet loading = new HashSet(); private readonly HashSet queued = new HashSet(); private readonly Queue loadQueue = new Queue(); private readonly Dictionary buildingTiles = new Dictionary(); private readonly Dictionary buildingLoaded = new Dictionary(); private readonly HashSet buildingLoading = new HashSet(); private readonly HashSet buildingQueued = new HashSet(); private readonly Queue buildingLoadQueue = new Queue(); private readonly Dictionary tileBlockKey = new Dictionary(); private readonly Dictionary blockBuildingKey = new Dictionary(); private bool initialized; private float nextUpdateTime; private void OnValidate() { if (unloadRadiusMeters < loadRadiusMeters) unloadRadiusMeters = loadRadiusMeters; if (maxConcurrentLoads < 1) maxConcurrentLoads = 1; if (updateInterval < 0.05f) updateInterval = 0.05f; if (buildingBlockSizeInTiles < 1) buildingBlockSizeInTiles = 1; } private void Awake() { if (tilesParent == null) { var parent = new GameObject("Geo_Tile_Addressables"); tilesParent = parent.transform; } } private void Start() { if (player == null && Camera.main != null) player = Camera.main.transform; StartCoroutine(Initialize()); } private IEnumerator Initialize() { var buildTargetFolder = string.IsNullOrWhiteSpace(buildTargetFolderOverride) ? GetBuildTargetFolderName() : buildTargetFolderOverride; var basePath = Path.Combine(Application.persistentDataPath, tileBundleFolderName, buildTargetFolder); var manifestPath = Path.Combine(basePath, manifestFileName); Debug.Log( $"[GeoTileAddressablesLoader] Startup: platform={Application.platform}, " + $"persistentDataPath={Application.persistentDataPath}, basePath={basePath}, manifestPath={manifestPath}"); LogRuntimeRenderState(); Log($"Initializing loader. BasePath={basePath}"); Log($"ManifestPath={manifestPath}"); if (!File.Exists(manifestPath)) { Debug.LogError($"[GeoTileAddressablesLoader] Manifest not found: {manifestPath}"); yield break; } manifest = JsonUtility.FromJson(File.ReadAllText(manifestPath)); if (manifest == null || manifest.tiles == null || manifest.tiles.Length == 0) { Debug.LogError("[GeoTileAddressablesLoader] Manifest is empty or invalid."); yield break; } tiles.Clear(); foreach (var tile in manifest.tiles) { var key = !string.IsNullOrWhiteSpace(tile.tileKey) ? tile.tileKey : tile.tileId; if (!string.IsNullOrWhiteSpace(key)) tiles[key] = tile; } LoadBuildingManifest(basePath); BuildBuildingBlocks(); Debug.Log( $"[GeoTileAddressablesLoader] Manifest loaded: path={manifestPath}, buildTarget={manifest.buildTarget}, " + $"catalogFile={manifest.catalogFile}, catalogHashFile={manifest.catalogHashFile}, tileCount={tiles.Count}"); Log($"Manifest loaded. Tiles={tiles.Count} CatalogFile={manifest.catalogFile}"); var catalogPath = Path.Combine(basePath, manifest.catalogFile); if (!File.Exists(catalogPath)) { Debug.LogError($"[GeoTileAddressablesLoader] Catalog not found: {catalogPath}"); yield break; } var catalogUri = ToFileUri(catalogPath); Debug.Log($"[GeoTileAddressablesLoader] Loading catalog: path={catalogPath}, uri={catalogUri}"); Log($"Loading catalog: {catalogPath}"); var handle = Addressables.LoadContentCatalogAsync(catalogUri, true); yield return handle; if (handle.Status != AsyncOperationStatus.Succeeded) { Debug.LogError($"[GeoTileAddressablesLoader] Catalog load failed: {handle.OperationException}"); yield break; } Debug.Log($"[GeoTileAddressablesLoader] Catalog loaded successfully: {manifest.catalogFile}"); Log("Catalog loaded successfully."); // Sanity-check key resolution against the loaded catalog so logcat immediately // shows whether the runtime catalog actually contains our tile keys. yield return StartCoroutine(LogCatalogKeyProbe()); initialized = true; nextUpdateTime = Time.time; } private IEnumerator LogCatalogKeyProbe() { int probeCount = 0; foreach (var kvp in tiles) { var key = kvp.Key; var locationsHandle = Addressables.LoadResourceLocationsAsync(key); yield return locationsHandle; int locationCount = 0; if (locationsHandle.Status == AsyncOperationStatus.Succeeded && locationsHandle.Result != null) locationCount = locationsHandle.Result.Count; Debug.Log($"[GeoTileAddressablesLoader] KeyProbe tile key={key} locations={locationCount}"); Addressables.Release(locationsHandle); probeCount++; if (probeCount >= 5) break; } if (loadBuildingsFor2kmBlocks && buildingTiles.Count > 0) { int buildingProbeCount = 0; foreach (var kvp in buildingTiles) { var key = kvp.Key; var locationsHandle = Addressables.LoadResourceLocationsAsync(key); yield return locationsHandle; int locationCount = 0; if (locationsHandle.Status == AsyncOperationStatus.Succeeded && locationsHandle.Result != null) locationCount = locationsHandle.Result.Count; Debug.Log($"[GeoTileAddressablesLoader] KeyProbe building key={key} locations={locationCount}"); Addressables.Release(locationsHandle); buildingProbeCount++; if (buildingProbeCount >= 5) break; } } } private void LoadBuildingManifest(string basePath) { buildingManifest = null; buildingTiles.Clear(); if (!loadBuildingsFor2kmBlocks || string.IsNullOrWhiteSpace(buildingManifestFileName)) return; var path = Path.Combine(basePath, buildingManifestFileName); Debug.Log($"[GeoTileAddressablesLoader] Building manifest path: {path}"); if (!File.Exists(path)) { Log($"Building manifest not found: {path}"); return; } buildingManifest = JsonUtility.FromJson(File.ReadAllText(path)); if (buildingManifest == null || buildingManifest.tiles == null || buildingManifest.tiles.Length == 0) { Log("Building manifest is empty or invalid."); return; } foreach (var tile in buildingManifest.tiles) { var key = !string.IsNullOrWhiteSpace(tile.tileKey) ? tile.tileKey : tile.tileId; if (!string.IsNullOrWhiteSpace(key)) buildingTiles[key] = tile; } Debug.Log( $"[GeoTileAddressablesLoader] Building manifest loaded: path={path}, buildTarget={buildingManifest.buildTarget}, " + $"catalogFile={buildingManifest.catalogFile}, tileCount={buildingTiles.Count}"); Log($"Building manifest loaded. BuildingTiles={buildingTiles.Count}"); } private void Update() { if (!initialized || player == null) return; if (Time.time < nextUpdateTime) return; nextUpdateTime = Time.time + updateInterval; UpdateTileSet(); ProcessQueue(); } private void UpdateTileSet() { var playerPos = player.position; var tileSize = manifest.tileSizeMeters; var wantedTiles = new HashSet(); HashSet wantedBlocks = (loadBuildingsFor2kmBlocks && buildingTiles.Count > 0) ? new HashSet() : null; foreach (var kvp in tiles) { var tileKey = kvp.Key; var tile = kvp.Value; var tileCenter = new Vector3( tile.offsetX + tileSize * 0.5f, tile.baseY, tile.offsetZ + tileSize * 0.5f); var distance = Vector2.Distance( new Vector2(playerPos.x, playerPos.z), new Vector2(tileCenter.x, tileCenter.z)); bool withinLoad = distance <= loadRadiusMeters; bool withinKeep = distance <= unloadRadiusMeters; if (withinLoad || (withinKeep && loaded.ContainsKey(tileKey))) wantedTiles.Add(tileKey); if (wantedBlocks != null && tileBlockKey.TryGetValue(tileKey, out var blockKey)) { if (withinLoad) wantedBlocks.Add(blockKey); else if (withinKeep && blockBuildingKey.TryGetValue(blockKey, out var buildingKey) && buildingLoaded.ContainsKey(buildingKey)) wantedBlocks.Add(blockKey); } } foreach (var tileKey in wantedTiles) EnqueueTileLoad(tileKey); if (loaded.Count == 0) { if (wantedBlocks == null) return; } var toUnload = new List(); foreach (var kvp in loaded) { if (!wantedTiles.Contains(kvp.Key)) toUnload.Add(kvp.Key); } for (int i = 0; i < toUnload.Count; i++) UnloadTile(toUnload[i]); if (wantedBlocks == null) return; var wantedBuildingKeys = new HashSet(); foreach (var blockKey in wantedBlocks) { if (blockBuildingKey.TryGetValue(blockKey, out var buildingKey)) wantedBuildingKeys.Add(buildingKey); } foreach (var buildingKey in wantedBuildingKeys) EnqueueBuildingLoad(buildingKey); if (buildingLoaded.Count == 0) return; var toUnloadBuildings = new List(); foreach (var kvp in buildingLoaded) { if (!wantedBuildingKeys.Contains(kvp.Key)) toUnloadBuildings.Add(kvp.Key); } for (int i = 0; i < toUnloadBuildings.Count; i++) UnloadBuilding(toUnloadBuildings[i]); } private void EnqueueTileLoad(string tileKey) { if (loaded.ContainsKey(tileKey) || loading.Contains(tileKey) || queued.Contains(tileKey)) return; loadQueue.Enqueue(tileKey); queued.Add(tileKey); } private void EnqueueBuildingLoad(string buildingKey) { if (buildingLoaded.ContainsKey(buildingKey) || buildingLoading.Contains(buildingKey) || buildingQueued.Contains(buildingKey)) return; buildingLoadQueue.Enqueue(buildingKey); buildingQueued.Add(buildingKey); } private void ProcessQueue() { bool preferTiles = true; while (loadQueue.Count > 0 || buildingLoadQueue.Count > 0) { int inFlight = loading.Count + buildingLoading.Count; if (inFlight >= maxConcurrentLoads) return; if ((preferTiles && loadQueue.Count > 0) || buildingLoadQueue.Count == 0) { var tileKey = loadQueue.Dequeue(); queued.Remove(tileKey); StartLoad(tileKey); } else { var buildingKey = buildingLoadQueue.Dequeue(); buildingQueued.Remove(buildingKey); StartBuildingLoad(buildingKey); } preferTiles = !preferTiles; } } private void StartLoad(string tileKey) { if (!tiles.TryGetValue(tileKey, out var tile)) return; loading.Add(tileKey); Log($"Loading tile {tileKey}..."); var handle = Addressables.InstantiateAsync(tileKey, tilesParent); handle.Completed += op => { loading.Remove(tileKey); if (op.Status != AsyncOperationStatus.Succeeded) { Debug.LogError($"[GeoTileAddressablesLoader] Load failed for {tileKey}: {op.OperationException}"); return; } var instance = op.Result; instance.name = tileKey; instance.transform.position = new Vector3(tile.offsetX, tile.baseY, tile.offsetZ); if (!useEmbeddedBuildingsInTilePrefabs) RemoveEmbeddedBuildings(instance, tileKey); LogTerrainState(instance, tileKey); loaded[tileKey] = instance; Log($"Loaded tile {tileKey}. LoadedCount={loaded.Count}"); }; } private void StartBuildingLoad(string buildingKey) { if (!buildingTiles.TryGetValue(buildingKey, out var tile)) return; buildingLoading.Add(buildingKey); Log($"Loading buildings {buildingKey}..."); var handle = Addressables.InstantiateAsync(buildingKey, tilesParent); handle.Completed += op => { buildingLoading.Remove(buildingKey); if (op.Status != AsyncOperationStatus.Succeeded) { Debug.LogError($"[GeoTileAddressablesLoader] Building load failed for {buildingKey}: {op.OperationException}"); return; } var instance = op.Result; instance.name = buildingKey; instance.transform.position = new Vector3(tile.offsetX, tile.baseY, tile.offsetZ); buildingLoaded[buildingKey] = instance; Log($"Loaded buildings {buildingKey}. LoadedCount={buildingLoaded.Count}"); }; } private void UnloadTile(string tileKey) { if (!loaded.TryGetValue(tileKey, out var instance)) return; Addressables.ReleaseInstance(instance); loaded.Remove(tileKey); Log($"Unloaded tile {tileKey}. LoadedCount={loaded.Count}"); } private void UnloadBuilding(string buildingKey) { if (!buildingLoaded.TryGetValue(buildingKey, out var instance)) return; Addressables.ReleaseInstance(instance); buildingLoaded.Remove(buildingKey); Log($"Unloaded buildings {buildingKey}. LoadedCount={buildingLoaded.Count}"); } private static void LogTerrainState(GameObject instance, string tileKey) { if (instance == null) return; var terrain = instance.GetComponent(); if (terrain == null) { int rendererCount = instance.GetComponentsInChildren(true).Length; Debug.LogWarning( $"[GeoTileAddressablesLoader] Tile {tileKey}: no Terrain component on instance, " + $"rendererCount={rendererCount}, rootPos={FormatVec3(instance.transform.position)}"); return; } var td = terrain.terrainData; if (td == null) { Debug.LogWarning($"[GeoTileAddressablesLoader] Tile {tileKey}: TerrainData is NULL."); return; } var layers = td.terrainLayers; int layerCount = layers != null ? layers.Length : 0; bool hasDiffuse = false; string diffuseName = ""; if (layerCount > 0 && layers[0] != null && layers[0].diffuseTexture != null) { hasDiffuse = true; diffuseName = layers[0].diffuseTexture.name; } string materialName = terrain.materialTemplate != null ? terrain.materialTemplate.name : ""; string shaderName = terrain.materialTemplate != null && terrain.materialTemplate.shader != null ? terrain.materialTemplate.shader.name : ""; bool shaderSupported = terrain.materialTemplate != null && terrain.materialTemplate.shader != null && terrain.materialTemplate.shader.isSupported; Bounds localBounds = td.bounds; Vector3 worldCenter = terrain.GetPosition() + localBounds.center; Vector3 extents = localBounds.extents; Debug.Log( $"[GeoTileAddressablesLoader] Tile {tileKey}: terrain ok, " + $"enabled={terrain.enabled}, drawHeightmap={terrain.drawHeightmap}, " + $"layers={layerCount}, hasDiffuse={hasDiffuse}, diffuse={diffuseName}, " + $"material={materialName}, shader={shaderName}, shaderSupported={shaderSupported}, " + $"worldCenter={FormatVec3(worldCenter)}, extents={FormatVec3(extents)}, " + $"rootPos={FormatVec3(instance.transform.position)}"); } private void RemoveEmbeddedBuildings(GameObject tileInstance, string tileKey) { if (tileInstance == null) return; int removed = 0; var removedIds = new HashSet(); // Current tile prefab convention: a direct child named "Buildings" var direct = tileInstance.transform.Find("Buildings"); if (direct != null) { Destroy(direct.gameObject); removed++; removedIds.Add(direct.gameObject.GetInstanceID()); } // Safety pass if any nested "Buildings" transforms exist var all = tileInstance.GetComponentsInChildren(true); for (int i = 0; i < all.Length; i++) { var t = all[i]; if (t == null || t == tileInstance.transform) continue; if (!string.Equals(t.name, "Buildings", StringComparison.Ordinal)) continue; if (removedIds.Contains(t.gameObject.GetInstanceID())) continue; Destroy(t.gameObject); removed++; removedIds.Add(t.gameObject.GetInstanceID()); } if (removed > 0) Log($"Removed {removed} embedded building root(s) from tile {tileKey}."); } private void BuildBuildingBlocks() { tileBlockKey.Clear(); blockBuildingKey.Clear(); if (!loadBuildingsFor2kmBlocks) return; int blockSize = Math.Max(1, buildingBlockSizeInTiles); foreach (var kvp in tiles) { if (!TryGetTileXY(kvp.Value, out int x, out int y)) continue; int blockX = x - (x % blockSize); int blockY = y - (y % blockSize); string blockKey = $"{blockX}_{blockY}"; tileBlockKey[kvp.Key] = blockKey; } if (buildingTiles.Count == 0) return; foreach (var kvp in buildingTiles) { if (!TryGetTileXY(kvp.Value, out int x, out int y)) continue; int blockX = x - (x % blockSize); int blockY = y - (y % blockSize); string blockKey = $"{blockX}_{blockY}"; blockBuildingKey[blockKey] = kvp.Key; } } private static bool TryGetTileXY(TileEntry tile, out int x, out int y) { if (!string.IsNullOrWhiteSpace(tile.tileKey) && TryParseTileKey(tile.tileKey, out x, out y)) return true; return TryParseTileId(tile.tileId, out x, out y); } private static bool TryParseTileKey(string tileKey, out int x, out int y) { x = 0; y = 0; if (string.IsNullOrWhiteSpace(tileKey)) return false; var parts = tileKey.Split('_'); if (parts.Length != 2) return false; return int.TryParse(parts[0], out x) && int.TryParse(parts[1], out y); } private static bool TryParseTileId(string tileId, out int x, out int y) { x = 0; y = 0; if (string.IsNullOrWhiteSpace(tileId)) return false; var parts = tileId.Split('_'); var coords = new List(); for (int i = 0; i < parts.Length; i++) { var part = parts[i]; if (part.Length < 3) continue; bool allDigits = true; for (int j = 0; j < part.Length; j++) { if (!char.IsDigit(part[j])) { allDigits = false; break; } } if (!allDigits) continue; if (int.TryParse(part, out int value)) coords.Add(value); } if (coords.Count < 2) return false; x = coords[coords.Count - 2]; y = coords[coords.Count - 1]; return true; } private static string GetBuildTargetFolderName() { switch (Application.platform) { case RuntimePlatform.Android: return "Android"; case RuntimePlatform.WindowsPlayer: case RuntimePlatform.WindowsEditor: return "StandaloneWindows64"; case RuntimePlatform.LinuxPlayer: case RuntimePlatform.LinuxEditor: return "StandaloneLinux64"; case RuntimePlatform.OSXPlayer: case RuntimePlatform.OSXEditor: return "StandaloneOSX"; default: return "Android"; } } private static string ToFileUri(string path) { var normalized = path.Replace("\\", "/"); return normalized.StartsWith("file://", StringComparison.OrdinalIgnoreCase) ? normalized : "file://" + normalized; } private void OnDestroy() { foreach (var instance in loaded.Values) Addressables.ReleaseInstance(instance); loaded.Clear(); loading.Clear(); queued.Clear(); loadQueue.Clear(); foreach (var instance in buildingLoaded.Values) Addressables.ReleaseInstance(instance); buildingLoaded.Clear(); buildingLoading.Clear(); buildingQueued.Clear(); buildingLoadQueue.Clear(); } private void Log(string message) { if (verboseLogging) Debug.Log($"[GeoTileAddressablesLoader] {message}"); } private void LogRuntimeRenderState() { var rp = GraphicsSettings.currentRenderPipeline; string rpName = rp != null ? $"{rp.name} ({rp.GetType().Name})" : "BuiltInRenderPipeline"; int qualityIndex = QualitySettings.GetQualityLevel(); string qualityName = qualityIndex >= 0 && qualityIndex < QualitySettings.names.Length ? QualitySettings.names[qualityIndex] : ""; string cameraName = Camera.main != null ? Camera.main.name : ""; Debug.Log( $"[GeoTileAddressablesLoader] RenderState: quality={qualityIndex}:{qualityName}, " + $"renderPipeline={rpName}, mainCamera={cameraName}"); } private static string FormatVec3(Vector3 v) { return $"({v.x:F2},{v.y:F2},{v.z:F2})"; } }