diff --git a/Assets/FloodSWE.meta b/Assets/FloodSWE.meta new file mode 100644 index 000000000..8547f8437 --- /dev/null +++ b/Assets/FloodSWE.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: caac3b37ae2b11983aff2916e1ff4864 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Compute.meta b/Assets/FloodSWE/Compute.meta new file mode 100644 index 000000000..53c1a38a0 --- /dev/null +++ b/Assets/FloodSWE/Compute.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8cbbf8ba1bacd0fa4975499573f29883 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Compute/SWE_Flux.compute b/Assets/FloodSWE/Compute/SWE_Flux.compute new file mode 100644 index 000000000..3a2bc53e8 --- /dev/null +++ b/Assets/FloodSWE/Compute/SWE_Flux.compute @@ -0,0 +1,207 @@ +#pragma kernel FluxUpdate +#pragma kernel InitDamBreak +#pragma kernel ScaleWater + +Texture2D _WaterIn; +Texture2D _VelIn; +Texture2D _TerrainHeight; +Texture2D _Porosity; + +RWTexture2D _WaterOut; +RWTexture2D _VelOut; +RWTexture2D _ClampMask; + +int _GridRes; +float _Dx; +float _Dt; +float _Gravity; +float _RainRate; +int _UsePorosity; +int _DebugClamp; +float _Scale; + +float _InitDepthLeft; +float _InitDepthRight; +float2 _InitVelocity; +int _InitDamBreak; + +struct State +{ + float h; + float mx; + float my; + float u; + float v; + float c; + float p; +}; + +State LoadState(int2 g) +{ + State s; + + float h = _WaterIn[g]; + if (h != h || h < 0.0) + { + h = 0.0; + } + + float2 vel = _VelIn[g]; + if (vel.x != vel.x) + { + vel.x = 0.0; + } + if (vel.y != vel.y) + { + vel.y = 0.0; + } + + float p = 1.0; + if (_UsePorosity == 1) + { + int2 i = int2(clamp(g.x - 1, 0, _GridRes - 1), clamp(g.y - 1, 0, _GridRes - 1)); + p = saturate(_Porosity[i]); + } + + s.h = h; + s.u = vel.x; + s.v = vel.y; + s.mx = h * vel.x; + s.my = h * vel.y; + s.c = sqrt(_Gravity * max(h, 0.0)); + s.p = p; + return s; +} + +float3 FluxX(State a, State b) +{ + float3 Fa = float3(a.mx, a.mx * a.u + 0.5 * _Gravity * a.h * a.h, a.mx * a.v); + float3 Fb = float3(b.mx, b.mx * b.u + 0.5 * _Gravity * b.h * b.h, b.mx * b.v); + float smax = max(abs(a.u) + a.c, abs(b.u) + b.c); + float3 flux = 0.5 * (Fa + Fb) - 0.5 * smax * (float3(b.h, b.mx, b.my) - float3(a.h, a.mx, a.my)); + float pFace = 0.5 * (a.p + b.p); + return flux * pFace; +} + +float3 FluxY(State a, State b) +{ + float3 Ga = float3(a.my, a.my * a.u, a.my * a.v + 0.5 * _Gravity * a.h * a.h); + float3 Gb = float3(b.my, b.my * b.u, b.my * b.v + 0.5 * _Gravity * b.h * b.h); + float smax = max(abs(a.v) + a.c, abs(b.v) + b.c); + float3 flux = 0.5 * (Ga + Gb) - 0.5 * smax * (float3(b.h, b.mx, b.my) - float3(a.h, a.mx, a.my)); + float pFace = 0.5 * (a.p + b.p); + return flux * pFace; +} + +float SampleTerrain(int2 i) +{ + int2 c = int2(clamp(i.x, 0, _GridRes - 1), clamp(i.y, 0, _GridRes - 1)); + return _TerrainHeight[c]; +} + +[numthreads(8, 8, 1)] +void FluxUpdate(uint3 id : SV_DispatchThreadID) +{ + int2 i = int2(id.xy); + if (i.x >= _GridRes || i.y >= _GridRes) + { + return; + } + + int2 g = i + 1; + + State c = LoadState(g); + State l = LoadState(g + int2(-1, 0)); + State r = LoadState(g + int2(1, 0)); + State d = LoadState(g + int2(0, -1)); + State u = LoadState(g + int2(0, 1)); + + float3 FxL = FluxX(l, c); + float3 FxR = FluxX(c, r); + float3 FyD = FluxY(d, c); + float3 FyU = FluxY(c, u); + + float3 U = float3(c.h, c.mx, c.my); + float3 Unew = U - (_Dt / _Dx) * ((FxR - FxL) + (FyU - FyD)); + + float zL = SampleTerrain(i + int2(-1, 0)); + float zR = SampleTerrain(i + int2(1, 0)); + float zD = SampleTerrain(i + int2(0, -1)); + float zU = SampleTerrain(i + int2(0, 1)); + float dzdx = (zR - zL) / (2.0 * _Dx); + float dzdy = (zU - zD) / (2.0 * _Dx); + Unew.y += -_Gravity * c.h * dzdx * _Dt; + Unew.z += -_Gravity * c.h * dzdy * _Dt; + + Unew.x += _RainRate * _Dt; + + bool clamped = (Unew.x < 0.0); + bool nanValue = (Unew.x != Unew.x) || (Unew.y != Unew.y) || (Unew.z != Unew.z); + float hNew = max(Unew.x, 0.0); + float2 velNew = float2(0.0, 0.0); + if (hNew > 1e-5) + { + velNew = Unew.yz / hNew; + } + + if (hNew != hNew) + { + hNew = 0.0; + velNew = float2(0.0, 0.0); + nanValue = true; + } + if (velNew.x != velNew.x || velNew.y != velNew.y) + { + velNew = float2(0.0, 0.0); + nanValue = true; + } + + _WaterOut[i] = hNew; + _VelOut[i] = velNew; + + if (_DebugClamp == 1) + { + _ClampMask[i] = nanValue ? 2.0 : (clamped ? 1.0 : 0.0); + } +} + +[numthreads(8, 8, 1)] +void InitDamBreak(uint3 id : SV_DispatchThreadID) +{ + int2 i = int2(id.xy); + if (i.x >= _GridRes || i.y >= _GridRes) + { + return; + } + + float depth = _InitDepthLeft; + if (_InitDamBreak == 1) + { + if (i.x >= (_GridRes >> 1)) + { + depth = _InitDepthRight; + } + } + + if (depth < 0.0) + { + depth = 0.0; + } + + _WaterOut[i] = depth; + _VelOut[i] = _InitVelocity; +} + +[numthreads(8, 8, 1)] +void ScaleWater(uint3 id : SV_DispatchThreadID) +{ + int2 i = int2(id.xy); + if (i.x >= _GridRes || i.y >= _GridRes) + { + return; + } + + float h = _WaterOut[i]; + h = max(0.0, h * _Scale); + _WaterOut[i] = h; +} diff --git a/Assets/FloodSWE/Compute/SWE_Flux.compute.meta b/Assets/FloodSWE/Compute/SWE_Flux.compute.meta new file mode 100644 index 000000000..861d7c865 --- /dev/null +++ b/Assets/FloodSWE/Compute/SWE_Flux.compute.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 5128e0ee725879f26bb5044de1621c5a +ComputeShaderImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Compute/SWE_GhostExchange.compute b/Assets/FloodSWE/Compute/SWE_GhostExchange.compute new file mode 100644 index 000000000..71ffe1476 --- /dev/null +++ b/Assets/FloodSWE/Compute/SWE_GhostExchange.compute @@ -0,0 +1,91 @@ +#pragma kernel GhostExchange + +Texture2D _WaterIn; +Texture2D _VelIn; +Texture2D _WaterNorth; +Texture2D _VelNorth; +Texture2D _WaterSouth; +Texture2D _VelSouth; +Texture2D _WaterEast; +Texture2D _VelEast; +Texture2D _WaterWest; +Texture2D _VelWest; + +RWTexture2D _WaterOut; +RWTexture2D _VelOut; + +int _GridRes; +int _HasNorth; +int _HasSouth; +int _HasEast; +int _HasWest; + +[numthreads(8, 8, 1)] +void GhostExchange(uint3 id : SV_DispatchThreadID) +{ + int size = _GridRes + 2; + int2 g = int2(id.xy); + if (g.x >= size || g.y >= size) + { + return; + } + + int2 interior = int2(clamp(g.x - 1, 0, _GridRes - 1), clamp(g.y - 1, 0, _GridRes - 1)); + float h = _WaterIn[interior]; + float2 v = _VelIn[interior]; + + bool usedNeighbor = false; + + if (g.x == 0 && _HasWest == 1) + { + h = _WaterWest[int2(_GridRes - 1, interior.y)]; + v = _VelWest[int2(_GridRes - 1, interior.y)]; + usedNeighbor = true; + } + else if (g.x == size - 1 && _HasEast == 1) + { + h = _WaterEast[int2(0, interior.y)]; + v = _VelEast[int2(0, interior.y)]; + usedNeighbor = true; + } + else if (g.y == 0 && _HasSouth == 1) + { + h = _WaterSouth[int2(interior.x, _GridRes - 1)]; + v = _VelSouth[int2(interior.x, _GridRes - 1)]; + usedNeighbor = true; + } + else if (g.y == size - 1 && _HasNorth == 1) + { + h = _WaterNorth[int2(interior.x, 0)]; + v = _VelNorth[int2(interior.x, 0)]; + usedNeighbor = true; + } + + if (!usedNeighbor) + { + if (g.x == 0 || g.x == size - 1) + { + v.x = -v.x; + } + if (g.y == 0 || g.y == size - 1) + { + v.y = -v.y; + } + } + + if (h != h || h < 0.0) + { + h = 0.0; + } + if (v.x != v.x) + { + v.x = 0.0; + } + if (v.y != v.y) + { + v.y = 0.0; + } + + _WaterOut[g] = h; + _VelOut[g] = v; +} diff --git a/Assets/FloodSWE/Compute/SWE_GhostExchange.compute.meta b/Assets/FloodSWE/Compute/SWE_GhostExchange.compute.meta new file mode 100644 index 000000000..a2a2aaa60 --- /dev/null +++ b/Assets/FloodSWE/Compute/SWE_GhostExchange.compute.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 996054f3be2086cabb5672a773d4a76d +ComputeShaderImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scenes.meta b/Assets/FloodSWE/Scenes.meta new file mode 100644 index 000000000..814a44e48 --- /dev/null +++ b/Assets/FloodSWE/Scenes.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fa8ec11cd35df8d2a8764cc60dc83d86 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts.meta b/Assets/FloodSWE/Scripts.meta new file mode 100644 index 000000000..b3b62682a --- /dev/null +++ b/Assets/FloodSWE/Scripts.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5f84cae402a59dd16a69e6257b52f26a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/Debug.meta b/Assets/FloodSWE/Scripts/Debug.meta new file mode 100644 index 000000000..2e1985940 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Debug.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8d54aac690b9b3c948a6967c06ac0906 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/Debug/SweHeightmapPreview.cs b/Assets/FloodSWE/Scripts/Debug/SweHeightmapPreview.cs new file mode 100644 index 000000000..94b2d9238 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Debug/SweHeightmapPreview.cs @@ -0,0 +1,110 @@ +using UnityEngine; + +namespace FloodSWE.Debugging +{ + [ExecuteAlways] + public sealed class SweHeightmapPreview : MonoBehaviour + { + [Header("Source")] + public SweTileSimulator simulator; + public RenderTexture overrideTexture; + + [Header("Target")] + public Renderer targetRenderer; + public bool createQuadIfMissing = true; + public Vector2 quadSize = new Vector2(10.0f, 10.0f); + + [Header("Display")] + [Range(0.0f, 5.0f)] + public float intensity = 1.0f; + public float minValue = 0.0f; + public float maxValue = 1.0f; + + [Header("Material")] + public Shader previewShader; + + private Material runtimeMaterial; + + private void OnEnable() + { + EnsureTarget(); + EnsureMaterial(); + ApplyTexture(); + } + + private void OnDisable() + { + if (runtimeMaterial != null) + { + DestroyImmediate(runtimeMaterial); + runtimeMaterial = null; + } + } + + private void Update() + { + ApplyTexture(); + } + + private void EnsureTarget() + { + if (targetRenderer != null || !createQuadIfMissing) + { + return; + } + + GameObject quad = GameObject.CreatePrimitive(PrimitiveType.Quad); + quad.name = "SWE_HeightmapPreview"; + quad.transform.SetParent(transform, false); + quad.transform.localRotation = Quaternion.Euler(90f, 0f, 0f); + quad.transform.localScale = new Vector3(quadSize.x, quadSize.y, 1f); + + targetRenderer = quad.GetComponent(); + } + + private void EnsureMaterial() + { + if (targetRenderer == null) + { + return; + } + + Shader shaderToUse = previewShader != null + ? previewShader + : Shader.Find("FloodSWE/HeightmapPreview"); + + if (shaderToUse == null) + { + Debug.LogWarning("SweHeightmapPreview: preview shader not found."); + return; + } + + runtimeMaterial = new Material(shaderToUse); + targetRenderer.sharedMaterial = runtimeMaterial; + } + + private void ApplyTexture() + { + if (runtimeMaterial == null) + { + return; + } + + RenderTexture tex = overrideTexture; + if (tex == null && simulator != null) + { + tex = simulator.debugWater; + } + + if (tex == null) + { + return; + } + + runtimeMaterial.SetTexture("_MainTex", tex); + runtimeMaterial.SetFloat("_Intensity", intensity); + runtimeMaterial.SetFloat("_MinValue", minValue); + runtimeMaterial.SetFloat("_MaxValue", maxValue); + } + } +} diff --git a/Assets/FloodSWE/Scripts/Debug/SweHeightmapPreview.cs.meta b/Assets/FloodSWE/Scripts/Debug/SweHeightmapPreview.cs.meta new file mode 100644 index 000000000..2ebd28231 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Debug/SweHeightmapPreview.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f27ca2e503cf98e1fae8e3292059b147 \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Debug/SweStatsOverlay.cs b/Assets/FloodSWE/Scripts/Debug/SweStatsOverlay.cs new file mode 100644 index 000000000..6c8dd27a3 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Debug/SweStatsOverlay.cs @@ -0,0 +1,65 @@ +using System.Text; +using TMPro; +using UnityEngine; + +namespace FloodSWE.Debugging +{ + public sealed class SweStatsOverlay : MonoBehaviour + { + public SweTileSimulator simulator; + public TextMeshProUGUI targetText; + public float updateInterval = 0.25f; + + private float elapsed; + private readonly StringBuilder builder = new StringBuilder(256); + + private void Update() + { + if (targetText == null) + { + return; + } + + elapsed += Time.deltaTime; + if (elapsed < updateInterval) + { + return; + } + + elapsed = 0.0f; + targetText.text = BuildStats(); + } + + private string BuildStats() + { + builder.Clear(); + + if (simulator == null) + { + builder.AppendLine("SWE: simulator not set"); + return builder.ToString(); + } + + builder.AppendLine("SWE Stats"); + builder.Append("Grid: ").Append(simulator.gridRes).Append(" "); + builder.Append("Tile: ").Append(simulator.tileSizeMeters.ToString("F0")).Append("m "); + builder.Append("dx: ").Append(simulator.CellSizeMeters.ToString("F3")).Append("m"); + builder.AppendLine(); + builder.Append("Depth: ").Append(simulator.LastMaxDepth.ToString("F3")).Append("m "); + builder.Append("Speed: ").Append(simulator.LastMaxSpeed.ToString("F3")).Append("m/s"); + builder.AppendLine(); + builder.Append("TotalDepth: ").Append(simulator.LastTotalDepth.ToString("F3")).Append("m "); + builder.Append("Volume: ").Append(simulator.LastTotalVolume.ToString("F3")).Append("m^3"); + builder.AppendLine(); + builder.Append("Clamped: ").Append(simulator.LastClampedCells); + builder.Append(" (").Append((simulator.LastClampedRatio * 100.0f).ToString("F1")).Append("%)"); + builder.Append(" NaN: ").Append(simulator.LastNanCells); + builder.AppendLine(); + builder.Append("dtMax: ").Append(simulator.LastDtMax.ToString("F4")).Append("s "); + builder.Append("substeps: ").Append(simulator.LastSubsteps).Append(" "); + builder.Append("dt: ").Append(simulator.LastDt.ToString("F4")).Append("s"); + + return builder.ToString(); + } + } +} diff --git a/Assets/FloodSWE/Scripts/Debug/SweStatsOverlay.cs.meta b/Assets/FloodSWE/Scripts/Debug/SweStatsOverlay.cs.meta new file mode 100644 index 000000000..0783c1059 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Debug/SweStatsOverlay.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d14330dd605fa5af6b8fa1d7219fdb30 \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/FloodSWE.Runtime.asmdef b/Assets/FloodSWE/Scripts/FloodSWE.Runtime.asmdef new file mode 100644 index 000000000..81eed4fae --- /dev/null +++ b/Assets/FloodSWE/Scripts/FloodSWE.Runtime.asmdef @@ -0,0 +1,16 @@ +{ + "name": "FloodSWE.Runtime", + "references": [ + "Unity.TextMeshPro" + ], + "optionalUnityReferences": [], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/FloodSWE/Scripts/FloodSWE.Runtime.asmdef.meta b/Assets/FloodSWE/Scripts/FloodSWE.Runtime.asmdef.meta new file mode 100644 index 000000000..5fc839d6d --- /dev/null +++ b/Assets/FloodSWE/Scripts/FloodSWE.Runtime.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 48859ef9fd5787638ab633c5fdbbba8c +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs b/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs new file mode 100644 index 000000000..265e1d42a --- /dev/null +++ b/Assets/FloodSWE/Scripts/IO/SweTileLoader.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using UnityEngine; + +namespace FloodSWE.IO +{ + public sealed class SweTileLoader : MonoBehaviour + { + public enum SourceMode + { + Resources = 0, + StreamingAssets = 1, + AbsolutePath = 2, + } + + [Header("Tile Index")] + public TextAsset tileIndexCsv; + public SourceMode sourceMode = SourceMode.Resources; + public string tileIndexPath = "export_swe/swe_tile_index.csv"; + public string fileRoot = ""; + public string resourcesRoot = "export_swe"; + + [Header("Runtime")] + public bool cacheTextures = true; + + private readonly Dictionary records = new Dictionary(); + private readonly Dictionary textureCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + private bool indexLoaded; + + public bool TryLoadTile(string lod, int tileX, int tileY, out SweTileData data) + { + data = default; + EnsureIndexLoaded(); + + TileKey key = new TileKey(lod, tileX, tileY); + if (!records.TryGetValue(key, out TileRecord record)) + { + return false; + } + + Texture2D height = LoadTexture(record.HeightPath); + Texture2D porosity = LoadTexture(record.PorosityPath); + Texture2D buildings = LoadTexture(record.BuildingPath); + + data = new SweTileData(record, height, porosity, buildings); + return height != null || porosity != null || buildings != null; + } + + public bool ApplyToSimulator(string lod, int tileX, int tileY, SweTileSimulator simulator) + { + if (simulator == null) + { + return false; + } + + if (!TryLoadTile(lod, tileX, tileY, out SweTileData data)) + { + return false; + } + + if (data.Height != null) + { + simulator.terrainHeight = data.Height; + } + if (data.Porosity != null) + { + simulator.porosity = data.Porosity; + } + + return true; + } + + private void EnsureIndexLoaded() + { + if (indexLoaded) + { + return; + } + + records.Clear(); + + string text = tileIndexCsv != null ? tileIndexCsv.text : LoadTextFromPath(); + if (string.IsNullOrWhiteSpace(text)) + { + Debug.LogWarning("SweTileLoader: tile index CSV is empty."); + indexLoaded = true; + return; + } + + string[] lines = text.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + if (lines.Length <= 1) + { + indexLoaded = true; + return; + } + + int headerColumns = 0; + for (int i = 0; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0) + { + continue; + } + + string[] parts = line.Split(','); + if (i == 0) + { + headerColumns = parts.Length; + continue; + } + + if (parts.Length < headerColumns) + { + continue; + } + + string lod = parts[0].Trim(); + if (!int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int tileX)) + { + continue; + } + if (!int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int tileY)) + { + continue; + } + + TileKey key = new TileKey(lod, tileX, tileY); + records[key] = new TileRecord( + lod, + tileX, + tileY, + NormalizePath(parts[9]), + NormalizePath(parts[10]), + NormalizePath(parts[11]) + ); + } + + indexLoaded = true; + } + + private string LoadTextFromPath() + { + if (string.IsNullOrWhiteSpace(tileIndexPath)) + { + return null; + } + + string path = tileIndexPath; + if (sourceMode == SourceMode.StreamingAssets) + { + path = Path.Combine(Application.streamingAssetsPath, tileIndexPath); + } + + if (!File.Exists(path)) + { + Debug.LogWarning($"SweTileLoader: CSV not found at {path}"); + return null; + } + + return File.ReadAllText(path); + } + + private Texture2D LoadTexture(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return null; + } + + if (cacheTextures && textureCache.TryGetValue(path, out Texture2D cached)) + { + return cached; + } + + Texture2D texture = null; + switch (sourceMode) + { + case SourceMode.Resources: + texture = Resources.Load(ToResourcesPath(path)); + break; + case SourceMode.StreamingAssets: + case SourceMode.AbsolutePath: + texture = LoadTextureFromFile(path); + break; + } + + if (cacheTextures && texture != null) + { + textureCache[path] = texture; + } + + return texture; + } + + private string NormalizePath(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return ""; + } + + string path = raw.Trim().Replace("\\", "/"); + if (sourceMode == SourceMode.AbsolutePath) + { + return path; + } + + if (sourceMode == SourceMode.StreamingAssets) + { + return string.IsNullOrWhiteSpace(fileRoot) ? path : Path.Combine(fileRoot, path).Replace("\\", "/"); + } + + return path; + } + + private string ToResourcesPath(string path) + { + string normalized = path.Replace("\\", "/"); + if (!string.IsNullOrWhiteSpace(resourcesRoot)) + { + int idx = normalized.IndexOf(resourcesRoot, StringComparison.OrdinalIgnoreCase); + if (idx >= 0) + { + normalized = normalized.Substring(idx); + } + } + + if (normalized.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring("Assets/".Length); + } + + if (normalized.StartsWith("Resources/", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring("Resources/".Length); + } + + if (normalized.EndsWith(".exr", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized.Substring(0, normalized.Length - 4); + } + + return normalized; + } + + private Texture2D LoadTextureFromFile(string path) + { + string resolved = path; + if (sourceMode == SourceMode.StreamingAssets) + { + resolved = Path.Combine(Application.streamingAssetsPath, path); + } + if (!File.Exists(resolved)) + { + Debug.LogWarning($"SweTileLoader: texture not found {resolved}"); + return null; + } + + byte[] bytes = File.ReadAllBytes(resolved); + Texture2D texture = new Texture2D(2, 2, TextureFormat.RFloat, false, true); + if (!ImageConversion.LoadImage(texture, bytes, false)) + { + Destroy(texture); + Debug.LogWarning($"SweTileLoader: failed to decode {resolved}"); + return null; + } + + texture.wrapMode = TextureWrapMode.Clamp; + texture.filterMode = FilterMode.Point; + texture.name = Path.GetFileNameWithoutExtension(resolved); + return texture; + } + + private readonly struct TileKey : IEquatable + { + public readonly string Lod; + public readonly int X; + public readonly int Y; + + public TileKey(string lod, int x, int y) + { + Lod = lod ?? ""; + X = x; + Y = y; + } + + public bool Equals(TileKey other) + { + return X == other.X && Y == other.Y && string.Equals(Lod, other.Lod, StringComparison.OrdinalIgnoreCase); + } + + public override bool Equals(object obj) + { + return obj is TileKey other && Equals(other); + } + + public override int GetHashCode() + { + unchecked + { + int hash = 17; + hash = hash * 31 + X; + hash = hash * 31 + Y; + hash = hash * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(Lod); + return hash; + } + } + } + + internal readonly struct TileRecord + { + public readonly string Lod; + public readonly int X; + public readonly int Y; + public readonly string HeightPath; + public readonly string PorosityPath; + public readonly string BuildingPath; + + public TileRecord(string lod, int x, int y, string heightPath, string porosityPath, string buildingPath) + { + Lod = lod; + X = x; + Y = y; + HeightPath = heightPath; + PorosityPath = porosityPath; + BuildingPath = buildingPath; + } + } + } + + public readonly struct SweTileData + { + public readonly string Lod; + public readonly int TileX; + public readonly int TileY; + public readonly Texture2D Height; + public readonly Texture2D Porosity; + public readonly Texture2D Buildings; + + public SweTileData( + in SweTileLoader.TileRecord record, + Texture2D height, + Texture2D porosity, + Texture2D buildings) + { + Lod = record.Lod; + TileX = record.X; + TileY = record.Y; + Height = height; + Porosity = porosity; + Buildings = buildings; + } + } +} diff --git a/Assets/FloodSWE/Scripts/Preprocess.meta b/Assets/FloodSWE/Scripts/Preprocess.meta new file mode 100644 index 000000000..3bb4756aa --- /dev/null +++ b/Assets/FloodSWE/Scripts/Preprocess.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 76a38ea8049773d56ab0ecb753b9314a +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/Preprocess/DemResampler.cs b/Assets/FloodSWE/Scripts/Preprocess/DemResampler.cs new file mode 100644 index 000000000..4df721982 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Preprocess/DemResampler.cs @@ -0,0 +1,344 @@ +using System; +using UnityEngine; + +namespace FloodSWE.Preprocess +{ + public static class DemResampler + { + private const float MinRangeEpsilon = 1e-6f; + + public static float[] ResampleHeights( + float[] source, + int sourceRes, + int targetRes, + float flatHeightMeters, + out float sourceMin, + out float sourceMax, + bool preserveMinMax = true) + { + return ResampleHeightsInternal( + source, + sourceRes, + sourceRes, + targetRes, + flatHeightMeters, + out sourceMin, + out sourceMax, + preserveMinMax); + } + + public static float[] ResampleHeights( + Texture2D source, + int targetRes, + float flatHeightMeters, + out float sourceMin, + out float sourceMax, + bool preserveMinMax = true) + { + if (source == null) + { + sourceMin = flatHeightMeters; + sourceMax = flatHeightMeters; + return CreateFlatArray(targetRes, flatHeightMeters); + } + + float[] data; + try + { + data = ExtractRedChannel(source); + } + catch (Exception ex) + { + Debug.LogWarning($"DemResampler: failed to read texture {source.name}. {ex.Message}"); + sourceMin = flatHeightMeters; + sourceMax = flatHeightMeters; + return CreateFlatArray(targetRes, flatHeightMeters); + } + + return ResampleHeightsInternal( + data, + source.width, + source.height, + targetRes, + flatHeightMeters, + out sourceMin, + out sourceMax, + preserveMinMax); + } + + public static float[] ResampleScalar( + float[] source, + int sourceRes, + int targetRes, + float flatValueMeters, + bool clampNonNegative = false) + { + float sourceMin; + float sourceMax; + float[] result = ResampleHeightsInternal( + source, + sourceRes, + sourceRes, + targetRes, + flatValueMeters, + out sourceMin, + out sourceMax, + false); + + if (clampNonNegative) + { + ClampMin(result, 0.0f); + } + + return result; + } + + public static byte[] BuildPorosityFromBuildings( + float[] buildingHeights, + float thresholdMeters = 0.1f, + byte solidValue = 0, + byte emptyValue = 255) + { + if (buildingHeights == null) + { + return null; + } + + int length = buildingHeights.Length; + byte[] porosity = new byte[length]; + for (int i = 0; i < length; i++) + { + porosity[i] = buildingHeights[i] > thresholdMeters ? solidValue : emptyValue; + } + + return porosity; + } + + public static byte[] BuildDefaultPorosity(int targetRes, byte value = 255) + { + int length = targetRes * targetRes; + byte[] porosity = new byte[length]; + for (int i = 0; i < length; i++) + { + porosity[i] = value; + } + + return porosity; + } + + public static TileStaticData BuildTileStaticData( + float[] heightSource, + int heightSourceRes, + int targetRes, + float tileSizeMeters, + float flatHeightMeters, + float[] buildingHeightSource = null, + int buildingSourceRes = 0, + float buildingFlatHeightMeters = 0.0f, + bool preserveHeightMinMax = true, + float buildingThresholdMeters = 0.1f) + { + float sourceMin; + float sourceMax; + float[] heights = ResampleHeights( + heightSource, + heightSourceRes, + targetRes, + flatHeightMeters, + out sourceMin, + out sourceMax, + preserveHeightMinMax); + + float[] buildingHeights = null; + byte[] porosity = null; + if (buildingHeightSource != null && buildingSourceRes > 0) + { + buildingHeights = ResampleScalar( + buildingHeightSource, + buildingSourceRes, + targetRes, + buildingFlatHeightMeters, + true); + porosity = BuildPorosityFromBuildings(buildingHeights, buildingThresholdMeters); + } + + return new TileStaticData( + targetRes, + tileSizeMeters, + heights, + sourceMin, + sourceMax, + buildingHeights, + porosity); + } + + private static float[] ResampleHeightsInternal( + float[] source, + int sourceWidth, + int sourceHeight, + int targetRes, + float flatHeightMeters, + out float sourceMin, + out float sourceMax, + bool preserveMinMax) + { + targetRes = Mathf.Max(1, targetRes); + if (source == null || source.Length == 0 || sourceWidth <= 0 || sourceHeight <= 0) + { + sourceMin = flatHeightMeters; + sourceMax = flatHeightMeters; + return CreateFlatArray(targetRes, flatHeightMeters); + } + + if (source.Length != sourceWidth * sourceHeight) + { + Debug.LogWarning( + $"DemResampler: source length {source.Length} does not match {sourceWidth}x{sourceHeight}. Using flat DEM."); + sourceMin = flatHeightMeters; + sourceMax = flatHeightMeters; + return CreateFlatArray(targetRes, flatHeightMeters); + } + + ComputeMinMax(source, out sourceMin, out sourceMax); + + if (targetRes == sourceWidth && targetRes == sourceHeight) + { + float[] copy = new float[source.Length]; + Array.Copy(source, copy, source.Length); + if (preserveMinMax) + { + NormalizeToMinMax(copy, sourceMin, sourceMax); + } + + return copy; + } + + float[] output = new float[targetRes * targetRes]; + float invTarget = targetRes == 1 ? 0.0f : 1.0f / (targetRes - 1); + for (int y = 0; y < targetRes; y++) + { + float v = targetRes == 1 ? 0.5f : y * invTarget; + float fy = v * (sourceHeight - 1); + for (int x = 0; x < targetRes; x++) + { + float u = targetRes == 1 ? 0.5f : x * invTarget; + float fx = u * (sourceWidth - 1); + output[y * targetRes + x] = SampleBilinear(source, sourceWidth, sourceHeight, fx, fy); + } + } + + if (preserveMinMax) + { + NormalizeToMinMax(output, sourceMin, sourceMax); + } + + return output; + } + + private static float SampleBilinear(float[] source, int width, int height, float fx, float fy) + { + if (width <= 1 && height <= 1) + { + return source[0]; + } + + int x0 = Mathf.Clamp(Mathf.FloorToInt(fx), 0, width - 1); + int y0 = Mathf.Clamp(Mathf.FloorToInt(fy), 0, height - 1); + int x1 = Mathf.Min(x0 + 1, width - 1); + int y1 = Mathf.Min(y0 + 1, height - 1); + + float tx = fx - x0; + float ty = fy - y0; + + float a = source[y0 * width + x0]; + float b = source[y0 * width + x1]; + float c = source[y1 * width + x0]; + float d = source[y1 * width + x1]; + + float ab = Mathf.Lerp(a, b, tx); + float cd = Mathf.Lerp(c, d, tx); + return Mathf.Lerp(ab, cd, ty); + } + + private static void NormalizeToMinMax(float[] data, float targetMin, float targetMax) + { + ComputeMinMax(data, out float min, out float max); + if (Mathf.Abs(max - min) < MinRangeEpsilon) + { + for (int i = 0; i < data.Length; i++) + { + data[i] = targetMin; + } + + return; + } + + float scale = (targetMax - targetMin) / (max - min); + for (int i = 0; i < data.Length; i++) + { + float value = (data[i] - min) * scale + targetMin; + data[i] = Mathf.Clamp(value, targetMin, targetMax); + } + } + + private static void ClampMin(float[] data, float minValue) + { + for (int i = 0; i < data.Length; i++) + { + if (data[i] < minValue) + { + data[i] = minValue; + } + } + } + + private static float[] CreateFlatArray(int targetRes, float value) + { + int length = targetRes * targetRes; + float[] output = new float[length]; + for (int i = 0; i < length; i++) + { + output[i] = value; + } + + return output; + } + + private static float[] ExtractRedChannel(Texture2D texture) + { + Color[] colors = texture.GetPixels(); + float[] data = new float[colors.Length]; + for (int i = 0; i < colors.Length; i++) + { + data[i] = colors[i].r; + } + + return data; + } + + private static void ComputeMinMax(float[] data, out float min, out float max) + { + min = float.PositiveInfinity; + max = float.NegativeInfinity; + for (int i = 0; i < data.Length; i++) + { + float value = data[i]; + if (value < min) + { + min = value; + } + + if (value > max) + { + max = value; + } + } + + if (float.IsInfinity(min)) + { + min = 0.0f; + max = 0.0f; + } + } + } +} diff --git a/Assets/FloodSWE/Scripts/Preprocess/DemResampler.cs.meta b/Assets/FloodSWE/Scripts/Preprocess/DemResampler.cs.meta new file mode 100644 index 000000000..c81774c97 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Preprocess/DemResampler.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 2d6b3ef671f38f985b086628f39649ab \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Preprocess/TileStaticData.cs b/Assets/FloodSWE/Scripts/Preprocess/TileStaticData.cs new file mode 100644 index 000000000..78446c33b --- /dev/null +++ b/Assets/FloodSWE/Scripts/Preprocess/TileStaticData.cs @@ -0,0 +1,142 @@ +using System; +using UnityEngine; + +namespace FloodSWE.Preprocess +{ + public sealed class TileStaticData + { + public int GridRes { get; } + public float TileSizeMeters { get; } + public float MinHeight { get; } + public float MaxHeight { get; } + public float[] Heights { get; } + public float[] BuildingHeights { get; } + public byte[] Porosity { get; } + + public TileStaticData( + int gridRes, + float tileSizeMeters, + float[] heights, + float minHeight, + float maxHeight, + float[] buildingHeights = null, + byte[] porosity = null) + { + if (gridRes <= 0) + { + throw new ArgumentOutOfRangeException(nameof(gridRes)); + } + + if (tileSizeMeters <= 0.0f) + { + throw new ArgumentOutOfRangeException(nameof(tileSizeMeters)); + } + + if (heights == null) + { + throw new ArgumentNullException(nameof(heights)); + } + + int expectedLength = gridRes * gridRes; + if (heights.Length != expectedLength) + { + throw new ArgumentException("Heights length does not match grid resolution.", nameof(heights)); + } + + if (buildingHeights != null && buildingHeights.Length != expectedLength) + { + throw new ArgumentException("Building heights length does not match grid resolution.", nameof(buildingHeights)); + } + + if (porosity != null && porosity.Length != expectedLength) + { + throw new ArgumentException("Porosity length does not match grid resolution.", nameof(porosity)); + } + + GridRes = gridRes; + TileSizeMeters = tileSizeMeters; + MinHeight = minHeight; + MaxHeight = maxHeight; + Heights = heights; + BuildingHeights = buildingHeights; + Porosity = porosity; + } + + public static TileStaticData CreateFlat( + int gridRes, + float tileSizeMeters, + float heightMeters, + byte porosityValue = 255) + { + int length = gridRes * gridRes; + float[] heights = new float[length]; + byte[] porosity = new byte[length]; + for (int i = 0; i < length; i++) + { + heights[i] = heightMeters; + porosity[i] = porosityValue; + } + + return new TileStaticData(gridRes, tileSizeMeters, heights, heightMeters, heightMeters, null, porosity); + } + + public bool ValidateMinMax(float toleranceMeters, out float resampledMin, out float resampledMax) + { + ComputeMinMax(Heights, out resampledMin, out resampledMax); + if (toleranceMeters < 0.0f) + { + toleranceMeters = 0.0f; + } + + return Mathf.Abs(resampledMin - MinHeight) <= toleranceMeters + && Mathf.Abs(resampledMax - MaxHeight) <= toleranceMeters; + } + + public ushort[] EncodeHeightsToUInt16Mm() + { + int length = Heights.Length; + ushort[] packed = new ushort[length]; + for (int i = 0; i < length; i++) + { + float mm = (Heights[i] - MinHeight) * 1000.0f; + if (mm < 0.0f) + { + mm = 0.0f; + } + else if (mm > ushort.MaxValue) + { + mm = ushort.MaxValue; + } + + packed[i] = (ushort)Mathf.RoundToInt(mm); + } + + return packed; + } + + private static void ComputeMinMax(float[] data, out float min, out float max) + { + min = float.PositiveInfinity; + max = float.NegativeInfinity; + for (int i = 0; i < data.Length; i++) + { + float value = data[i]; + if (value < min) + { + min = value; + } + + if (value > max) + { + max = value; + } + } + + if (float.IsInfinity(min)) + { + min = 0.0f; + max = 0.0f; + } + } + } +} diff --git a/Assets/FloodSWE/Scripts/Preprocess/TileStaticData.cs.meta b/Assets/FloodSWE/Scripts/Preprocess/TileStaticData.cs.meta new file mode 100644 index 000000000..7bf44746e --- /dev/null +++ b/Assets/FloodSWE/Scripts/Preprocess/TileStaticData.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 99d03b8cf139bf08d9ec82636348cef2 \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Streaming.meta b/Assets/FloodSWE/Scripts/Streaming.meta new file mode 100644 index 000000000..dfa9f68ce --- /dev/null +++ b/Assets/FloodSWE/Scripts/Streaming.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 79b936f7f0c9c532f94e673537710f81 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/Streaming/HeightmapExtractor.cs b/Assets/FloodSWE/Scripts/Streaming/HeightmapExtractor.cs new file mode 100644 index 000000000..65da2c1de --- /dev/null +++ b/Assets/FloodSWE/Scripts/Streaming/HeightmapExtractor.cs @@ -0,0 +1,100 @@ +using FloodSWE.TileGraph; +using UnityEngine; + +namespace FloodSWE.Streaming +{ + public static class HeightmapExtractor + { + public static HeightmapPacket CreatePacketFromHeights(int frameId, TileId tile, int resolution, float[] heights) + { + return HeightmapPacket.FromHeights(frameId, tile, resolution, heights); + } + + public static HeightmapPacket CreateMockPacket(int frameId, TileId tile, int resolution, float timeSeconds, float baseHeight, float amplitude) + { + int sampleCount = resolution * resolution; + float[] heights = new float[sampleCount]; + float invRes = 1.0f / Mathf.Max(1, resolution - 1); + + int index = 0; + for (int y = 0; y < resolution; y++) + { + float fy = y * invRes; + for (int x = 0; x < resolution; x++) + { + float fx = x * invRes; + float wave = Mathf.Sin((fx + timeSeconds * 0.2f) * Mathf.PI * 2.0f) * + Mathf.Cos((fy + timeSeconds * 0.15f) * Mathf.PI * 2.0f); + heights[index++] = baseHeight + (amplitude * wave); + } + } + + return HeightmapPacket.FromHeights(frameId, tile, resolution, heights); + } + + public static bool TryExtractFromTexture2D(Texture2D texture, int frameId, TileId tile, out HeightmapPacket packet) + { + packet = default; + + if (texture == null) + { + return false; + } + + if (!texture.isReadable) + { + Debug.LogWarning("HeightmapExtractor: texture is not readable."); + return false; + } + + int width = texture.width; + int height = texture.height; + if (width != height) + { + Debug.LogWarning("HeightmapExtractor: texture must be square."); + return false; + } + + Color[] pixels = texture.GetPixels(); + float[] heights = new float[pixels.Length]; + for (int i = 0; i < pixels.Length; i++) + { + heights[i] = pixels[i].r; + } + + packet = HeightmapPacket.FromHeights(frameId, tile, width, heights); + return true; + } + + public static bool TryExtractFromRenderTexture(RenderTexture source, int frameId, TileId tile, out HeightmapPacket packet) + { + packet = default; + + if (source == null) + { + return false; + } + + int width = source.width; + int height = source.height; + if (width != height) + { + Debug.LogWarning("HeightmapExtractor: render texture must be square."); + return false; + } + + RenderTexture previous = RenderTexture.active; + RenderTexture.active = source; + + Texture2D temp = new Texture2D(width, height, TextureFormat.RFloat, false, true); + temp.ReadPixels(new Rect(0, 0, width, height), 0, 0); + temp.Apply(false, true); + + RenderTexture.active = previous; + + bool success = TryExtractFromTexture2D(temp, frameId, tile, out packet); + Object.Destroy(temp); + return success; + } + } +} diff --git a/Assets/FloodSWE/Scripts/Streaming/HeightmapExtractor.cs.meta b/Assets/FloodSWE/Scripts/Streaming/HeightmapExtractor.cs.meta new file mode 100644 index 000000000..2867f4ac9 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Streaming/HeightmapExtractor.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9d88c2b775fd75cbe9285e85e3d4630c \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Streaming/HeightmapInterpolator.cs b/Assets/FloodSWE/Scripts/Streaming/HeightmapInterpolator.cs new file mode 100644 index 000000000..82d5e5985 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Streaming/HeightmapInterpolator.cs @@ -0,0 +1,149 @@ +using UnityEngine; + +namespace FloodSWE.Streaming +{ + public sealed class HeightmapInterpolator + { + public float FrameDurationSeconds = 0.5f; + + private HeightmapPacket previousPacket; + private HeightmapPacket nextPacket; + private float[] previousHeights; + private float[] nextHeights; + private float[] blendedHeights; + private bool hasPrevious; + private bool hasNext; + private float blendTime; + + public void Reset() + { + previousPacket = default; + nextPacket = default; + previousHeights = null; + nextHeights = null; + blendedHeights = null; + hasPrevious = false; + hasNext = false; + blendTime = 0.0f; + } + + public void PushPacket(HeightmapPacket packet) + { + if (!packet.TryDecodeHeights(out float[] heights)) + { + return; + } + + if (!hasPrevious) + { + previousPacket = packet; + previousHeights = heights; + hasPrevious = true; + blendTime = 0.0f; + return; + } + + if (packet.Resolution != previousPacket.Resolution) + { + Reset(); + previousPacket = packet; + previousHeights = heights; + hasPrevious = true; + return; + } + + if (packet.FrameId <= previousPacket.FrameId) + { + if (packet.FrameId == previousPacket.FrameId) + { + previousPacket = packet; + previousHeights = heights; + blendTime = 0.0f; + } + + return; + } + + if (hasNext && packet.FrameId > nextPacket.FrameId) + { + PromoteNext(); + } + + nextPacket = packet; + nextHeights = heights; + hasNext = true; + blendTime = 0.0f; + } + + public void Step(float deltaTime) + { + if (!hasPrevious || !hasNext) + { + return; + } + + if (FrameDurationSeconds <= 0.0f) + { + PromoteNext(); + return; + } + + blendTime += Mathf.Max(0.0f, deltaTime); + if (blendTime >= FrameDurationSeconds) + { + PromoteNext(); + blendTime = Mathf.Max(0.0f, blendTime - FrameDurationSeconds); + } + } + + public bool TryGetHeights(out float[] heights) + { + heights = null; + + if (!hasPrevious) + { + return false; + } + + if (!hasNext || FrameDurationSeconds <= 0.0f) + { + heights = previousHeights; + return true; + } + + float alpha = Mathf.Clamp01(blendTime / FrameDurationSeconds); + EnsureBlendBuffer(previousHeights.Length); + + for (int i = 0; i < previousHeights.Length; i++) + { + blendedHeights[i] = Mathf.Lerp(previousHeights[i], nextHeights[i], alpha); + } + + heights = blendedHeights; + return true; + } + + private void PromoteNext() + { + if (!hasNext) + { + return; + } + + previousPacket = nextPacket; + previousHeights = nextHeights; + nextPacket = default; + nextHeights = null; + hasNext = false; + blendTime = 0.0f; + } + + private void EnsureBlendBuffer(int length) + { + if (blendedHeights == null || blendedHeights.Length != length) + { + blendedHeights = new float[length]; + } + } + } +} diff --git a/Assets/FloodSWE/Scripts/Streaming/HeightmapInterpolator.cs.meta b/Assets/FloodSWE/Scripts/Streaming/HeightmapInterpolator.cs.meta new file mode 100644 index 000000000..b08003c3e --- /dev/null +++ b/Assets/FloodSWE/Scripts/Streaming/HeightmapInterpolator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1af734fd97eacedd8a43b69a6c3c64bd \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Streaming/HeightmapPacket.cs b/Assets/FloodSWE/Scripts/Streaming/HeightmapPacket.cs new file mode 100644 index 000000000..2a12cd111 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Streaming/HeightmapPacket.cs @@ -0,0 +1,183 @@ +using System; +using System.IO; +using System.IO.Compression; +using FloodSWE.TileGraph; +using UnityEngine; + +namespace FloodSWE.Streaming +{ + [Serializable] + public struct HeightmapPacket + { + public int FrameId; + public TileId Tile; + public int Resolution; + public float MinHeight; + public float MaxHeight; + public byte[] Payload; + + public bool IsValid + { + get { return Resolution > 0 && Payload != null && Payload.Length > 0; } + } + + public int SampleCount + { + get { return Resolution * Resolution; } + } + + public static HeightmapPacket FromHeights(int frameId, TileId tile, int resolution, float[] heights) + { + if (heights == null) + { + throw new ArgumentNullException(nameof(heights)); + } + + int expected = resolution * resolution; + if (expected <= 0 || heights.Length != expected) + { + throw new ArgumentException("Height array size does not match resolution."); + } + + float minHeight = heights[0]; + float maxHeight = heights[0]; + for (int i = 1; i < heights.Length; i++) + { + float h = heights[i]; + if (h < minHeight) + { + minHeight = h; + } + else if (h > maxHeight) + { + maxHeight = h; + } + } + + float range = maxHeight - minHeight; + if (range <= 0.0f) + { + range = 0.001f; + maxHeight = minHeight + range; + } + + float maxRange = 65.535f; + if (range > maxRange) + { + maxHeight = minHeight + maxRange; + range = maxRange; + } + + ushort[] quantized = new ushort[expected]; + for (int i = 0; i < heights.Length; i++) + { + float delta = Mathf.Clamp(heights[i] - minHeight, 0.0f, range); + int mm = Mathf.RoundToInt(delta * 1000.0f); + quantized[i] = (ushort)Mathf.Clamp(mm, 0, ushort.MaxValue); + } + + byte[] payload = CompressUShorts(quantized); + + return new HeightmapPacket + { + FrameId = frameId, + Tile = tile, + Resolution = resolution, + MinHeight = minHeight, + MaxHeight = maxHeight, + Payload = payload + }; + } + + public bool TryDecodeHeights(out float[] heights) + { + heights = null; + + if (!IsValid) + { + return false; + } + + int expectedBytes = SampleCount * sizeof(ushort); + if (!TryDecompress(Payload, expectedBytes, out byte[] raw)) + { + return false; + } + + if (raw.Length < expectedBytes) + { + return false; + } + + ushort[] quantized = new ushort[SampleCount]; + Buffer.BlockCopy(raw, 0, quantized, 0, expectedBytes); + + heights = new float[SampleCount]; + for (int i = 0; i < quantized.Length; i++) + { + heights[i] = MinHeight + (quantized[i] * 0.001f); + } + + return true; + } + + private static byte[] CompressUShorts(ushort[] values) + { + byte[] raw = new byte[values.Length * sizeof(ushort)]; + Buffer.BlockCopy(values, 0, raw, 0, raw.Length); + + using (var output = new MemoryStream()) + { + using (var deflate = new DeflateStream(output, System.IO.Compression.CompressionLevel.Fastest, true)) + { + deflate.Write(raw, 0, raw.Length); + } + + return output.ToArray(); + } + } + + private static bool TryDecompress(byte[] payload, int expectedBytes, out byte[] raw) + { + raw = null; + + if (payload == null || payload.Length == 0) + { + return false; + } + + byte[] buffer = new byte[expectedBytes]; + int offset = 0; + + try + { + using (var input = new MemoryStream(payload)) + using (var deflate = new DeflateStream(input, CompressionMode.Decompress)) + { + while (offset < expectedBytes) + { + int read = deflate.Read(buffer, offset, expectedBytes - offset); + if (read == 0) + { + break; + } + + offset += read; + } + } + } + catch (Exception) + { + return false; + } + + if (offset < expectedBytes) + { + return false; + } + + raw = buffer; + return true; + } + } +} diff --git a/Assets/FloodSWE/Scripts/Streaming/HeightmapPacket.cs.meta b/Assets/FloodSWE/Scripts/Streaming/HeightmapPacket.cs.meta new file mode 100644 index 000000000..8abde1105 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Streaming/HeightmapPacket.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8711c3f108beb17afb343275ea2e69ab \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/SweTileSimulator.cs b/Assets/FloodSWE/Scripts/SweTileSimulator.cs new file mode 100644 index 000000000..32cab3d48 --- /dev/null +++ b/Assets/FloodSWE/Scripts/SweTileSimulator.cs @@ -0,0 +1,866 @@ +using System; +using FloodSWE.Preprocess; +using FloodSWE.Streaming; +using FloodSWE.TileGraph; +using Unity.Collections; +using UnityEngine; +using UnityEngine.Rendering; + +public sealed class SweTileSimulator : MonoBehaviour +{ + [Header("Resources")] + public ComputeShader ghostExchangeShader; + public ComputeShader fluxShader; + + [Header("Grid")] + public int gridRes = 256; + public float tileSizeMeters = 1000f; + + [Header("Initial Conditions")] + public bool damBreak = true; + public float initialDepthLeft = 1.0f; + public float initialDepthRight = 0.0f; + public Vector2 initialVelocity = Vector2.zero; + + [Header("Rain")] + public float rainRateMmPerHr = 0.0f; + + [Header("Simulation")] + public float cfl = 0.5f; + public float tickSeconds = 0.5f; + public bool logCfl = true; + public bool useFullPrecisionTextures = true; + + [Header("Static Data")] + public Texture2D terrainHeight; + public Texture2D porosity; + public float flatTerrainHeightMeters = 0.0f; + public float defaultPorosity = 1.0f; + public bool resampleTerrainToGrid = true; + public bool resamplePorosityToGrid = true; + + [Header("Streaming (Debug)")] + public bool emitHeightmapPackets = false; + public int packetEveryNTicks = 1; + public TileId packetTileId = new TileId(1, 0, 0); + public HeightmapPacket lastHeightmapPacket; + public event Action HeightmapPacketReady; + + [Header("Debug Outputs")] + public RenderTexture debugWater; + public RenderTexture debugVelocity; + + [Header("Debug - Conservation")] + public bool logConservation = false; + public bool debugClampStats = true; + public bool applyMassCorrection = false; + [Range(0.5f, 2.0f)] + public float massCorrectionMinScale = 0.8f; + [Range(0.5f, 2.0f)] + public float massCorrectionMaxScale = 1.2f; + + private const float Gravity = 9.81f; + + private RenderTexture waterA; + private RenderTexture waterB; + private RenderTexture velA; + private RenderTexture velB; + private RenderTexture waterGhost; + private RenderTexture velGhost; + private Texture2D resolvedTerrain; + private Texture2D resolvedPorosity; + private RenderTexture clampMask; + private RenderTexture dummyRw; + + private int ghostKernel; + private int fluxKernel; + private int initKernel; + + private float dx; + private float accumulatedTime; + private float lastMaxDepth; + private float lastMaxSpeed; + private float lastTotalDepth; + private float lastTotalVolume; + private int lastClampedCells; + private float lastClampedRatio; + private int lastNanCells; + private float lastDtMax; + private float lastDt; + private int lastSubsteps; + private bool useA = true; + private bool isInitialized; + private int tickIndex; + private float pendingMassScale = 1.0f; + + public float LastMaxDepth + { + get { return lastMaxDepth; } + } + + public float LastMaxSpeed + { + get { return lastMaxSpeed; } + } + + public float LastTotalDepth + { + get { return lastTotalDepth; } + } + + public float LastTotalVolume + { + get { return lastTotalVolume; } + } + + public int LastClampedCells + { + get { return lastClampedCells; } + } + + public float LastClampedRatio + { + get { return lastClampedRatio; } + } + + public int LastNanCells + { + get { return lastNanCells; } + } + + public float LastDtMax + { + get { return lastDtMax; } + } + + public float LastDt + { + get { return lastDt; } + } + + public int LastSubsteps + { + get { return lastSubsteps; } + } + + public float CellSizeMeters + { + get { return dx; } + } + + private void OnEnable() + { + Initialize(); + } + + private void OnDisable() + { + Release(); + } + + private void Update() + { + if (!isInitialized) + { + return; + } + + accumulatedTime += Time.deltaTime; + if (accumulatedTime < tickSeconds) + { + return; + } + + accumulatedTime -= tickSeconds; + + float maxDepth = Mathf.Max(lastMaxDepth, 1e-4f); + float maxSpeed = Mathf.Max(lastMaxSpeed, 0.0f); + float dtMax = cfl * dx / (maxSpeed + Mathf.Sqrt(Gravity * maxDepth)); + if (float.IsNaN(dtMax) || dtMax <= 0.0f) + { + dtMax = tickSeconds; + } + + int substeps = Mathf.Max(1, Mathf.CeilToInt(tickSeconds / dtMax)); + float dt = tickSeconds / substeps; + + lastDtMax = dtMax; + lastSubsteps = substeps; + lastDt = dt; + + if (logCfl) + { + Debug.Log($"SWE CFL: maxDepth={maxDepth:F3}m maxSpeed={maxSpeed:F3}m/s dtMax={dtMax:F4}s substeps={substeps} dt={dt:F4}s"); + } + + float rainRate = rainRateMmPerHr / 1000.0f / 3600.0f; + + for (int i = 0; i < substeps; i++) + { + DispatchGhost(); + DispatchFlux(dt, rainRate); + SwapBuffers(); + } + + if (applyMassCorrection && Mathf.Abs(pendingMassScale - 1.0f) > 0.001f) + { + DispatchScale(pendingMassScale); + pendingMassScale = 1.0f; + } + + debugWater = CurrentWater; + debugVelocity = CurrentVelocity; + + tickIndex++; + TryEmitHeightmapPacket(); + + RequestReadback(); + } + + private RenderTexture CurrentWater + { + get { return useA ? waterA : waterB; } + } + + private RenderTexture CurrentVelocity + { + get { return useA ? velA : velB; } + } + + private RenderTexture NextWater + { + get { return useA ? waterB : waterA; } + } + + private RenderTexture NextVelocity + { + get { return useA ? velB : velA; } + } + + private void Initialize() + { + if (ghostExchangeShader == null || fluxShader == null) + { + Debug.LogError("SweTileSimulator: missing compute shaders."); + return; + } + + if (useFullPrecisionTextures && SystemInfo.graphicsDeviceType == UnityEngine.Rendering.GraphicsDeviceType.OpenGLES3) + { + Debug.LogWarning("SweTileSimulator: full precision UAVs are unsupported on GLES3; falling back to half precision."); + useFullPrecisionTextures = false; + } + + gridRes = Mathf.Max(4, gridRes); + tileSizeMeters = Mathf.Max(1.0f, tileSizeMeters); + dx = tileSizeMeters / gridRes; + + ghostKernel = ghostExchangeShader.FindKernel("GhostExchange"); + fluxKernel = fluxShader.FindKernel("FluxUpdate"); + initKernel = fluxShader.FindKernel("InitDamBreak"); + RenderTextureFormat waterFormat = useFullPrecisionTextures ? RenderTextureFormat.RFloat : RenderTextureFormat.RHalf; + RenderTextureFormat velFormat = useFullPrecisionTextures ? RenderTextureFormat.RGFloat : RenderTextureFormat.RGHalf; + + waterA = CreateRenderTexture(gridRes, gridRes, waterFormat, "SWE_WaterA"); + waterB = CreateRenderTexture(gridRes, gridRes, waterFormat, "SWE_WaterB"); + velA = CreateRenderTexture(gridRes, gridRes, velFormat, "SWE_VelA"); + velB = CreateRenderTexture(gridRes, gridRes, velFormat, "SWE_VelB"); + waterGhost = CreateRenderTexture(gridRes + 2, gridRes + 2, waterFormat, "SWE_WaterGhost"); + velGhost = CreateRenderTexture(gridRes + 2, gridRes + 2, velFormat, "SWE_VelGhost"); + dummyRw = CreateRenderTexture(1, 1, waterFormat, "SWE_DummyRW"); + if (debugClampStats) + { + clampMask = CreateRenderTexture(gridRes, gridRes, waterFormat, "SWE_ClampMask"); + } + + ResolveStaticTextures(); + + ClearRenderTexture(waterB); + ClearRenderTexture(velB); + ClearRenderTexture(waterGhost); + ClearRenderTexture(velGhost); + + fluxShader.SetInt("_GridRes", gridRes); + fluxShader.SetFloat("_InitDepthLeft", initialDepthLeft); + fluxShader.SetFloat("_InitDepthRight", initialDepthRight); + fluxShader.SetVector("_InitVelocity", initialVelocity); + fluxShader.SetInt("_InitDamBreak", damBreak ? 1 : 0); + fluxShader.SetTexture(initKernel, "_WaterOut", waterA); + fluxShader.SetTexture(initKernel, "_VelOut", velA); + DispatchKernel(fluxShader, initKernel, gridRes, gridRes); + + useA = true; + debugWater = CurrentWater; + debugVelocity = CurrentVelocity; + + lastMaxDepth = Mathf.Max(initialDepthLeft, initialDepthRight); + lastMaxSpeed = Mathf.Sqrt(Gravity * Mathf.Max(lastMaxDepth, 0.0f)); + isInitialized = true; + } + + private void Release() + { + isInitialized = false; + ReleaseRenderTexture(waterA); + ReleaseRenderTexture(waterB); + ReleaseRenderTexture(velA); + ReleaseRenderTexture(velB); + ReleaseRenderTexture(waterGhost); + ReleaseRenderTexture(velGhost); + ReleaseRenderTexture(clampMask); + ReleaseRenderTexture(dummyRw); + waterA = null; + waterB = null; + velA = null; + velB = null; + waterGhost = null; + velGhost = null; + clampMask = null; + dummyRw = null; + + if (resolvedTerrain != null && resolvedTerrain != terrainHeight) + { + Destroy(resolvedTerrain); + } + + if (resolvedPorosity != null && resolvedPorosity != porosity) + { + Destroy(resolvedPorosity); + } + + resolvedTerrain = null; + resolvedPorosity = null; + } + + private void ResolveStaticTextures() + { + resolvedTerrain = ResolveTerrainTexture(); + resolvedPorosity = ResolvePorosityTexture(); + } + + private Texture2D ResolveTerrainTexture() + { + if (terrainHeight == null) + { + return CreateFlatTexture(gridRes, flatTerrainHeightMeters); + } + + if (!resampleTerrainToGrid) + { + if (terrainHeight.width == gridRes && terrainHeight.height == gridRes) + { + return terrainHeight; + } + + Debug.LogWarning("SweTileSimulator: terrainHeight size mismatch and resampling disabled. Using flat terrain."); + return CreateFlatTexture(gridRes, flatTerrainHeightMeters); + } + + if (!terrainHeight.isReadable) + { + Debug.LogWarning("SweTileSimulator: terrainHeight texture must be readable. Using flat terrain."); + return CreateFlatTexture(gridRes, flatTerrainHeightMeters); + } + + if (terrainHeight.width == gridRes && terrainHeight.height == gridRes) + { + return terrainHeight; + } + + float minHeight; + float maxHeight; + float[] resampled = DemResampler.ResampleHeights( + terrainHeight, + gridRes, + flatTerrainHeightMeters, + out minHeight, + out maxHeight, + true); + + return CreateTextureFromFloats(resampled, gridRes, gridRes, "SWE_TerrainResampled"); + } + + private Texture2D ResolvePorosityTexture() + { + if (porosity == null) + { + return CreateFlatTexture(gridRes, Mathf.Clamp01(defaultPorosity)); + } + + if (!resamplePorosityToGrid) + { + if (porosity.width == gridRes && porosity.height == gridRes) + { + return porosity; + } + + Debug.LogWarning("SweTileSimulator: porosity size mismatch and resampling disabled. Using default porosity."); + return CreateFlatTexture(gridRes, Mathf.Clamp01(defaultPorosity)); + } + + if (!porosity.isReadable) + { + Debug.LogWarning("SweTileSimulator: porosity texture must be readable. Using default porosity."); + return CreateFlatTexture(gridRes, Mathf.Clamp01(defaultPorosity)); + } + + if (porosity.width == gridRes && porosity.height == gridRes) + { + return porosity; + } + + float minValue; + float maxValue; + float[] resampled = DemResampler.ResampleHeights( + porosity, + gridRes, + Mathf.Clamp01(defaultPorosity), + out minValue, + out maxValue, + false); + + Clamp01(resampled); + return CreateTextureFromFloats(resampled, gridRes, gridRes, "SWE_PorosityResampled"); + } + + private void TryEmitHeightmapPacket() + { + if (!emitHeightmapPackets) + { + return; + } + + int interval = Mathf.Max(1, packetEveryNTicks); + if (tickIndex % interval != 0) + { + return; + } + + if (HeightmapExtractor.TryExtractFromRenderTexture(debugWater, tickIndex, packetTileId, out HeightmapPacket packet)) + { + lastHeightmapPacket = packet; + HeightmapPacketReady?.Invoke(packet); + } + } + + private void DispatchGhost() + { + ghostExchangeShader.SetInt("_GridRes", gridRes); + ghostExchangeShader.SetInt("_HasNorth", 0); + ghostExchangeShader.SetInt("_HasSouth", 0); + ghostExchangeShader.SetInt("_HasEast", 0); + ghostExchangeShader.SetInt("_HasWest", 0); + + ghostExchangeShader.SetTexture(ghostKernel, "_WaterIn", CurrentWater); + ghostExchangeShader.SetTexture(ghostKernel, "_VelIn", CurrentVelocity); + ghostExchangeShader.SetTexture(ghostKernel, "_WaterNorth", CurrentWater); + ghostExchangeShader.SetTexture(ghostKernel, "_VelNorth", CurrentVelocity); + ghostExchangeShader.SetTexture(ghostKernel, "_WaterSouth", CurrentWater); + ghostExchangeShader.SetTexture(ghostKernel, "_VelSouth", CurrentVelocity); + ghostExchangeShader.SetTexture(ghostKernel, "_WaterEast", CurrentWater); + ghostExchangeShader.SetTexture(ghostKernel, "_VelEast", CurrentVelocity); + ghostExchangeShader.SetTexture(ghostKernel, "_WaterWest", CurrentWater); + ghostExchangeShader.SetTexture(ghostKernel, "_VelWest", CurrentVelocity); + ghostExchangeShader.SetTexture(ghostKernel, "_WaterOut", waterGhost); + ghostExchangeShader.SetTexture(ghostKernel, "_VelOut", velGhost); + + DispatchKernel(ghostExchangeShader, ghostKernel, gridRes + 2, gridRes + 2); + } + + private void DispatchFlux(float dt, float rainRate) + { + fluxShader.SetInt("_GridRes", gridRes); + fluxShader.SetFloat("_Dx", dx); + fluxShader.SetFloat("_Dt", dt); + fluxShader.SetFloat("_Gravity", Gravity); + fluxShader.SetFloat("_RainRate", Mathf.Max(0.0f, rainRate)); + fluxShader.SetInt("_UsePorosity", resolvedPorosity != null ? 1 : 0); + + fluxShader.SetTexture(fluxKernel, "_WaterIn", waterGhost); + fluxShader.SetTexture(fluxKernel, "_VelIn", velGhost); + fluxShader.SetTexture(fluxKernel, "_WaterOut", NextWater); + fluxShader.SetTexture(fluxKernel, "_VelOut", NextVelocity); + fluxShader.SetTexture(fluxKernel, "_TerrainHeight", resolvedTerrain); + fluxShader.SetTexture(fluxKernel, "_Porosity", resolvedPorosity); + fluxShader.SetInt("_DebugClamp", debugClampStats ? 1 : 0); + fluxShader.SetTexture(fluxKernel, "_ClampMask", debugClampStats && clampMask != null ? clampMask : dummyRw); + + DispatchKernel(fluxShader, fluxKernel, gridRes, gridRes); + } + + private void DispatchScale(float scale) + { + int scaleKernel = fluxShader.FindKernel("ScaleWater"); + fluxShader.SetInt("_GridRes", gridRes); + fluxShader.SetFloat("_Scale", Mathf.Clamp(scale, 0.0f, 10.0f)); + fluxShader.SetTexture(scaleKernel, "_WaterOut", CurrentWater); + DispatchKernel(fluxShader, scaleKernel, gridRes, gridRes); + } + + private void SwapBuffers() + { + useA = !useA; + } + + private void RequestReadback() + { + if (!SystemInfo.supportsAsyncGPUReadback) + { + return; + } + + RenderTexture water = CurrentWater; + RenderTexture vel = CurrentVelocity; + + if (useFullPrecisionTextures) + { + AsyncGPUReadback.Request(water, 0, request => + { + if (!request.hasError) + { + DepthStats stats = ComputeDepthStats(request.GetData()); + if (!float.IsNaN(stats.Max) && !float.IsInfinity(stats.Max)) + { + lastMaxDepth = stats.Max; + } + + if (!float.IsNaN(stats.Sum) && !float.IsInfinity(stats.Sum)) + { + float previousTotal = lastTotalDepth > 0.0f ? lastTotalDepth : stats.Sum; + lastTotalDepth = stats.Sum; + lastTotalVolume = stats.Sum * (dx * dx); + + if (applyMassCorrection && stats.Sum > 1e-6f) + { + float scale = previousTotal / stats.Sum; + pendingMassScale = Mathf.Clamp(scale, massCorrectionMinScale, massCorrectionMaxScale); + } + + if (logConservation) + { + Debug.Log($"SWE mass: totalDepth={lastTotalDepth:F3}m volume={lastTotalVolume:F3}m^3 scaleNext={pendingMassScale:F3}"); + } + } + } + }); + + AsyncGPUReadback.Request(vel, 0, request => + { + if (!request.hasError) + { + float speed = ComputeMaxSpeed(request.GetData()); + if (!float.IsNaN(speed) && !float.IsInfinity(speed)) + { + lastMaxSpeed = speed; + } + } + }); + } + else + { + AsyncGPUReadback.Request(water, 0, request => + { + if (!request.hasError) + { + DepthStats stats = ComputeDepthStats(request.GetData()); + if (!float.IsNaN(stats.Max) && !float.IsInfinity(stats.Max)) + { + lastMaxDepth = stats.Max; + } + + if (!float.IsNaN(stats.Sum) && !float.IsInfinity(stats.Sum)) + { + float previousTotal = lastTotalDepth > 0.0f ? lastTotalDepth : stats.Sum; + lastTotalDepth = stats.Sum; + lastTotalVolume = stats.Sum * (dx * dx); + + if (applyMassCorrection && stats.Sum > 1e-6f) + { + float scale = previousTotal / stats.Sum; + pendingMassScale = Mathf.Clamp(scale, massCorrectionMinScale, massCorrectionMaxScale); + } + + if (logConservation) + { + Debug.Log($"SWE mass: totalDepth={lastTotalDepth:F3}m volume={lastTotalVolume:F3}m^3 scaleNext={pendingMassScale:F3}"); + } + } + } + }); + + AsyncGPUReadback.Request(vel, 0, request => + { + if (!request.hasError) + { + float speed = ComputeMaxSpeed(request.GetData()); + if (!float.IsNaN(speed) && !float.IsInfinity(speed)) + { + lastMaxSpeed = speed; + } + } + }); + } + + if (debugClampStats && clampMask != null) + { + AsyncGPUReadback.Request(clampMask, 0, request => + { + if (!request.hasError) + { + ClampStats stats = useFullPrecisionTextures + ? CountClampStats(request.GetData()) + : CountClampStats(request.GetData()); + lastClampedCells = stats.Clamped; + lastNanCells = stats.NaNs; + lastClampedRatio = stats.Clamped / (float)(gridRes * gridRes); + } + }); + } + } + + private static DepthStats ComputeDepthStats(NativeArray data) + { + float max = 0.0f; + double sum = 0.0; + for (int i = 0; i < data.Length; i++) + { + float h = HalfToFloat(data[i]); + if (h > max) + { + max = h; + } + if (h > 0.0f) + { + sum += h; + } + } + return new DepthStats(max, (float)sum); + } + + private static DepthStats ComputeDepthStats(NativeArray data) + { + float max = 0.0f; + double sum = 0.0; + for (int i = 0; i < data.Length; i++) + { + float h = data[i]; + if (h > max) + { + max = h; + } + if (h > 0.0f) + { + sum += h; + } + } + return new DepthStats(max, (float)sum); + } + + private static float ComputeMaxSpeed(NativeArray data) + { + float max = 0.0f; + int count = data.Length / 2; + for (int i = 0; i < count; i++) + { + float u = HalfToFloat(data[i * 2]); + float v = HalfToFloat(data[i * 2 + 1]); + float speed = Mathf.Sqrt(u * u + v * v); + if (speed > max) + { + max = speed; + } + } + return max; + } + + private static float ComputeMaxSpeed(NativeArray data) + { + float max = 0.0f; + int count = data.Length / 2; + for (int i = 0; i < count; i++) + { + float u = data[i * 2]; + float v = data[i * 2 + 1]; + float speed = Mathf.Sqrt(u * u + v * v); + if (speed > max) + { + max = speed; + } + } + return max; + } + + private static ClampStats CountClampStats(NativeArray data) + { + int clamped = 0; + int nans = 0; + for (int i = 0; i < data.Length; i++) + { + float v = HalfToFloat(data[i]); + if (v > 1.5f) + { + nans++; + } + else if (v > 0.5f) + { + clamped++; + } + } + return new ClampStats(clamped, nans); + } + + private static ClampStats CountClampStats(NativeArray data) + { + int clamped = 0; + int nans = 0; + for (int i = 0; i < data.Length; i++) + { + float v = data[i]; + if (v > 1.5f) + { + nans++; + } + else if (v > 0.5f) + { + clamped++; + } + } + return new ClampStats(clamped, nans); + } + + private readonly struct ClampStats + { + public readonly int Clamped; + public readonly int NaNs; + + public ClampStats(int clamped, int nans) + { + Clamped = clamped; + NaNs = nans; + } + } + + private static float HalfToFloat(ushort half) + { + uint sign = (uint)(half >> 15) & 0x00000001u; + int exp = (half >> 10) & 0x0000001F; + int mant = half & 0x000003FF; + + if (exp == 0) + { + if (mant == 0) + { + return sign == 1 ? -0.0f : 0.0f; + } + + exp = 1; + while ((mant & 0x00000400) == 0) + { + mant <<= 1; + exp--; + } + mant &= 0x000003FF; + } + else if (exp == 31) + { + uint infNaN = (sign << 31) | 0x7F800000u | ((uint)mant << 13); + return BitConverter.ToSingle(BitConverter.GetBytes(infNaN), 0); + } + + uint fexp = (uint)(exp + (127 - 15)); + uint fmant = (uint)(mant << 13); + uint bits = (sign << 31) | (fexp << 23) | fmant; + return BitConverter.ToSingle(BitConverter.GetBytes(bits), 0); + } + + private readonly struct DepthStats + { + public readonly float Max; + public readonly float Sum; + + public DepthStats(float max, float sum) + { + Max = max; + Sum = sum; + } + } + + private static Texture2D CreateTextureFromFloats(float[] data, int width, int height, string name) + { + Texture2D texture = new Texture2D(width, height, TextureFormat.RFloat, false, true) + { + name = name, + wrapMode = TextureWrapMode.Clamp, + filterMode = FilterMode.Point + }; + + texture.SetPixelData(data, 0); + texture.Apply(false, true); + return texture; + } + + private static Texture2D CreateFlatTexture(int resolution, float value) + { + int length = resolution * resolution; + float[] data = new float[length]; + for (int i = 0; i < length; i++) + { + data[i] = value; + } + + return CreateTextureFromFloats(data, resolution, resolution, "SWE_Flat"); + } + + private static void Clamp01(float[] data) + { + for (int i = 0; i < data.Length; i++) + { + data[i] = Mathf.Clamp01(data[i]); + } + } + + private static RenderTexture CreateRenderTexture(int width, int height, RenderTextureFormat format, string name) + { + RenderTexture rt = new RenderTexture(width, height, 0, format) + { + name = name, + enableRandomWrite = true, + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp + }; + rt.Create(); + return rt; + } + + private static void ClearRenderTexture(RenderTexture rt) + { + if (rt == null) + { + return; + } + + RenderTexture prev = RenderTexture.active; + RenderTexture.active = rt; + GL.Clear(false, true, Color.clear); + RenderTexture.active = prev; + } + + private static void ReleaseRenderTexture(RenderTexture rt) + { + if (rt != null) + { + rt.Release(); + } + } + + private static void DispatchKernel(ComputeShader shader, int kernel, int width, int height) + { + uint x; + uint y; + uint z; + shader.GetKernelThreadGroupSizes(kernel, out x, out y, out z); + int groupsX = Mathf.CeilToInt(width / (float)x); + int groupsY = Mathf.CeilToInt(height / (float)y); + shader.Dispatch(kernel, groupsX, groupsY, 1); + } +} diff --git a/Assets/FloodSWE/Scripts/SweTileSimulator.cs.meta b/Assets/FloodSWE/Scripts/SweTileSimulator.cs.meta new file mode 100644 index 000000000..9493d8f7b --- /dev/null +++ b/Assets/FloodSWE/Scripts/SweTileSimulator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5539ed5458731ed3db3b49073dcaa1d7 \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Tests.meta b/Assets/FloodSWE/Scripts/Tests.meta new file mode 100644 index 000000000..44c75dcba --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e1cdfecc9178688ffa8c85d23e58c858 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/Tests/DamBreakTest.cs b/Assets/FloodSWE/Scripts/Tests/DamBreakTest.cs new file mode 100644 index 000000000..034b8badb --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/DamBreakTest.cs @@ -0,0 +1,58 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +public sealed class DamBreakTest +{ + private const float Gravity = 9.81f; + + [UnityTest] + public IEnumerator DamBreak_WaveFrontAdvances() + { + if (!SweTestUtils.SupportsGpu) + { + Assert.Ignore("Compute shaders or async GPU readback not supported on this platform."); + } + + SweTileSimulator sim = SweTestUtils.CreateSimulator( + gridRes: 64, + tileSizeMeters: 20.0f, + tickSeconds: 0.1f, + damBreak: true, + initialDepthLeft: 1.0f, + initialDepthRight: 0.0f, + rainRateMmPerHr: 0.0f); + + if (sim == null) + { + Assert.Ignore("Compute shader assets could not be loaded."); + } + + yield return null; + + float simSeconds = 1.0f; + float endTime = Time.time + simSeconds; + while (Time.time < endTime) + { + yield return null; + } + + float dx = sim.tileSizeMeters / sim.gridRes; + float expectedFront = 2.0f * Mathf.Sqrt(Gravity * sim.initialDepthLeft) * simSeconds; + float measuredFront = 0.0f; + + yield return SweTestUtils.Readback(sim.debugWater, data => + { + measuredFront = SweTestUtils.ComputeFrontDistance(data, sim.gridRes, dx, threshold: 0.005f); + }); + + Object.Destroy(sim.gameObject); + + float tolerance = Mathf.Max(dx * 2.0f, expectedFront * 0.35f); + float lower = Mathf.Max(0.0f, expectedFront - tolerance); + float upper = expectedFront + tolerance; + Assert.That(measuredFront, Is.InRange(lower, upper), + $"Wave front distance {measuredFront:F3}m outside expected range [{lower:F3}, {upper:F3}]m"); + } +} diff --git a/Assets/FloodSWE/Scripts/Tests/DamBreakTest.cs.meta b/Assets/FloodSWE/Scripts/Tests/DamBreakTest.cs.meta new file mode 100644 index 000000000..bf1877535 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/DamBreakTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 066b13730d1434bf7a3f5b01fcfc7f1f \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Tests/FloodSWE.Tests.asmdef b/Assets/FloodSWE/Scripts/Tests/FloodSWE.Tests.asmdef new file mode 100644 index 000000000..e0d734b66 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/FloodSWE.Tests.asmdef @@ -0,0 +1,18 @@ +{ + "name": "FloodSWE.Tests", + "references": [ + "FloodSWE.Runtime" + ], + "optionalUnityReferences": [ + "TestAssemblies" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/Assets/FloodSWE/Scripts/Tests/FloodSWE.Tests.asmdef.meta b/Assets/FloodSWE/Scripts/Tests/FloodSWE.Tests.asmdef.meta new file mode 100644 index 000000000..f21eb2fc0 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/FloodSWE.Tests.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6849d9f7c0440852ab0d17f21dc5c934 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/Tests/RainFillTest.cs b/Assets/FloodSWE/Scripts/Tests/RainFillTest.cs new file mode 100644 index 000000000..189b95b7a --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/RainFillTest.cs @@ -0,0 +1,55 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +public sealed class RainFillTest +{ + [UnityTest] + public IEnumerator RainFill_AccumulatesAtExpectedRate() + { + if (!SweTestUtils.SupportsGpu) + { + Assert.Ignore("Compute shaders or async GPU readback not supported on this platform."); + } + + const float rainRateMmPerHr = 3600.0f; + SweTileSimulator sim = SweTestUtils.CreateSimulator( + gridRes: 32, + tileSizeMeters: 10.0f, + tickSeconds: 0.1f, + damBreak: false, + initialDepthLeft: 0.0f, + initialDepthRight: 0.0f, + rainRateMmPerHr: rainRateMmPerHr); + + if (sim == null) + { + Assert.Ignore("Compute shader assets could not be loaded."); + } + + yield return null; + + float simSeconds = 2.0f; + float endTime = Time.time + simSeconds; + while (Time.time < endTime) + { + yield return null; + } + + float expected = (rainRateMmPerHr / 1000.0f / 3600.0f) * simSeconds; + float measured = 0.0f; + + yield return SweTestUtils.Readback(sim.debugWater, data => + { + measured = SweTestUtils.ComputeAverageDepth(data); + }); + + Object.Destroy(sim.gameObject); + + float lower = expected * 0.7f; + float upper = expected * 1.3f; + Assert.That(measured, Is.InRange(lower, upper), + $"Average depth {measured:F6}m outside expected range [{lower:F6}, {upper:F6}]m"); + } +} diff --git a/Assets/FloodSWE/Scripts/Tests/RainFillTest.cs.meta b/Assets/FloodSWE/Scripts/Tests/RainFillTest.cs.meta new file mode 100644 index 000000000..322a50994 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/RainFillTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 725efcb4f40636aafa694f519aaa23a5 \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/Tests/SweTestUtils.cs b/Assets/FloodSWE/Scripts/Tests/SweTestUtils.cs new file mode 100644 index 000000000..a8ef02e8c --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/SweTestUtils.cs @@ -0,0 +1,151 @@ +using System.Collections; +using NUnit.Framework; +using Unity.Collections; +using UnityEngine; +using UnityEngine.Rendering; + +internal static class SweTestUtils +{ + public static bool SupportsGpu + { + get { return SystemInfo.supportsComputeShaders && SystemInfo.supportsAsyncGPUReadback; } + } + + public static ComputeShader LoadComputeShader(string assetPath) + { +#if UNITY_EDITOR + return UnityEditor.AssetDatabase.LoadAssetAtPath(assetPath); +#else + return null; +#endif + } + + public static SweTileSimulator CreateSimulator( + int gridRes, + float tileSizeMeters, + float tickSeconds, + bool damBreak, + float initialDepthLeft, + float initialDepthRight, + float rainRateMmPerHr) + { + ComputeShader ghost = LoadComputeShader("Assets/FloodSWE/Compute/SWE_GhostExchange.compute"); + ComputeShader flux = LoadComputeShader("Assets/FloodSWE/Compute/SWE_Flux.compute"); + + if (ghost == null || flux == null) + { + return null; + } + + GameObject go = new GameObject("SweTileSimulator_Test"); + go.SetActive(false); + SweTileSimulator sim = go.AddComponent(); + sim.ghostExchangeShader = ghost; + sim.fluxShader = flux; + sim.gridRes = gridRes; + sim.tileSizeMeters = tileSizeMeters; + sim.tickSeconds = tickSeconds; + sim.damBreak = damBreak; + sim.initialDepthLeft = initialDepthLeft; + sim.initialDepthRight = initialDepthRight; + sim.initialVelocity = Vector2.zero; + sim.rainRateMmPerHr = rainRateMmPerHr; + sim.logCfl = false; + sim.terrainHeight = null; + sim.porosity = null; + go.SetActive(true); + return sim; + } + + public static IEnumerator WaitForSecondsRealtime(float seconds) + { + float endTime = Time.realtimeSinceStartup + seconds; + while (Time.realtimeSinceStartup < endTime) + { + yield return null; + } + } + + public static IEnumerator Readback(RenderTexture rt, System.Action> onData) + { + AsyncGPUReadbackRequest request = AsyncGPUReadback.Request(rt, 0); + while (!request.done) + { + yield return null; + } + + if (request.hasError) + { + Assert.Fail("Async GPU readback failed."); + } + + onData(request.GetData()); + } + + public static float ComputeAverageDepth(NativeArray data) + { + double sum = 0.0; + for (int i = 0; i < data.Length; i++) + { + sum += HalfToFloat(data[i]); + } + return (float)(sum / data.Length); + } + + public static float ComputeFrontDistance(NativeArray data, int gridRes, float dx, float threshold) + { + int center = gridRes / 2; + int frontX = center; + + for (int x = center; x < gridRes; x++) + { + double sum = 0.0; + for (int y = 0; y < gridRes; y++) + { + int index = y * gridRes + x; + sum += HalfToFloat(data[index]); + } + + float avg = (float)(sum / gridRes); + if (avg >= threshold) + { + frontX = x; + } + } + + return Mathf.Max(0.0f, (frontX - center) * dx); + } + + private static float HalfToFloat(ushort half) + { + uint sign = (uint)(half >> 15) & 0x00000001u; + int exp = (half >> 10) & 0x0000001F; + int mant = half & 0x000003FF; + + if (exp == 0) + { + if (mant == 0) + { + return sign == 1 ? -0.0f : 0.0f; + } + + exp = 1; + while ((mant & 0x00000400) == 0) + { + mant <<= 1; + exp--; + } + mant &= 0x000003FF; + } + else if (exp == 31) + { + uint infNaN = (sign << 31) | 0x7F800000u | ((uint)mant << 13); + return System.BitConverter.ToSingle(System.BitConverter.GetBytes(infNaN), 0); + } + + uint fexp = (uint)(exp + (127 - 15)); + uint fmant = (uint)(mant << 13); + uint bits = (sign << 31) | (fexp << 23) | fmant; + return System.BitConverter.ToSingle(System.BitConverter.GetBytes(bits), 0); + } +} diff --git a/Assets/FloodSWE/Scripts/Tests/SweTestUtils.cs.meta b/Assets/FloodSWE/Scripts/Tests/SweTestUtils.cs.meta new file mode 100644 index 000000000..d3435a948 --- /dev/null +++ b/Assets/FloodSWE/Scripts/Tests/SweTestUtils.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 51a90ee6e9ee03fd9bc56ce6a64de61a \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/TileGraph.meta b/Assets/FloodSWE/Scripts/TileGraph.meta new file mode 100644 index 000000000..ff449afca --- /dev/null +++ b/Assets/FloodSWE/Scripts/TileGraph.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 062c43bc68188239e86255fca867f481 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Scripts/TileGraph/LodTileManager.cs b/Assets/FloodSWE/Scripts/TileGraph/LodTileManager.cs new file mode 100644 index 000000000..a17377130 --- /dev/null +++ b/Assets/FloodSWE/Scripts/TileGraph/LodTileManager.cs @@ -0,0 +1,479 @@ +using System.Collections.Generic; +using System.Text; +using UnityEngine; + +namespace FloodSWE.TileGraph +{ + public sealed class LodTileManager : MonoBehaviour + { + [Header("LOD")] + public int minLod = 1; + public int maxLod = 4; + public float baseTileSizeMeters = 1000f; + public int maxActiveTiles = 128; + + [Tooltip("LOD0 is disabled by default because L0/L1 share the same world size in the plan.")] + public bool enableLod0 = false; + + [Header("Test Grid")] + public bool buildTestGridOnStart = true; + public int testGridLod = 1; + public Vector2Int testGridSize = new Vector2Int(4, 4); + public int refineSteps = 2; + public int refinePerStep = 1; + public int randomSeed = 1; + + [Header("Debug")] + public bool logActiveTiles = true; + public bool logAdjacencyFixes = false; + public bool logBudget = false; + + private const float AdjacencyEpsilon = 0.001f; + + private readonly Dictionary nodes = new Dictionary(); + private readonly List scratchActive = new List(); + private readonly List scratchSorted = new List(); + + private void Start() + { + NormalizeLodSettings(); + + if (!buildTestGridOnStart) + { + return; + } + + BuildTestGrid(); + AssignPriorityByDistance(); + RefineByPriority(refineSteps, refinePerStep); + EnforceAdjacency(); + EnforceBudget(); + EnforceAdjacency(); + + if (logActiveTiles) + { + LogActiveTilesPerLod(); + } + } + + public void BuildTestGrid() + { + nodes.Clear(); + + NormalizeLodSettings(); + testGridLod = Mathf.Clamp(testGridLod, minLod, maxLod); + testGridSize = new Vector2Int(Mathf.Max(1, testGridSize.x), Mathf.Max(1, testGridSize.y)); + + for (int y = 0; y < testGridSize.y; y++) + { + for (int x = 0; x < testGridSize.x; x++) + { + TileId id = new TileId(testGridLod, x, y); + TileNode node = GetOrCreateNode(id); + node.Active = true; + node.Priority = 0.0f; + } + } + } + + public void AssignPriorityByDistance() + { + GetActiveLeafNodes(scratchActive); + if (scratchActive.Count == 0) + { + return; + } + + float centerX = 0.0f; + float centerY = 0.0f; + for (int i = 0; i < scratchActive.Count; i++) + { + centerX += scratchActive[i].Id.X; + centerY += scratchActive[i].Id.Y; + } + + centerX /= scratchActive.Count; + centerY /= scratchActive.Count; + + for (int i = 0; i < scratchActive.Count; i++) + { + TileNode node = scratchActive[i]; + float dx = node.Id.X - centerX; + float dy = node.Id.Y - centerY; + float dist = Mathf.Sqrt(dx * dx + dy * dy); + node.Priority = 1.0f / (1.0f + dist); + } + } + + public void RefineByPriority(int steps, int perStep) + { + steps = Mathf.Max(0, steps); + perStep = Mathf.Max(0, perStep); + + for (int step = 0; step < steps; step++) + { + GetActiveLeafNodes(scratchActive); + if (scratchActive.Count == 0) + { + return; + } + + scratchSorted.Clear(); + scratchSorted.AddRange(scratchActive); + scratchSorted.Sort((a, b) => b.Priority.CompareTo(a.Priority)); + + int refineCount = Mathf.Min(perStep, scratchSorted.Count); + for (int i = 0; i < refineCount; i++) + { + Refine(scratchSorted[i]); + } + } + } + + public void EnforceAdjacency() + { + int guard = 0; + while (guard < 200) + { + guard++; + bool changed = false; + + GetActiveLeafNodes(scratchActive); + for (int i = 0; i < scratchActive.Count && !changed; i++) + { + TileNode a = scratchActive[i]; + for (int j = i + 1; j < scratchActive.Count; j++) + { + TileNode b = scratchActive[j]; + if (!AreAdjacent(a, b)) + { + continue; + } + + int diff = Mathf.Abs(a.Id.Lod - b.Id.Lod); + if (diff <= 1) + { + continue; + } + + TileNode coarse = a.Id.Lod < b.Id.Lod ? a : b; + if (Refine(coarse)) + { + if (logAdjacencyFixes) + { + TileNode fine = coarse == a ? b : a; + Debug.Log($"LOD adjacency fix: refined {coarse.Id} next to {fine.Id}."); + } + + changed = true; + break; + } + } + } + + if (!changed) + { + break; + } + } + } + + public void EnforceBudget() + { + if (maxActiveTiles <= 0) + { + return; + } + + int guard = 0; + while (CountActiveLeaves() > maxActiveTiles && guard < 200) + { + guard++; + if (!TryCoarsenLowestPriority()) + { + break; + } + } + } + + public void LogActiveTilesPerLod() + { + int lodCount = Mathf.Max(1, maxLod - minLod + 1); + int[] counts = new int[lodCount]; + + foreach (TileNode node in nodes.Values) + { + if (!node.Active || node.HasActiveChildren) + { + continue; + } + + int index = node.Id.Lod - minLod; + if (index >= 0 && index < counts.Length) + { + counts[index]++; + } + } + + StringBuilder builder = new StringBuilder("Active tiles per LOD: "); + for (int i = 0; i < counts.Length; i++) + { + int lod = minLod + i; + builder.Append($"L{lod}={counts[i]}"); + if (i < counts.Length - 1) + { + builder.Append(" "); + } + } + + Debug.Log(builder.ToString()); + } + + private TileNode GetOrCreateNode(TileId id) + { + if (!nodes.TryGetValue(id, out TileNode node)) + { + node = new TileNode(id, GetTileSizeMeters(id.Lod), GetGridRes(id.Lod)); + nodes.Add(id, node); + } + + return node; + } + + private float GetTileSizeMeters(int lod) + { + if (lod <= 1) + { + return Mathf.Max(0.01f, baseTileSizeMeters); + } + + float size = baseTileSizeMeters / Mathf.Pow(2.0f, lod - 1); + return Mathf.Max(0.01f, size); + } + + private int GetGridRes(int lod) + { + return lod == 0 ? 128 : 256; + } + + private void GetActiveLeafNodes(List results) + { + results.Clear(); + foreach (TileNode node in nodes.Values) + { + if (node.Active && !node.HasActiveChildren) + { + results.Add(node); + } + } + } + + private int CountActiveLeaves() + { + int count = 0; + foreach (TileNode node in nodes.Values) + { + if (node.Active && !node.HasActiveChildren) + { + count++; + } + } + + return count; + } + + private bool Refine(TileNode node) + { + if (node == null || !node.Active) + { + return false; + } + + if (node.Id.Lod >= maxLod) + { + return false; + } + + if (node.Id.Lod == 0) + { + if (enableLod0) + { + Debug.LogWarning("LodTileManager: L0->L1 refinement is not implemented. Start at L1 or disable LOD0."); + } + + return false; + } + + if (node.Children == null || node.Children.Length != 4) + { + node.Children = new TileNode[4]; + } + + for (int childY = 0; childY < 2; childY++) + { + for (int childX = 0; childX < 2; childX++) + { + int index = childY * 2 + childX; + TileId childId = node.Id.Child(childX, childY); + TileNode child = GetOrCreateNode(childId); + child.Parent = node; + child.Active = true; + child.Priority = node.Priority; + node.Children[index] = child; + } + } + + node.Active = false; + return true; + } + + private bool TryCoarsenLowestPriority() + { + CoarsenCandidate best = default; + bool hasCandidate = false; + + foreach (TileNode node in nodes.Values) + { + if (node.Id.Lod < minLod) + { + continue; + } + + if (!node.HasChildren || !AreChildrenActiveLeaves(node)) + { + continue; + } + + float priority = 0.0f; + for (int i = 0; i < node.Children.Length; i++) + { + priority += node.Children[i].Priority; + } + + priority /= node.Children.Length; + + if (!hasCandidate || priority < best.Priority) + { + best = new CoarsenCandidate(node, priority); + hasCandidate = true; + } + } + + if (!hasCandidate) + { + return false; + } + + bool result = Coarsen(best.Parent, best.Priority); + if (result && logBudget) + { + Debug.Log($"Budget coarsen: {best.Parent.Id} priority={best.Priority:F3}."); + } + + return result; + } + + private bool Coarsen(TileNode parent, float priority) + { + if (parent == null || !parent.HasChildren) + { + return false; + } + + for (int i = 0; i < parent.Children.Length; i++) + { + TileNode child = parent.Children[i]; + if (child == null || !child.Active || child.HasActiveChildren) + { + return false; + } + } + + for (int i = 0; i < parent.Children.Length; i++) + { + parent.Children[i].Active = false; + } + + parent.Active = true; + parent.Priority = priority; + return true; + } + + private void NormalizeLodSettings() + { + if (!enableLod0 && minLod < 1) + { + minLod = 1; + } + + if (!enableLod0 && testGridLod < 1) + { + testGridLod = 1; + } + + if (maxLod < minLod) + { + maxLod = minLod; + } + } + + private bool AreChildrenActiveLeaves(TileNode parent) + { + if (!parent.HasChildren) + { + return false; + } + + for (int i = 0; i < parent.Children.Length; i++) + { + TileNode child = parent.Children[i]; + if (child == null || !child.Active || child.HasActiveChildren) + { + return false; + } + } + + return true; + } + + private bool AreAdjacent(TileNode a, TileNode b) + { + GetBounds(a, out float aMinX, out float aMaxX, out float aMinY, out float aMaxY); + GetBounds(b, out float bMinX, out float bMaxX, out float bMinY, out float bMaxY); + + bool xOverlap = Overlaps(aMinX, aMaxX, bMinX, bMaxX); + bool yOverlap = Overlaps(aMinY, aMaxY, bMinY, bMaxY); + + bool northSouth = xOverlap && (Mathf.Abs(aMaxY - bMinY) <= AdjacencyEpsilon || Mathf.Abs(aMinY - bMaxY) <= AdjacencyEpsilon); + bool eastWest = yOverlap && (Mathf.Abs(aMaxX - bMinX) <= AdjacencyEpsilon || Mathf.Abs(aMinX - bMaxX) <= AdjacencyEpsilon); + + return northSouth || eastWest; + } + + private void GetBounds(TileNode node, out float minX, out float maxX, out float minY, out float maxY) + { + float size = GetTileSizeMeters(node.Id.Lod); + minX = node.Id.X * size; + maxX = minX + size; + minY = node.Id.Y * size; + maxY = minY + size; + } + + private bool Overlaps(float aMin, float aMax, float bMin, float bMax) + { + return aMin < bMax - AdjacencyEpsilon && aMax > bMin + AdjacencyEpsilon; + } + + private readonly struct CoarsenCandidate + { + public readonly TileNode Parent; + public readonly float Priority; + + public CoarsenCandidate(TileNode parent, float priority) + { + Parent = parent; + Priority = priority; + } + } + } +} diff --git a/Assets/FloodSWE/Scripts/TileGraph/LodTileManager.cs.meta b/Assets/FloodSWE/Scripts/TileGraph/LodTileManager.cs.meta new file mode 100644 index 000000000..b218d06a3 --- /dev/null +++ b/Assets/FloodSWE/Scripts/TileGraph/LodTileManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 120950576bb06f2a0b821f4c20de967a \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/TileGraph/TileId.cs b/Assets/FloodSWE/Scripts/TileGraph/TileId.cs new file mode 100644 index 000000000..09acf6e9c --- /dev/null +++ b/Assets/FloodSWE/Scripts/TileGraph/TileId.cs @@ -0,0 +1,58 @@ +using System; + +namespace FloodSWE.TileGraph +{ + public readonly struct TileId : IEquatable + { + public readonly int Lod; + public readonly int X; + public readonly int Y; + + public TileId(int lod, int x, int y) + { + Lod = lod; + X = x; + Y = y; + } + + public bool Equals(TileId other) + { + return Lod == other.Lod && X == other.X && Y == other.Y; + } + + public override bool Equals(object obj) + { + return obj is TileId other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Lod, X, Y); + } + + public override string ToString() + { + return $"LOD{Lod} ({X},{Y})"; + } + + public static bool operator ==(TileId a, TileId b) + { + return a.Equals(b); + } + + public static bool operator !=(TileId a, TileId b) + { + return !a.Equals(b); + } + + public TileId Parent() + { + return Lod > 0 ? new TileId(Lod - 1, X >> 1, Y >> 1) : this; + } + + public TileId Child(int childX, int childY) + { + return new TileId(Lod + 1, (X << 1) + childX, (Y << 1) + childY); + } + } +} diff --git a/Assets/FloodSWE/Scripts/TileGraph/TileId.cs.meta b/Assets/FloodSWE/Scripts/TileGraph/TileId.cs.meta new file mode 100644 index 000000000..06011a71c --- /dev/null +++ b/Assets/FloodSWE/Scripts/TileGraph/TileId.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 655e4befd8aed0ce49fe0f1a55709194 \ No newline at end of file diff --git a/Assets/FloodSWE/Scripts/TileGraph/TileNode.cs b/Assets/FloodSWE/Scripts/TileGraph/TileNode.cs new file mode 100644 index 000000000..9abf4a826 --- /dev/null +++ b/Assets/FloodSWE/Scripts/TileGraph/TileNode.cs @@ -0,0 +1,61 @@ +using UnityEngine; + +namespace FloodSWE.TileGraph +{ + public sealed class TileNode + { + public TileId Id; + public float TileSizeMeters; + public int GridRes; + + public Texture TerrainHeight; + public Texture Porosity; + + public RenderTexture WaterA; + public RenderTexture WaterB; + public RenderTexture VelA; + public RenderTexture VelB; + + public bool Active; + public float Priority; + public float MaxDepth; + public float MaxSpeed; + + public TileNode Parent; + public TileNode[] Children; + + public TileNode(TileId id, float tileSizeMeters, int gridRes) + { + Id = id; + TileSizeMeters = tileSizeMeters; + GridRes = gridRes; + } + + public bool HasChildren + { + get { return Children != null && Children.Length == 4; } + } + + public bool HasActiveChildren + { + get + { + if (!HasChildren) + { + return false; + } + + for (int i = 0; i < Children.Length; i++) + { + TileNode child = Children[i]; + if (child != null && child.Active) + { + return true; + } + } + + return false; + } + } + } +} diff --git a/Assets/FloodSWE/Scripts/TileGraph/TileNode.cs.meta b/Assets/FloodSWE/Scripts/TileGraph/TileNode.cs.meta new file mode 100644 index 000000000..e3f991df7 --- /dev/null +++ b/Assets/FloodSWE/Scripts/TileGraph/TileNode.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 575ced0116c14039a9dcea5b5975c043 \ No newline at end of file diff --git a/Assets/FloodSWE/Shaders.meta b/Assets/FloodSWE/Shaders.meta new file mode 100644 index 000000000..e81e0f1bd --- /dev/null +++ b/Assets/FloodSWE/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 117300c7ddc293d23a6961b4689ad03e +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/FloodSWE/Shaders/SWE_HeightmapPreview.shader b/Assets/FloodSWE/Shaders/SWE_HeightmapPreview.shader new file mode 100644 index 000000000..da853da2a --- /dev/null +++ b/Assets/FloodSWE/Shaders/SWE_HeightmapPreview.shader @@ -0,0 +1,59 @@ +Shader "FloodSWE/HeightmapPreview" +{ + Properties + { + _MainTex ("Heightmap", 2D) = "black" {} + _Intensity ("Intensity", Range(0, 5)) = 1 + _MinValue ("Min Value", Float) = 0 + _MaxValue ("Max Value", Float) = 1 + } + SubShader + { + Tags { "RenderPipeline"="UniversalPipeline" "RenderType"="Opaque" } + Pass + { + Name "Unlit" + HLSLPROGRAM + #pragma vertex Vert + #pragma fragment Frag + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + + TEXTURE2D(_MainTex); + SAMPLER(sampler_MainTex); + float _Intensity; + float _MinValue; + float _MaxValue; + + struct Attributes + { + float4 positionOS : POSITION; + float2 uv : TEXCOORD0; + }; + + struct Varyings + { + float4 positionHCS : SV_POSITION; + float2 uv : TEXCOORD0; + }; + + Varyings Vert(Attributes input) + { + Varyings output; + output.positionHCS = TransformObjectToHClip(input.positionOS.xyz); + output.uv = input.uv; + return output; + } + + half4 Frag(Varyings input) : SV_Target + { + float h = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv).r; + float range = max(_MaxValue - _MinValue, 1e-6); + float t = saturate((h - _MinValue) / range); + t *= _Intensity; + return half4(t, t, t, 1.0); + } + ENDHLSL + } + } +} diff --git a/Assets/FloodSWE/Shaders/SWE_HeightmapPreview.shader.meta b/Assets/FloodSWE/Shaders/SWE_HeightmapPreview.shader.meta new file mode 100644 index 000000000..968450f36 --- /dev/null +++ b/Assets/FloodSWE/Shaders/SWE_HeightmapPreview.shader.meta @@ -0,0 +1,9 @@ +fileFormatVersion: 2 +guid: 942b8ce998a337a7aae19258096d6eb0 +ShaderImporter: + externalObjects: {} + defaultTextures: [] + nonModifiableTextures: [] + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scenes/SWE_test.unity b/Assets/Scenes/SWE_test.unity new file mode 100644 index 000000000..c79f80d23 --- /dev/null +++ b/Assets/Scenes/SWE_test.unity @@ -0,0 +1,1009 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!29 &1 +OcclusionCullingSettings: + m_ObjectHideFlags: 0 + serializedVersion: 2 + m_OcclusionBakeSettings: + smallestOccluder: 5 + smallestHole: 0.25 + backfaceThreshold: 100 + m_SceneGUID: 00000000000000000000000000000000 + m_OcclusionCullingData: {fileID: 0} +--- !u!104 &2 +RenderSettings: + m_ObjectHideFlags: 0 + serializedVersion: 10 + m_Fog: 0 + m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} + m_FogMode: 3 + m_FogDensity: 0.01 + m_LinearFogStart: 0 + m_LinearFogEnd: 300 + m_AmbientSkyColor: {r: 0.212, g: 0.227, b: 0.259, a: 1} + m_AmbientEquatorColor: {r: 0.114, g: 0.125, b: 0.133, a: 1} + m_AmbientGroundColor: {r: 0.047, g: 0.043, b: 0.035, a: 1} + m_AmbientIntensity: 1 + m_AmbientMode: 0 + m_SubtractiveShadowColor: {r: 0.42, g: 0.478, b: 0.627, a: 1} + m_SkyboxMaterial: {fileID: 10304, guid: 0000000000000000f000000000000000, type: 0} + m_HaloStrength: 0.5 + m_FlareStrength: 1 + m_FlareFadeSpeed: 3 + m_HaloTexture: {fileID: 0} + m_SpotCookie: {fileID: 10001, guid: 0000000000000000e000000000000000, type: 0} + m_DefaultReflectionMode: 0 + m_DefaultReflectionResolution: 128 + m_ReflectionBounces: 1 + m_ReflectionIntensity: 1 + m_CustomReflection: {fileID: 0} + m_Sun: {fileID: 0} + m_UseRadianceAmbientProbe: 0 +--- !u!157 &3 +LightmapSettings: + m_ObjectHideFlags: 0 + serializedVersion: 13 + m_BakeOnSceneLoad: 0 + m_GISettings: + serializedVersion: 2 + m_BounceScale: 1 + m_IndirectOutputScale: 1 + m_AlbedoBoost: 1 + m_EnvironmentLightingMode: 0 + m_EnableBakedLightmaps: 1 + m_EnableRealtimeLightmaps: 0 + m_LightmapEditorSettings: + serializedVersion: 12 + m_Resolution: 2 + m_BakeResolution: 40 + m_AtlasSize: 1024 + m_AO: 0 + m_AOMaxDistance: 1 + m_CompAOExponent: 1 + m_CompAOExponentDirect: 0 + m_ExtractAmbientOcclusion: 0 + m_Padding: 2 + m_LightmapParameters: {fileID: 0} + m_LightmapsBakeMode: 1 + m_TextureCompression: 1 + m_ReflectionCompression: 2 + m_MixedBakeMode: 2 + m_BakeBackend: 1 + m_PVRSampling: 1 + m_PVRDirectSampleCount: 32 + m_PVRSampleCount: 512 + m_PVRBounces: 2 + m_PVREnvironmentSampleCount: 256 + m_PVREnvironmentReferencePointCount: 2048 + m_PVRFilteringMode: 1 + m_PVRDenoiserTypeDirect: 1 + m_PVRDenoiserTypeIndirect: 1 + m_PVRDenoiserTypeAO: 1 + m_PVRFilterTypeDirect: 0 + m_PVRFilterTypeIndirect: 0 + m_PVRFilterTypeAO: 0 + m_PVREnvironmentMIS: 1 + m_PVRCulling: 1 + m_PVRFilteringGaussRadiusDirect: 1 + m_PVRFilteringGaussRadiusIndirect: 5 + m_PVRFilteringGaussRadiusAO: 2 + m_PVRFilteringAtrousPositionSigmaDirect: 0.5 + m_PVRFilteringAtrousPositionSigmaIndirect: 2 + m_PVRFilteringAtrousPositionSigmaAO: 1 + m_ExportTrainingData: 0 + m_TrainingDataDestination: TrainingData + m_LightProbeSampleCountMultiplier: 4 + m_LightingDataAsset: {fileID: 20201, guid: 0000000000000000f000000000000000, type: 0} + m_LightingSettings: {fileID: 0} +--- !u!196 &4 +NavMeshSettings: + serializedVersion: 2 + m_ObjectHideFlags: 0 + m_BuildSettings: + serializedVersion: 3 + agentTypeID: 0 + agentRadius: 0.5 + agentHeight: 2 + agentSlope: 45 + agentClimb: 0.4 + ledgeDropHeight: 0 + maxJumpAcrossDistance: 0 + minRegionArea: 2 + manualCellSize: 0 + cellSize: 0.16666667 + manualTileSize: 0 + tileSize: 256 + buildHeightMesh: 0 + maxJobWorkers: 0 + preserveTilesOutsideBounds: 0 + debug: + m_Flags: 0 + m_NavMeshData: {fileID: 0} +--- !u!1 &62740688 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 62740691} + - component: {fileID: 62740690} + - component: {fileID: 62740689} + m_Layer: 0 + m_Name: EventSystem + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &62740689 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 62740688} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 01614664b831546d2ae94a42149d80ac, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.InputSystem::UnityEngine.InputSystem.UI.InputSystemUIInputModule + m_SendPointerHoverToParent: 1 + m_MoveRepeatDelay: 0.5 + m_MoveRepeatRate: 0.1 + m_XRTrackingOrigin: {fileID: 0} + m_ActionsAsset: {fileID: -944628639613478452, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_PointAction: {fileID: -1654692200621890270, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_MoveAction: {fileID: -8784545083839296357, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_SubmitAction: {fileID: 392368643174621059, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_CancelAction: {fileID: 7727032971491509709, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_LeftClickAction: {fileID: 3001919216989983466, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_MiddleClickAction: {fileID: -2185481485913320682, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_RightClickAction: {fileID: -4090225696740746782, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_ScrollWheelAction: {fileID: 6240969308177333660, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_TrackedDevicePositionAction: {fileID: 6564999863303420839, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_TrackedDeviceOrientationAction: {fileID: 7970375526676320489, guid: ca9f5fa95ffab41fb9a615ab714db018, type: 3} + m_DeselectOnBackgroundClick: 1 + m_PointerBehavior: 0 + m_CursorLockBehavior: 0 + m_ScrollDeltaPerTick: 6 +--- !u!114 &62740690 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 62740688} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 76c392e42b5098c458856cdf6ecaaaa1, type: 3} + m_Name: + m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.EventSystems.EventSystem + m_FirstSelected: {fileID: 0} + m_sendNavigationEvents: 1 + m_DragThreshold: 10 +--- !u!4 &62740691 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 62740688} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &65783697 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 65783701} + - component: {fileID: 65783700} + - component: {fileID: 65783699} + - component: {fileID: 65783698} + m_Layer: 0 + m_Name: SweHeightmapPreview + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!23 &65783698 +MeshRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 65783697} + m_Enabled: 1 + m_CastShadows: 1 + m_ReceiveShadows: 1 + m_DynamicOccludee: 1 + m_StaticShadowCaster: 0 + m_MotionVectors: 1 + m_LightProbeUsage: 1 + m_ReflectionProbeUsage: 1 + m_RayTracingMode: 2 + m_RayTraceProcedural: 0 + m_RayTracingAccelStructBuildFlagsOverride: 0 + m_RayTracingAccelStructBuildFlags: 1 + m_SmallMeshCulling: 1 + m_ForceMeshLod: -1 + m_MeshLodSelectionBias: 0 + m_RenderingLayerMask: 1 + m_RendererPriority: 0 + m_Materials: + - {fileID: 704478796} + m_StaticBatchInfo: + firstSubMesh: 0 + subMeshCount: 0 + m_StaticBatchRoot: {fileID: 0} + m_ProbeAnchor: {fileID: 0} + m_LightProbeVolumeOverride: {fileID: 0} + m_ScaleInLightmap: 1 + m_ReceiveGI: 1 + m_PreserveUVs: 1 + m_IgnoreNormalsForChartDetection: 0 + m_ImportantGI: 0 + m_StitchLightmapSeams: 1 + m_SelectedEditorRenderState: 3 + m_MinimumChartSize: 4 + m_AutoUVMaxDistance: 0.5 + m_AutoUVMaxAngle: 89 + m_LightmapParameters: {fileID: 0} + m_GlobalIlluminationMeshLod: 0 + m_SortingLayerID: 0 + m_SortingLayer: 0 + m_SortingOrder: 0 + m_MaskInteraction: 0 + m_AdditionalVertexStreams: {fileID: 0} +--- !u!64 &65783699 +MeshCollider: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 65783697} + m_Material: {fileID: 0} + m_IncludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_ExcludeLayers: + serializedVersion: 2 + m_Bits: 0 + m_LayerOverridePriority: 0 + m_IsTrigger: 0 + m_ProvidesContacts: 0 + m_Enabled: 1 + serializedVersion: 5 + m_Convex: 0 + m_CookingOptions: 30 + m_Mesh: {fileID: 10210, guid: 0000000000000000e000000000000000, type: 0} +--- !u!33 &65783700 +MeshFilter: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 65783697} + m_Mesh: {fileID: 10210, guid: 0000000000000000e000000000000000, type: 0} +--- !u!4 &65783701 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 65783697} + serializedVersion: 2 + m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 10, y: 10, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 300279283} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &82956090 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 82956095} + - component: {fileID: 82956094} + - component: {fileID: 82956093} + - component: {fileID: 82956092} + - component: {fileID: 82956091} + m_Layer: 5 + m_Name: SweStatsOverlay + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &82956091 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 82956090} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: d14330dd605fa5af6b8fa1d7219fdb30, type: 3} + m_Name: + m_EditorClassIdentifier: FloodSWE.Runtime::FloodSWE.Debugging.SweStatsOverlay + simulator: {fileID: 670458859} + targetText: {fileID: 1070468483} + updateInterval: 0.25 +--- !u!114 &82956092 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 82956090} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: dc42784cf147c0c48a680349fa168899, type: 3} + m_Name: + m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.GraphicRaycaster + m_IgnoreReversedGraphics: 1 + m_BlockingObjects: 0 + m_BlockingMask: + serializedVersion: 2 + m_Bits: 4294967295 +--- !u!114 &82956093 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 82956090} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 0cd44c1031e13a943bb63640046fad76, type: 3} + m_Name: + m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.CanvasScaler + m_UiScaleMode: 0 + m_ReferencePixelsPerUnit: 100 + m_ScaleFactor: 1 + m_ReferenceResolution: {x: 800, y: 600} + m_ScreenMatchMode: 0 + m_MatchWidthOrHeight: 0 + m_PhysicalUnit: 3 + m_FallbackScreenDPI: 96 + m_DefaultSpriteDPI: 96 + m_DynamicPixelsPerUnit: 1 + m_PresetInfoIsWorld: 0 +--- !u!223 &82956094 +Canvas: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 82956090} + m_Enabled: 1 + serializedVersion: 3 + m_RenderMode: 1 + m_Camera: {fileID: 961739752} + m_PlaneDistance: 100 + m_PixelPerfect: 0 + m_ReceivesEvents: 1 + m_OverrideSorting: 0 + m_OverridePixelPerfect: 0 + m_SortingBucketNormalizedSize: 0 + m_VertexColorAlwaysGammaSpace: 1 + m_AdditionalShaderChannelsFlag: 25 + m_UpdateRectTransformForStandalone: 0 + m_SortingLayerID: 0 + m_SortingOrder: 0 + m_TargetDisplay: 0 +--- !u!224 &82956095 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 82956090} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 0, y: 0, z: 0} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 1070468482} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 0} + m_AnchorMax: {x: 0, y: 0} + m_AnchoredPosition: {x: 0, y: 0} + m_SizeDelta: {x: 0, y: 0} + m_Pivot: {x: 0, y: 0} +--- !u!1 &203844586 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 203844589} + - component: {fileID: 203844588} + - component: {fileID: 203844587} + m_Layer: 0 + m_Name: Directional Light + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &203844587 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 203844586} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 474bcb49853aa07438625e644c072ee6, type: 3} + m_Name: + m_EditorClassIdentifier: + m_UsePipelineSettings: 1 + m_AdditionalLightsShadowResolutionTier: 2 + m_CustomShadowLayers: 0 + m_LightCookieSize: {x: 1, y: 1} + m_LightCookieOffset: {x: 0, y: 0} + m_SoftShadowQuality: 0 + m_RenderingLayersMask: + serializedVersion: 0 + m_Bits: 1 + m_ShadowRenderingLayersMask: + serializedVersion: 0 + m_Bits: 1 + m_Version: 4 + m_LightLayerMask: 1 + m_ShadowLayerMask: 1 + m_RenderingLayers: 1 + m_ShadowRenderingLayers: 1 +--- !u!108 &203844588 +Light: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 203844586} + m_Enabled: 1 + serializedVersion: 12 + m_Type: 1 + m_Color: {r: 1, g: 0.95686275, b: 0.8392157, a: 1} + m_Intensity: 1 + m_Range: 10 + m_SpotAngle: 30 + m_InnerSpotAngle: 21.80208 + m_CookieSize2D: {x: 10, y: 10} + m_Shadows: + m_Type: 2 + m_Resolution: -1 + m_CustomResolution: -1 + m_Strength: 1 + m_Bias: 0.05 + m_NormalBias: 0.4 + m_NearPlane: 0.2 + m_CullingMatrixOverride: + e00: 1 + e01: 0 + e02: 0 + e03: 0 + e10: 0 + e11: 1 + e12: 0 + e13: 0 + e20: 0 + e21: 0 + e22: 1 + e23: 0 + e30: 0 + e31: 0 + e32: 0 + e33: 1 + m_UseCullingMatrixOverride: 0 + m_Cookie: {fileID: 0} + m_DrawHalo: 0 + m_Flare: {fileID: 0} + m_RenderMode: 0 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingLayerMask: 1 + m_Lightmapping: 4 + m_LightShadowCasterMode: 0 + m_AreaSize: {x: 1, y: 1} + m_BounceIntensity: 1 + m_ColorTemperature: 6570 + m_UseColorTemperature: 0 + m_BoundingSphereOverride: {x: 0, y: 0, z: 0, w: 0} + m_UseBoundingSphereOverride: 0 + m_UseViewFrustumForShadowCasterCull: 1 + m_ForceVisible: 0 + m_ShadowRadius: 0 + m_ShadowAngle: 0 + m_LightUnit: 1 + m_LuxAtDistance: 1 + m_EnableSpotReflector: 1 +--- !u!4 &203844589 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 203844586} + serializedVersion: 2 + m_LocalRotation: {x: 0.40821788, y: -0.23456968, z: 0.10938163, w: 0.8754261} + m_LocalPosition: {x: 0, y: 3, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 50, y: -30, z: 0} +--- !u!1 &300279281 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 300279283} + - component: {fileID: 300279282} + m_Layer: 0 + m_Name: SweDebugView + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &300279282 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 300279281} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f27ca2e503cf98e1fae8e3292059b147, type: 3} + m_Name: + m_EditorClassIdentifier: FloodSWE.Runtime::FloodSWE.Debugging.SweHeightmapPreview + simulator: {fileID: 670458859} + overrideTexture: {fileID: 0} + targetRenderer: {fileID: 65783698} + createQuadIfMissing: 1 + quadSize: {x: 10, y: 10} + intensity: 1 + minValue: 0 + maxValue: 4 + previewShader: {fileID: 0} +--- !u!4 &300279283 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 300279281} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: + - {fileID: 65783701} + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!1 &670458858 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 670458860} + - component: {fileID: 670458859} + m_Layer: 0 + m_Name: SweSimulator + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &670458859 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 670458858} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: 5539ed5458731ed3db3b49073dcaa1d7, type: 3} + m_Name: + m_EditorClassIdentifier: FloodSWE.Runtime::SweTileSimulator + ghostExchangeShader: {fileID: 7200000, guid: 996054f3be2086cabb5672a773d4a76d, type: 3} + fluxShader: {fileID: 7200000, guid: 5128e0ee725879f26bb5044de1621c5a, type: 3} + gridRes: 64 + tileSizeMeters: 20 + damBreak: 1 + initialDepthLeft: 3 + initialDepthRight: 0 + initialVelocity: {x: 0, y: 5} + rainRateMmPerHr: 0 + cfl: 0.2 + tickSeconds: 0.05 + logCfl: 1 + useFullPrecisionTextures: 1 + terrainHeight: {fileID: 0} + porosity: {fileID: 0} + flatTerrainHeightMeters: 0 + defaultPorosity: 1 + resampleTerrainToGrid: 1 + resamplePorosityToGrid: 1 + emitHeightmapPackets: 0 + packetEveryNTicks: 1 + lastHeightmapPacket: + FrameId: 0 + Resolution: 0 + MinHeight: 0 + MaxHeight: 0 + Payload: + debugWater: {fileID: 0} + debugVelocity: {fileID: 0} + logConservation: 1 + debugClampStats: 1 + applyMassCorrection: 0 + massCorrectionMinScale: 0.8 + massCorrectionMaxScale: 1.2 +--- !u!4 &670458860 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 670458858} + serializedVersion: 2 + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 1268.3092, y: 0.00003, z: 511.31314} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} +--- !u!21 &704478796 +Material: + serializedVersion: 8 + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_Name: FloodSWE/HeightmapPreview + m_Shader: {fileID: 4800000, guid: 942b8ce998a337a7aae19258096d6eb0, type: 3} + m_Parent: {fileID: 0} + m_ModifiedSerializedProperties: 0 + m_ValidKeywords: [] + m_InvalidKeywords: [] + m_LightmapFlags: 4 + m_EnableInstancingVariants: 0 + m_DoubleSidedGI: 0 + m_CustomRenderQueue: -1 + stringTagMap: {} + disabledShaderPasses: [] + m_LockedProperties: + m_SavedProperties: + serializedVersion: 3 + m_TexEnvs: + - _MainTex: + m_Texture: {fileID: 0} + m_Scale: {x: 1, y: 1} + m_Offset: {x: 0, y: 0} + m_Ints: [] + m_Floats: + - _Intensity: 1 + - _MaxValue: 1 + - _MinValue: 0 + m_Colors: [] + m_BuildTextureStacks: [] + m_AllowLocking: 1 +--- !u!1 &961739749 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 961739753} + - component: {fileID: 961739752} + - component: {fileID: 961739751} + - component: {fileID: 961739750} + m_Layer: 0 + m_Name: Main Camera + m_TagString: MainCamera + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!114 &961739750 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 961739749} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: a79441f348de89743a2939f4d699eac1, type: 3} + m_Name: + m_EditorClassIdentifier: + m_RenderShadows: 1 + m_RequiresDepthTextureOption: 2 + m_RequiresOpaqueTextureOption: 2 + m_CameraType: 0 + m_Cameras: [] + m_RendererIndex: -1 + m_VolumeLayerMask: + serializedVersion: 2 + m_Bits: 1 + m_VolumeTrigger: {fileID: 0} + m_VolumeFrameworkUpdateModeOption: 2 + m_RenderPostProcessing: 0 + m_Antialiasing: 0 + m_AntialiasingQuality: 2 + m_StopNaN: 0 + m_Dithering: 0 + m_ClearDepth: 1 + m_AllowXRRendering: 1 + m_AllowHDROutput: 1 + m_UseScreenCoordOverride: 0 + m_ScreenSizeOverride: {x: 0, y: 0, z: 0, w: 0} + m_ScreenCoordScaleBias: {x: 0, y: 0, z: 0, w: 0} + m_RequiresDepthTexture: 0 + m_RequiresColorTexture: 0 + m_TaaSettings: + m_Quality: 3 + m_FrameInfluence: 0.1 + m_JitterScale: 1 + m_MipBias: 0 + m_VarianceClampScale: 0.9 + m_ContrastAdaptiveSharpening: 0 + m_Version: 2 +--- !u!81 &961739751 +AudioListener: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 961739749} + m_Enabled: 1 +--- !u!20 &961739752 +Camera: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 961739749} + m_Enabled: 1 + serializedVersion: 2 + m_ClearFlags: 1 + m_BackGroundColor: {r: 0.19215687, g: 0.3019608, b: 0.4745098, a: 0} + m_projectionMatrixMode: 1 + m_GateFitMode: 2 + m_FOVAxisMode: 0 + m_Iso: 200 + m_ShutterSpeed: 0.005 + m_Aperture: 16 + m_FocusDistance: 10 + m_FocalLength: 50 + m_BladeCount: 5 + m_Curvature: {x: 2, y: 11} + m_BarrelClipping: 0.25 + m_Anamorphism: 0 + m_SensorSize: {x: 36, y: 24} + m_LensShift: {x: 0, y: 0} + m_NormalizedViewPortRect: + serializedVersion: 2 + x: 0 + y: 0 + width: 1 + height: 1 + near clip plane: 0.3 + far clip plane: 1000 + field of view: 60 + orthographic: 0 + orthographic size: 5 + m_Depth: -1 + m_CullingMask: + serializedVersion: 2 + m_Bits: 4294967295 + m_RenderingPath: -1 + m_TargetTexture: {fileID: 0} + m_TargetDisplay: 0 + m_TargetEye: 3 + m_HDR: 1 + m_AllowMSAA: 1 + m_AllowDynamicResolution: 0 + m_ForceIntoRT: 0 + m_OcclusionCulling: 1 + m_StereoConvergence: 10 + m_StereoSeparation: 0.022 +--- !u!4 &961739753 +Transform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 961739749} + serializedVersion: 2 + m_LocalRotation: {x: 0.7071068, y: 0, z: 0, w: 0.7071068} + m_LocalPosition: {x: 0, y: 10, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 0} + m_LocalEulerAnglesHint: {x: 90, y: 0, z: 0} +--- !u!1 &1070468481 +GameObject: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + serializedVersion: 6 + m_Component: + - component: {fileID: 1070468482} + - component: {fileID: 1070468484} + - component: {fileID: 1070468483} + m_Layer: 5 + m_Name: StatsTitle + m_TagString: Untagged + m_Icon: {fileID: 0} + m_NavMeshLayer: 0 + m_StaticEditorFlags: 0 + m_IsActive: 1 +--- !u!224 &1070468482 +RectTransform: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1070468481} + m_LocalRotation: {x: 0, y: 0, z: 0, w: 1} + m_LocalPosition: {x: 0, y: 0, z: 0} + m_LocalScale: {x: 1, y: 1, z: 1} + m_ConstrainProportionsScale: 0 + m_Children: [] + m_Father: {fileID: 82956095} + m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0} + m_AnchorMin: {x: 0, y: 1} + m_AnchorMax: {x: 0, y: 1} + m_AnchoredPosition: {x: 20, y: -20} + m_SizeDelta: {x: 250, y: 80} + m_Pivot: {x: 0, y: 1} +--- !u!114 &1070468483 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1070468481} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3} + m_Name: + m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI + m_Material: {fileID: 0} + m_Color: {r: 1, g: 1, b: 1, a: 1} + m_RaycastTarget: 1 + m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0} + m_Maskable: 1 + m_OnCullStateChanged: + m_PersistentCalls: + m_Calls: [] + m_text: Stats + m_isRightToLeft: 0 + m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2} + m_fontSharedMaterials: [] + m_fontMaterial: {fileID: 0} + m_fontMaterials: [] + m_fontColor32: + serializedVersion: 2 + rgba: 4294967295 + m_fontColor: {r: 1, g: 1, b: 1, a: 1} + m_enableVertexGradient: 0 + m_colorMode: 3 + m_fontColorGradient: + topLeft: {r: 1, g: 1, b: 1, a: 1} + topRight: {r: 1, g: 1, b: 1, a: 1} + bottomLeft: {r: 1, g: 1, b: 1, a: 1} + bottomRight: {r: 1, g: 1, b: 1, a: 1} + m_fontColorGradientPreset: {fileID: 0} + m_spriteAsset: {fileID: 0} + m_tintAllSprites: 0 + m_StyleSheet: {fileID: 0} + m_TextStyleHashCode: -1183493901 + m_overrideHtmlColors: 0 + m_faceColor: + serializedVersion: 2 + rgba: 4294967295 + m_fontSize: 30 + m_fontSizeBase: 30 + m_fontWeight: 400 + m_enableAutoSizing: 0 + m_fontSizeMin: 18 + m_fontSizeMax: 72 + m_fontStyle: 0 + m_HorizontalAlignment: 1 + m_VerticalAlignment: 256 + m_textAlignment: 65535 + m_characterSpacing: 0 + m_characterHorizontalScale: 1 + m_wordSpacing: 0 + m_lineSpacing: 0 + m_lineSpacingMax: 0 + m_paragraphSpacing: 0 + m_charWidthMaxAdj: 0 + m_TextWrappingMode: 1 + m_wordWrappingRatios: 0.4 + m_overflowMode: 0 + m_linkedTextComponent: {fileID: 0} + parentLinkedComponent: {fileID: 0} + m_enableKerning: 0 + m_ActiveFontFeatures: 6e72656b + m_enableExtraPadding: 0 + checkPaddingRequired: 0 + m_isRichText: 1 + m_EmojiFallbackSupport: 1 + m_parseCtrlCharacters: 1 + m_isOrthographic: 1 + m_isCullingEnabled: 0 + m_horizontalMapping: 0 + m_verticalMapping: 0 + m_uvLineOffset: 0 + m_geometrySortingOrder: 0 + m_IsTextObjectScaleStatic: 0 + m_VertexBufferAutoSizeReduction: 0 + m_useMaxVisibleDescender: 1 + m_pageToDisplay: 1 + m_margin: {x: 0, y: 0, z: 0, w: 0} + m_isUsingLegacyAnimationComponent: 0 + m_isVolumetricText: 0 + m_hasFontAssetChanged: 0 + m_baseMaterial: {fileID: 0} + m_maskOffset: {x: 0, y: 0, z: 0, w: 0} +--- !u!222 &1070468484 +CanvasRenderer: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 1070468481} + m_CullTransparentMesh: 1 +--- !u!1660057539 &9223372036854775807 +SceneRoots: + m_ObjectHideFlags: 0 + m_Roots: + - {fileID: 961739753} + - {fileID: 203844589} + - {fileID: 670458860} + - {fileID: 300279283} + - {fileID: 82956095} + - {fileID: 62740691} diff --git a/Assets/Scenes/SWE_test.unity.meta b/Assets/Scenes/SWE_test.unity.meta new file mode 100644 index 000000000..5008c74ae --- /dev/null +++ b/Assets/Scenes/SWE_test.unity.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: b877d4144ba0f02d5be60365a4bcc89b +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scenes/TestArea.unity b/Assets/Scenes/TestArea.unity index 9e33ec0e7..341eb3b02 100644 Binary files a/Assets/Scenes/TestArea.unity and b/Assets/Scenes/TestArea.unity differ diff --git a/Assets/Scripts/Editor/GeoTileAddressablesBuilder.cs b/Assets/Scripts/Editor/GeoTileAddressablesBuilder.cs index 4ad42a95a..e9adade73 100644 --- a/Assets/Scripts/Editor/GeoTileAddressablesBuilder.cs +++ b/Assets/Scripts/Editor/GeoTileAddressablesBuilder.cs @@ -12,10 +12,15 @@ using UnityEngine; public static class GeoTileAddressablesBuilder { private const string TilePrefabsDir = "Assets/TilePrefabs"; + private const string BuildingPrefabsDir = "Assets/TilePrefabs_Buildings"; private const string TileIndexCsvPath = "Assets/GeoData/tile_index.csv"; private const string GroupName = "TilePrefabs"; + private const string BuildingGroupName = "TileBuildings"; private const string TileLabel = "tile"; + private const string BuildingLabel = "tile_building"; private const string ManifestFileName = "TileManifest.json"; + private const string BuildingManifestFileName = "TileBuildingsManifest.json"; + private const string BuildingAddressSuffix = "_bldg"; private const float DefaultTileSizeMeters = 1000f; private const float DefaultTileSizeX = 1000f; private const float DefaultTileSizeY = 1000f; @@ -61,6 +66,7 @@ public static class GeoTileAddressablesBuilder { public string TileIndexCsvPath; public string TilePrefabsDir; + public string BuildingPrefabsDir; public string OutputRoot; public BuildTarget Target; public BuildTargetGroup TargetGroup; @@ -69,6 +75,8 @@ public static class GeoTileAddressablesBuilder public List SelectedTiles; public bool OverwriteExisting; public bool Verbose; + public bool IncludeBuildingPrefabs; + public int BuildingBlockSizeInTiles; } [MenuItem("Tools/Geo Tiles/Build (Android)")] @@ -91,6 +99,7 @@ public static class GeoTileAddressablesBuilder { TileIndexCsvPath = TileIndexCsvPath, TilePrefabsDir = TilePrefabsDir, + BuildingPrefabsDir = BuildingPrefabsDir, OutputRoot = "ServerData/TileBundles", Target = target, TargetGroup = group, @@ -98,7 +107,9 @@ public static class GeoTileAddressablesBuilder TileKeyConfig = TileKeyConfig.Default, SelectedTiles = null, OverwriteExisting = false, - Verbose = false + Verbose = false, + IncludeBuildingPrefabs = true, + BuildingBlockSizeInTiles = 2 }); } @@ -106,8 +117,11 @@ public static class GeoTileAddressablesBuilder { var tileIndexCsvPath = string.IsNullOrWhiteSpace(request.TileIndexCsvPath) ? TileIndexCsvPath : request.TileIndexCsvPath; var tilePrefabsDir = string.IsNullOrWhiteSpace(request.TilePrefabsDir) ? TilePrefabsDir : request.TilePrefabsDir; + var buildingPrefabsDir = string.IsNullOrWhiteSpace(request.BuildingPrefabsDir) ? BuildingPrefabsDir : request.BuildingPrefabsDir; var outputRoot = string.IsNullOrWhiteSpace(request.OutputRoot) ? "ServerData/TileBundles" : request.OutputRoot; var tileSizeMeters = request.TileSizeMeters > 0f ? request.TileSizeMeters : DefaultTileSizeMeters; + var includeBuildingPrefabs = request.IncludeBuildingPrefabs; + var buildingBlockSize = Math.Max(1, request.BuildingBlockSizeInTiles); var tileKeyConfig = request.TileKeyConfig; if (tileKeyConfig.TileSizeX <= 0f && tileKeyConfig.TileSizeY <= 0f && tileKeyConfig.OverlapX == 0f && tileKeyConfig.OverlapY == 0f) @@ -120,6 +134,11 @@ public static class GeoTileAddressablesBuilder Debug.LogError($"[GeoTileAddressablesBuilder] Prefab directory missing: {tilePrefabsDir}"); return false; } + if (includeBuildingPrefabs && !Directory.Exists(buildingPrefabsDir)) + { + Debug.LogWarning($"[GeoTileAddressablesBuilder] Building prefab directory missing: {buildingPrefabsDir}. Skipping building bundles."); + includeBuildingPrefabs = false; + } if (!File.Exists(tileIndexCsvPath)) { Debug.LogError($"[GeoTileAddressablesBuilder] CSV missing: {tileIndexCsvPath}"); @@ -145,7 +164,7 @@ public static class GeoTileAddressablesBuilder EnsureProfileVariable(settings, LoadPathVariable, LoadPathValue); EnsureRemoteCatalogPaths(settings); - var groupAsset = GetOrCreateGroup(settings); + var groupAsset = GetOrCreateGroup(settings, GroupName); ConfigureGroup(settings, groupAsset); var assignedTileIds = AssignPrefabs(settings, groupAsset, tilePrefabsDir, tiles, true); @@ -158,6 +177,18 @@ public static class GeoTileAddressablesBuilder return false; } + List buildingTiles = null; + if (includeBuildingPrefabs) + { + var buildingGroup = GetOrCreateGroup(settings, BuildingGroupName); + ConfigureGroup(settings, buildingGroup); + var anchors = tiles.Where(tile => IsBuildingAnchor(tile, buildingBlockSize)).ToList(); + var assignedBuildingIds = AssignBuildingPrefabs(settings, buildingGroup, buildingPrefabsDir, anchors, true); + buildingTiles = anchors + .Where(tile => !string.IsNullOrWhiteSpace(tile.TileId) && assignedBuildingIds.Contains(tile.TileId)) + .ToList(); + } + settings.SetDirty(AddressableAssetSettings.ModificationEvent.BatchModification, null, true); AssetDatabase.SaveAssets(); @@ -191,14 +222,43 @@ public static class GeoTileAddressablesBuilder return false; } - var manifest = BuildManifest(request.Target.ToString(), catalogFile, catalogHashFile, filteredTiles, tileSizeMeters); + var (originX, originY) = ComputeOrigin(filteredTiles); + var manifest = BuildManifest( + request.Target.ToString(), + catalogFile, + catalogHashFile, + filteredTiles, + tileSizeMeters, + ResolveTileKey, + originX, + originY); var manifestPath = Path.Combine(outputPath, ManifestFileName); File.WriteAllText(manifestPath, JsonUtility.ToJson(manifest, true)); + if (includeBuildingPrefabs && buildingTiles != null && buildingTiles.Count > 0) + { + var buildingManifest = BuildManifest( + request.Target.ToString(), + catalogFile, + catalogHashFile, + buildingTiles, + tileSizeMeters, + BuildBuildingAddress, + originX, + originY); + var buildingManifestPath = Path.Combine(outputPath, BuildingManifestFileName); + File.WriteAllText(buildingManifestPath, JsonUtility.ToJson(buildingManifest, true)); + } + AssetDatabase.Refresh(); if (request.Verbose) - Debug.Log($"[GeoTileAddressablesBuilder] Built {filteredTiles.Count} tiles (from {tiles.Count} selected). Output={outputPath}"); + { + var buildingInfo = includeBuildingPrefabs && buildingTiles != null + ? $", Buildings={buildingTiles.Count}" + : ""; + Debug.Log($"[GeoTileAddressablesBuilder] Built {filteredTiles.Count} tiles (from {tiles.Count} selected){buildingInfo}. Output={outputPath}"); + } return true; } @@ -227,7 +287,7 @@ public static class GeoTileAddressablesBuilder continue; var entry = settings.CreateOrMoveEntry(guid, group, false, false); - entry.address = tile.TileKey; + entry.address = ResolveTileKey(tile); entry.SetLabel(TileLabel, true, true); selectedGuids.Add(guid); assignedTileIds.Add(tileId); @@ -254,13 +314,64 @@ public static class GeoTileAddressablesBuilder return assignedTileIds; } - private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings) + private static HashSet AssignBuildingPrefabs(AddressableAssetSettings settings, AddressableAssetGroup group, string prefabsDir, List tiles, bool removeUnselected) { - var group = settings.FindGroup(GroupName); + var tileById = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var tile in tiles) + { + if (!string.IsNullOrWhiteSpace(tile.TileId)) + tileById[tile.TileId] = tile; + } + + var assignedTileIds = new HashSet(StringComparer.OrdinalIgnoreCase); + var selectedGuids = new HashSet(); + var prefabGuids = AssetDatabase.FindAssets("t:Prefab", new[] { prefabsDir }); + foreach (var guid in prefabGuids) + { + var path = AssetDatabase.GUIDToAssetPath(guid).Replace("\\", "/"); + var parentDir = Path.GetDirectoryName(path)?.Replace("\\", "/"); + if (!string.Equals(parentDir, prefabsDir, StringComparison.OrdinalIgnoreCase)) + continue; + + var tileId = Path.GetFileNameWithoutExtension(path); + if (!tileById.TryGetValue(tileId, out var tile)) + continue; + + var entry = settings.CreateOrMoveEntry(guid, group, false, false); + entry.address = BuildBuildingAddress(tile); + entry.SetLabel(BuildingLabel, true, true); + selectedGuids.Add(guid); + assignedTileIds.Add(tileId); + } + + if (!removeUnselected) + return assignedTileIds; + + var entries = group.entries.ToList(); + foreach (var entry in entries) + { + if (entry == null || string.IsNullOrWhiteSpace(entry.AssetPath)) + continue; + + var entryPath = entry.AssetPath.Replace("\\", "/"); + var entryDir = Path.GetDirectoryName(entryPath)?.Replace("\\", "/"); + if (!string.Equals(entryDir, prefabsDir, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!selectedGuids.Contains(entry.guid)) + group.RemoveAssetEntry(entry); + } + + return assignedTileIds; + } + + private static AddressableAssetGroup GetOrCreateGroup(AddressableAssetSettings settings, string groupName) + { + var group = settings.FindGroup(groupName); if (group != null) return group; - group = settings.CreateGroup(GroupName, false, false, false, new List()); + group = settings.CreateGroup(groupName, false, false, false, new List()); group.AddSchema(); group.AddSchema(); return group; @@ -327,17 +438,37 @@ public static class GeoTileAddressablesBuilder } private static TileManifest BuildManifest(string buildTarget, string catalogFile, string catalogHashFile, List tiles, float tileSizeMeters) + => BuildManifest(buildTarget, catalogFile, catalogHashFile, tiles, tileSizeMeters, ResolveTileKey, null, null); + + private static TileManifest BuildManifest( + string buildTarget, + string catalogFile, + string catalogHashFile, + List tiles, + float tileSizeMeters, + Func keyResolver, + double? originX, + double? originY) { if (tiles.Count == 0) throw new InvalidOperationException("No tiles selected for TileManifest."); - double minX = double.PositiveInfinity; - double minY = double.PositiveInfinity; - - foreach (var tile in tiles) + double minX; + double minY; + if (originX.HasValue && originY.HasValue) { - minX = Math.Min(minX, tile.Xmin); - minY = Math.Min(minY, tile.Ymin); + minX = originX.Value; + minY = originY.Value; + } + else + { + minX = double.PositiveInfinity; + minY = double.PositiveInfinity; + foreach (var tile in tiles) + { + minX = Math.Min(minX, tile.Xmin); + minY = Math.Min(minY, tile.Ymin); + } } var entries = new TileEntry[tiles.Count]; @@ -346,7 +477,7 @@ public static class GeoTileAddressablesBuilder var tile = tiles[i]; entries[i] = new TileEntry { - tileKey = tile.TileKey, + tileKey = keyResolver(tile), tileId = tile.TileId, offsetX = (float)(tile.Xmin - minX), offsetZ = (float)(tile.Ymin - minY), @@ -366,6 +497,28 @@ public static class GeoTileAddressablesBuilder }; } + private static (double originX, double originY) ComputeOrigin(List tiles) + { + double minX = double.PositiveInfinity; + double minY = double.PositiveInfinity; + foreach (var tile in tiles) + { + minX = Math.Min(minX, tile.Xmin); + minY = Math.Min(minY, tile.Ymin); + } + + return (minX, minY); + } + + private static string ResolveTileKey(TileRecord tile) + => string.IsNullOrWhiteSpace(tile.TileKey) ? tile.TileId : tile.TileKey; + + private static string BuildBuildingAddress(TileRecord tile) + => $"{ResolveTileKey(tile)}{BuildingAddressSuffix}"; + + private static bool IsBuildingAnchor(TileRecord tile, int blockSize) + => (tile.XKey % blockSize == 0) && (tile.YKey % blockSize == 0); + private static string GetRemoteCatalogBuildPath(AddressableAssetSettings settings, BuildTarget target) { var rawPath = settings.RemoteCatalogBuildPath.GetValue(settings); diff --git a/Assets/Scripts/Editor/GeoTileAddressablesWindow.cs b/Assets/Scripts/Editor/GeoTileAddressablesWindow.cs index 93536b8a7..dbb7298d8 100644 --- a/Assets/Scripts/Editor/GeoTileAddressablesWindow.cs +++ b/Assets/Scripts/Editor/GeoTileAddressablesWindow.cs @@ -8,6 +8,7 @@ public class GeoTileAddressablesWindow : EditorWindow { private string tileIndexCsvPath = "Assets/GeoData/tile_index.csv"; private string tilePrefabsDir = "Assets/TilePrefabs"; + private string buildingPrefabsDir = "Assets/TilePrefabs_Buildings"; private string outputRoot = "ServerData/TileBundles"; private BuildTarget buildTarget = BuildTarget.StandaloneLinux64; @@ -21,6 +22,8 @@ public class GeoTileAddressablesWindow : EditorWindow private bool overwriteExisting = true; private bool verboseLogging = false; + private bool includeBuildingPrefabs = true; + private int buildingBlockSize = 2; private Vector2 scrollPosition; @@ -68,6 +71,7 @@ public class GeoTileAddressablesWindow : EditorWindow GUILayout.Label("Paths", EditorStyles.boldLabel); tileIndexCsvPath = EditorGUILayout.TextField("Tile Index CSV", tileIndexCsvPath); tilePrefabsDir = EditorGUILayout.TextField("Tile Prefabs Dir", tilePrefabsDir); + buildingPrefabsDir = EditorGUILayout.TextField("Building Prefabs Dir", buildingPrefabsDir); outputRoot = EditorGUILayout.TextField("Output Root", outputRoot); buildTarget = (BuildTarget)EditorGUILayout.EnumPopup("Build Target", buildTarget); @@ -89,6 +93,8 @@ public class GeoTileAddressablesWindow : EditorWindow buildSource = (BuildSource)EditorGUILayout.EnumPopup("Source", buildSource); overwriteExisting = EditorGUILayout.ToggleLeft("Overwrite existing bundles", overwriteExisting); verboseLogging = EditorGUILayout.ToggleLeft("Verbose logging", verboseLogging); + includeBuildingPrefabs = EditorGUILayout.ToggleLeft("Include building prefabs (2km blocks)", includeBuildingPrefabs); + buildingBlockSize = EditorGUILayout.IntField("Building block size (tiles)", buildingBlockSize); if (buildSource == BuildSource.SceneTerrains) EditorGUILayout.HelpBox("Scene terrain source is not implemented yet.", MessageType.Info); @@ -299,6 +305,7 @@ public class GeoTileAddressablesWindow : EditorWindow { TileIndexCsvPath = tileIndexCsvPath, TilePrefabsDir = tilePrefabsDir, + BuildingPrefabsDir = buildingPrefabsDir, OutputRoot = outputRoot, Target = buildTarget, TargetGroup = GetBuildTargetGroup(buildTarget), @@ -312,7 +319,9 @@ public class GeoTileAddressablesWindow : EditorWindow }, SelectedTiles = selectedTiles, OverwriteExisting = overwriteExisting, - Verbose = verboseLogging + Verbose = verboseLogging, + IncludeBuildingPrefabs = includeBuildingPrefabs, + BuildingBlockSizeInTiles = buildingBlockSize }; GeoTileAddressablesBuilder.BuildSelectedTiles(request); diff --git a/Assets/Scripts/Editor/GeoTileImporter.cs b/Assets/Scripts/Editor/GeoTileImporter.cs index 6568c9616..11ae66c1a 100644 --- a/Assets/Scripts/Editor/GeoTileImporter.cs +++ b/Assets/Scripts/Editor/GeoTileImporter.cs @@ -13,9 +13,10 @@ using UnityEngine; public class GeoTileImporter : EditorWindow { - private string tilesCsvPath = "Assets/GeoData/tile_index.csv"; - private string heightmapsDir = "Assets/GeoData/height_png16"; - private string orthoDir = "Assets/GeoData/ortho_jpg"; + private string tilesCsvPath = "Assets/GeoData/tile_index_river_vr.csv"; + private string heightmapsDir = "Assets/GeoData/height_png16_river/vr"; + private string orthoDir = "Assets/GeoData/ortho_jpg_river"; + private string orthoDirFallback = "Assets/GeoData/ortho_jpg"; private string buildingsDir = "Assets/GeoData/buildings_tiles"; private string buildingsEnhancedDir = "Assets/GeoData/buildings_enhanced"; private string treesDir = "Assets/GeoData/trees_tiles"; @@ -34,6 +35,7 @@ public class GeoTileImporter : EditorWindow private bool applyOrthoTextures = true; private bool importBuildings = true; private bool useEnhancedBuildings = false; + private bool importBuildingsEvenTilesOnly = true; private bool importTrees = true; private bool importFurniture = false; private bool deleteExistingBuildings = false; @@ -83,7 +85,8 @@ public class GeoTileImporter : EditorWindow GUILayout.Label("Inputs", EditorStyles.boldLabel); tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath); heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir); - orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir); + orthoDir = EditorGUILayout.TextField("ortho_jpg dir (primary)", orthoDir); + orthoDirFallback = EditorGUILayout.TextField("ortho_jpg dir (fallback)", orthoDirFallback); buildingsDir = EditorGUILayout.TextField("buildings_glb dir", buildingsDir); treesDir = EditorGUILayout.TextField("trees_glb dir", treesDir); treeProxyPath = EditorGUILayout.TextField("tree_proxies.glb", treeProxyPath); @@ -103,6 +106,7 @@ public class GeoTileImporter : EditorWindow deleteExistingBuildings = EditorGUILayout.ToggleLeft("Delete existing buildings under parent", deleteExistingBuildings); importBuildings = EditorGUILayout.ToggleLeft("Import buildings (GLB per tile)", importBuildings); useEnhancedBuildings = EditorGUILayout.ToggleLeft("Use enhanced buildings (from buildings_enhanced/)", useEnhancedBuildings); + importBuildingsEvenTilesOnly = EditorGUILayout.ToggleLeft("Import 2km buildings once (even X/Y tiles only)", importBuildingsEvenTilesOnly); GUILayout.Space(5); treesParentName = EditorGUILayout.TextField("Trees parent name", treesParentName); @@ -287,10 +291,15 @@ public class GeoTileImporter : EditorWindow Debug.LogError($"[GeoTileImporter] Heightmap dir not found: {heightmapsDir}"); return; } - if (applyOrthoTextures && !Directory.Exists(orthoDir)) + if (applyOrthoTextures) { - Debug.LogWarning($"[GeoTileImporter] Ortho dir not found: {orthoDir} (textures will be skipped)."); - applyOrthoTextures = false; + bool primaryExists = Directory.Exists(orthoDir); + bool fallbackExists = !string.IsNullOrWhiteSpace(orthoDirFallback) && Directory.Exists(orthoDirFallback); + if (!primaryExists && !fallbackExists) + { + Debug.LogWarning($"[GeoTileImporter] Ortho dirs not found: primary={orthoDir}, fallback={orthoDirFallback} (textures will be skipped)."); + applyOrthoTextures = false; + } } RefreshTileIndexCache(); @@ -431,6 +440,12 @@ public class GeoTileImporter : EditorWindow if (applyOrthoTextures) { string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/"); + if (!File.Exists(orthoPath) && !string.IsNullOrWhiteSpace(orthoDirFallback)) + { + string fallbackPath = Path.Combine(orthoDirFallback, $"{tileId}.jpg").Replace("\\", "/"); + if (File.Exists(fallbackPath)) + orthoPath = fallbackPath; + } if (File.Exists(orthoPath)) { EnsureOrthoImportSettings(orthoPath); @@ -805,6 +820,56 @@ public class GeoTileImporter : EditorWindow public int Y; } + private static bool TryGetTileXY(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++) + { + string 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, NumberStyles.Integer, CultureInfo.InvariantCulture, 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 bool ShouldImportBuildingsForTile(string tileId, bool evenTilesOnly) + { + if (!evenTilesOnly) + return true; + + if (!TryGetTileXY(tileId, out int x, out int y)) + return true; + + return (x % 2 == 0) && (y % 2 == 0); + } private void ImportBuildings(List<(string tileId, float ux, float uz, float baseY)> placements) { @@ -829,9 +894,15 @@ public class GeoTileImporter : EditorWindow DestroyImmediate(parent.transform.GetChild(i).gameObject); } - int imported = 0, missing = 0; + int imported = 0, missing = 0, skipped = 0; foreach (var (tileId, ux, uz, baseY) in placements) { + if (!ShouldImportBuildingsForTile(tileId, importBuildingsEvenTilesOnly)) + { + skipped++; + continue; + } + string glbPath = Path.Combine(activeDir, $"{tileId}.glb").Replace("\\", "/"); if (!File.Exists(glbPath)) { @@ -863,7 +934,7 @@ public class GeoTileImporter : EditorWindow imported++; } - Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, missing/failed={missing} under '{buildingsParentName}'."); + Debug.Log($"[GeoTileImporter] Buildings ({sourceLabel}) imported={imported}, skipped={skipped}, missing/failed={missing} under '{buildingsParentName}'."); } private void ImportTrees(List<(string tileId, float ux, float uz, float baseY)> placements) diff --git a/Assets/Scripts/Editor/GeoTilePrefabImporter.cs b/Assets/Scripts/Editor/GeoTilePrefabImporter.cs index 26fea7448..c755b1b54 100644 --- a/Assets/Scripts/Editor/GeoTilePrefabImporter.cs +++ b/Assets/Scripts/Editor/GeoTilePrefabImporter.cs @@ -15,6 +15,7 @@ public class GeoTilePrefabImporter : EditorWindow private string tilesCsvPath = "Assets/GeoData/tile_index.csv"; private string heightmapsDir = "Assets/GeoData/height_png16"; private string orthoDir = "Assets/GeoData/ortho_jpg"; + private string orthoDirFallback = "Assets/GeoData/ortho_jpg_river"; private string buildingsDir = "Assets/GeoData/buildings_tiles"; private string treesDir = "Assets/GeoData/trees_tiles"; private string furnitureDir = "Assets/GeoData/street_furniture"; @@ -23,6 +24,9 @@ public class GeoTilePrefabImporter : EditorWindow // Output settings private string prefabOutputDir = "Assets/TilePrefabs"; private bool overwriteExisting = false; + private string buildingPrefabsDir = "Assets/TilePrefabs_Buildings"; + private bool exportBuildingPrefabs = true; + private bool overwriteBuildingPrefabs = false; // Terrain settings private float tileSizeMeters = 1000f; @@ -31,6 +35,7 @@ public class GeoTilePrefabImporter : EditorWindow // Component toggles private bool applyOrthoTextures = true; private bool includeBuildings = true; + private bool includeBuildingsEvenTilesOnly = true; private bool includeTrees = true; private bool includeFurniture = false; private bool includeEnhancedTrees = false; @@ -87,7 +92,8 @@ public class GeoTilePrefabImporter : EditorWindow GUILayout.Label("Input Paths", EditorStyles.boldLabel); tilesCsvPath = EditorGUILayout.TextField("tile_index.csv", tilesCsvPath); heightmapsDir = EditorGUILayout.TextField("height_png16 dir", heightmapsDir); - orthoDir = EditorGUILayout.TextField("ortho_jpg dir", orthoDir); + orthoDir = EditorGUILayout.TextField("ortho_jpg dir (primary)", orthoDir); + orthoDirFallback = EditorGUILayout.TextField("ortho_jpg dir (fallback)", orthoDirFallback); buildingsDir = EditorGUILayout.TextField("buildings_tiles dir", buildingsDir); treesDir = EditorGUILayout.TextField("trees_tiles dir", treesDir); furnitureDir = EditorGUILayout.TextField("street_furniture dir", furnitureDir); @@ -97,6 +103,9 @@ public class GeoTilePrefabImporter : EditorWindow GUILayout.Label("Output Settings", EditorStyles.boldLabel); prefabOutputDir = EditorGUILayout.TextField("Prefab output dir", prefabOutputDir); overwriteExisting = EditorGUILayout.ToggleLeft("Overwrite existing prefabs", overwriteExisting); + buildingPrefabsDir = EditorGUILayout.TextField("Building prefab output dir", buildingPrefabsDir); + exportBuildingPrefabs = EditorGUILayout.ToggleLeft("Export building-only prefabs (2km blocks)", exportBuildingPrefabs); + overwriteBuildingPrefabs = EditorGUILayout.ToggleLeft("Overwrite building prefabs", overwriteBuildingPrefabs); GUILayout.Space(10); GUILayout.Label("Terrain Settings", EditorStyles.boldLabel); @@ -107,6 +116,7 @@ public class GeoTilePrefabImporter : EditorWindow GUILayout.Label("Include Components", EditorStyles.boldLabel); applyOrthoTextures = EditorGUILayout.ToggleLeft("Apply ortho textures", applyOrthoTextures); includeBuildings = EditorGUILayout.ToggleLeft("Include buildings (GLB)", includeBuildings); + includeBuildingsEvenTilesOnly = EditorGUILayout.ToggleLeft("Include 2km buildings once (even X/Y tiles only)", includeBuildingsEvenTilesOnly); includeTrees = EditorGUILayout.ToggleLeft("Include trees (GLB chunks)", includeTrees); includeFurniture = EditorGUILayout.ToggleLeft("Include street furniture (CSV)", includeFurniture); includeEnhancedTrees = EditorGUILayout.ToggleLeft("Include enhanced trees (CSV)", includeEnhancedTrees); @@ -249,6 +259,8 @@ public class GeoTilePrefabImporter : EditorWindow EnsureDirectoryExists(prefabOutputDir); EnsureDirectoryExists($"{prefabOutputDir}/TerrainData"); EnsureDirectoryExists($"{prefabOutputDir}/TerrainLayers"); + if (exportBuildingPrefabs) + EnsureDirectoryExists(buildingPrefabsDir); // Parse CSV RefreshTileIndexCache(); @@ -269,6 +281,7 @@ public class GeoTilePrefabImporter : EditorWindow Debug.Log($"[GeoTilePrefabImporter] Found {selectedTiles.Count} tiles to process."); int created = 0, skipped = 0, failed = 0; + int buildingsCreated = 0, buildingsSkipped = 0, buildingsFailed = 0; for (int i = 0; i < selectedTiles.Count; i++) { @@ -279,19 +292,33 @@ public class GeoTilePrefabImporter : EditorWindow (float)i / selectedTiles.Count); string prefabPath = $"{prefabOutputDir}/{tile.TileId}.prefab"; - if (File.Exists(prefabPath) && !overwriteExisting) + bool skipTerrain = File.Exists(prefabPath) && !overwriteExisting; + if (skipTerrain) { - Debug.Log($"[GeoTilePrefabImporter] Skipping existing: {tile.TileId}"); + Debug.Log($"[GeoTilePrefabImporter] Skipping existing terrain prefab: {tile.TileId}"); skipped++; - continue; } try { - if (CreateTilePrefab(tile)) - created++; - else - failed++; + if (!skipTerrain) + { + if (CreateTilePrefab(tile)) + created++; + else + failed++; + } + + if (exportBuildingPrefabs) + { + var result = CreateBuildingPrefab(tile); + if (result == BuildResult.Created) + buildingsCreated++; + else if (result == BuildResult.Skipped) + buildingsSkipped++; + else + buildingsFailed++; + } } catch (Exception e) { @@ -304,7 +331,8 @@ public class GeoTilePrefabImporter : EditorWindow AssetDatabase.SaveAssets(); AssetDatabase.Refresh(); - Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}"); + Debug.Log($"[GeoTilePrefabImporter] DONE. Created={created}, Skipped={skipped}, Failed={failed}" + + (exportBuildingPrefabs ? $", Buildings Created={buildingsCreated}, Skipped={buildingsSkipped}, Failed={buildingsFailed}" : "")); } private void RefreshTileIndexCache() @@ -618,6 +646,21 @@ public class GeoTilePrefabImporter : EditorWindow public int Y; } + private static bool ShouldIncludeBuildings(TileMetadata tile, bool evenTilesOnly) + { + if (!evenTilesOnly) + return true; + + return (tile.XKey % 2 == 0) && (tile.YKey % 2 == 0); + } + + private enum BuildResult + { + Created, + Skipped, + Failed + } + private bool CreateTilePrefab(TileMetadata tile) { // Validate height range @@ -736,9 +779,53 @@ public class GeoTilePrefabImporter : EditorWindow return true; } + private BuildResult CreateBuildingPrefab(TileMetadata tile) + { + if (!exportBuildingPrefabs) + return BuildResult.Skipped; + if (!ShouldIncludeBuildings(tile, includeBuildingsEvenTilesOnly)) + return BuildResult.Skipped; + + string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/"); + if (!File.Exists(glbPath)) + return BuildResult.Skipped; + + string prefabPath = $"{buildingPrefabsDir}/{tile.TileId}.prefab"; + if (File.Exists(prefabPath)) + { + if (!overwriteBuildingPrefabs) + return BuildResult.Skipped; + AssetDatabase.DeleteAsset(prefabPath); + } + + var root = new GameObject(tile.TileId); + var metadata = root.AddComponent(); + metadata.tileKey = tile.TileKey; + metadata.tileId = tile.TileId; + metadata.xmin = tile.Xmin; + metadata.ymin = tile.Ymin; + metadata.globalMin = tile.GlobalMin; + metadata.globalMax = tile.GlobalMax; + metadata.tileMin = tile.TileMin; + metadata.tileMax = tile.TileMax; + + AddBuildings(root, tile); + PrefabUtility.SaveAsPrefabAsset(root, prefabPath); + Debug.Log($"[GeoTilePrefabImporter] Created building prefab: {prefabPath}"); + DestroyImmediate(root); + + return BuildResult.Created; + } + private void ApplyOrthoTexture(TerrainData terrainData, string tileId) { string orthoPath = Path.Combine(orthoDir, $"{tileId}.jpg").Replace("\\", "/"); + if (!File.Exists(orthoPath) && !string.IsNullOrWhiteSpace(orthoDirFallback)) + { + string fallbackPath = Path.Combine(orthoDirFallback, $"{tileId}.jpg").Replace("\\", "/"); + if (File.Exists(fallbackPath)) + orthoPath = fallbackPath; + } if (!File.Exists(orthoPath)) { Debug.LogWarning($"[GeoTilePrefabImporter] Ortho texture missing for {tileId}: {orthoPath}"); @@ -779,6 +866,9 @@ public class GeoTilePrefabImporter : EditorWindow private void AddBuildings(GameObject root, TileMetadata tile) { + if (!ShouldIncludeBuildings(tile, includeBuildingsEvenTilesOnly)) + return; + string glbPath = Path.Combine(buildingsDir, $"{tile.TileId}.glb").Replace("\\", "/"); if (!File.Exists(glbPath)) return; diff --git a/Assets/Scripts/GeoDataUtils/GeoTileAddressablesLoader.cs b/Assets/Scripts/GeoDataUtils/GeoTileAddressablesLoader.cs index 21828bd68..0b85f9f0f 100644 --- a/Assets/Scripts/GeoDataUtils/GeoTileAddressablesLoader.cs +++ b/Assets/Scripts/GeoDataUtils/GeoTileAddressablesLoader.cs @@ -15,6 +15,7 @@ public class GeoTileAddressablesLoader : MonoBehaviour [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")] @@ -24,12 +25,24 @@ public class GeoTileAddressablesLoader : MonoBehaviour [SerializeField] private int maxConcurrentLoads = 2; [SerializeField] private bool verboseLogging = false; + [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; @@ -42,6 +55,8 @@ public class GeoTileAddressablesLoader : MonoBehaviour maxConcurrentLoads = 1; if (updateInterval < 0.05f) updateInterval = 0.05f; + if (buildingBlockSizeInTiles < 1) + buildingBlockSizeInTiles = 1; } private void Awake() @@ -94,6 +109,9 @@ public class GeoTileAddressablesLoader : MonoBehaviour tiles[key] = tile; } + LoadBuildingManifest(basePath); + BuildBuildingBlocks(); + Log($"Manifest loaded. Tiles={tiles.Count} CatalogFile={manifest.catalogFile}"); var catalogPath = Path.Combine(basePath, manifest.catalogFile); @@ -118,6 +136,38 @@ public class GeoTileAddressablesLoader : MonoBehaviour nextUpdateTime = Time.time; } + private void LoadBuildingManifest(string basePath) + { + buildingManifest = null; + buildingTiles.Clear(); + + if (!loadBuildingsFor2kmBlocks || string.IsNullOrWhiteSpace(buildingManifestFileName)) + return; + + var path = Path.Combine(basePath, buildingManifestFileName); + 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; + } + + Log($"Building manifest loaded. BuildingTiles={buildingTiles.Count}"); + } + private void Update() { if (!initialized || player == null) @@ -136,9 +186,14 @@ public class GeoTileAddressablesLoader : MonoBehaviour { 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, @@ -148,15 +203,65 @@ public class GeoTileAddressablesLoader : MonoBehaviour new Vector2(playerPos.x, playerPos.z), new Vector2(tileCenter.x, tileCenter.z)); - if (distance <= loadRadiusMeters) + 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)) { - EnqueueTileLoad(kvp.Key); - } - else if (distance >= unloadRadiusMeters) - { - UnloadTile(kvp.Key); + 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) @@ -168,13 +273,38 @@ public class GeoTileAddressablesLoader : MonoBehaviour 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() { - while (loading.Count < maxConcurrentLoads && loadQueue.Count > 0) + bool preferTiles = true; + while (loadQueue.Count > 0 || buildingLoadQueue.Count > 0) { - var tileKey = loadQueue.Dequeue(); - queued.Remove(tileKey); - StartLoad(tileKey); + 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; } } @@ -205,6 +335,33 @@ public class GeoTileAddressablesLoader : MonoBehaviour }; } + 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)) @@ -215,6 +372,113 @@ public class GeoTileAddressablesLoader : MonoBehaviour 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 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) @@ -252,6 +516,14 @@ public class GeoTileAddressablesLoader : MonoBehaviour 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)