add flood swe module and 2km building bundles

This commit is contained in:
2026-02-04 01:05:20 +01:00
parent 2c77c0d215
commit ff5af7a63a
57 changed files with 5369 additions and 44 deletions

8
Assets/FloodSWE.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: caac3b37ae2b11983aff2916e1ff4864
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8cbbf8ba1bacd0fa4975499573f29883
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,207 @@
#pragma kernel FluxUpdate
#pragma kernel InitDamBreak
#pragma kernel ScaleWater
Texture2D<float> _WaterIn;
Texture2D<float2> _VelIn;
Texture2D<float> _TerrainHeight;
Texture2D<float> _Porosity;
RWTexture2D<float> _WaterOut;
RWTexture2D<float2> _VelOut;
RWTexture2D<float> _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;
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5128e0ee725879f26bb5044de1621c5a
ComputeShaderImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,91 @@
#pragma kernel GhostExchange
Texture2D<float> _WaterIn;
Texture2D<float2> _VelIn;
Texture2D<float> _WaterNorth;
Texture2D<float2> _VelNorth;
Texture2D<float> _WaterSouth;
Texture2D<float2> _VelSouth;
Texture2D<float> _WaterEast;
Texture2D<float2> _VelEast;
Texture2D<float> _WaterWest;
Texture2D<float2> _VelWest;
RWTexture2D<float> _WaterOut;
RWTexture2D<float2> _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;
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 996054f3be2086cabb5672a773d4a76d
ComputeShaderImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: fa8ec11cd35df8d2a8764cc60dc83d86
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5f84cae402a59dd16a69e6257b52f26a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 8d54aac690b9b3c948a6967c06ac0906
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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<Renderer>();
}
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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f27ca2e503cf98e1fae8e3292059b147

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d14330dd605fa5af6b8fa1d7219fdb30

View File

@@ -0,0 +1,16 @@
{
"name": "FloodSWE.Runtime",
"references": [
"Unity.TextMeshPro"
],
"optionalUnityReferences": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 48859ef9fd5787638ab633c5fdbbba8c
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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<TileKey, TileRecord> records = new Dictionary<TileKey, TileRecord>();
private readonly Dictionary<string, Texture2D> textureCache = new Dictionary<string, Texture2D>(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<Texture2D>(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<TileKey>
{
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;
}
}
}

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 76a38ea8049773d56ab0ecb753b9314a
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 2d6b3ef671f38f985b086628f39649ab

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 99d03b8cf139bf08d9ec82636348cef2

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 79b936f7f0c9c532f94e673537710f81
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9d88c2b775fd75cbe9285e85e3d4630c

View File

@@ -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];
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1af734fd97eacedd8a43b69a6c3c64bd

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8711c3f108beb17afb343275ea2e69ab

View File

@@ -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<HeightmapPacket> 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<float>());
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<float>());
if (!float.IsNaN(speed) && !float.IsInfinity(speed))
{
lastMaxSpeed = speed;
}
}
});
}
else
{
AsyncGPUReadback.Request(water, 0, request =>
{
if (!request.hasError)
{
DepthStats stats = ComputeDepthStats(request.GetData<ushort>());
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<ushort>());
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<float>())
: CountClampStats(request.GetData<ushort>());
lastClampedCells = stats.Clamped;
lastNanCells = stats.NaNs;
lastClampedRatio = stats.Clamped / (float)(gridRes * gridRes);
}
});
}
}
private static DepthStats ComputeDepthStats(NativeArray<ushort> 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<float> 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<ushort> 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<float> 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<ushort> 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<float> 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5539ed5458731ed3db3b49073dcaa1d7

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: e1cdfecc9178688ffa8c85d23e58c858
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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");
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 066b13730d1434bf7a3f5b01fcfc7f1f

View File

@@ -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
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 6849d9f7c0440852ab0d17f21dc5c934
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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");
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 725efcb4f40636aafa694f519aaa23a5

View File

@@ -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<ComputeShader>(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<SweTileSimulator>();
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<NativeArray<ushort>> 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<ushort>());
}
public static float ComputeAverageDepth(NativeArray<ushort> 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<ushort> 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);
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 51a90ee6e9ee03fd9bc56ce6a64de61a

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 062c43bc68188239e86255fca867f481
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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<TileId, TileNode> nodes = new Dictionary<TileId, TileNode>();
private readonly List<TileNode> scratchActive = new List<TileNode>();
private readonly List<TileNode> scratchSorted = new List<TileNode>();
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<TileNode> 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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 120950576bb06f2a0b821f4c20de967a

View File

@@ -0,0 +1,58 @@
using System;
namespace FloodSWE.TileGraph
{
public readonly struct TileId : IEquatable<TileId>
{
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);
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 655e4befd8aed0ce49fe0f1a55709194

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 575ced0116c14039a9dcea5b5975c043

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 117300c7ddc293d23a6961b4689ad03e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 942b8ce998a337a7aae19258096d6eb0
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:

1009
Assets/Scenes/SWE_test.unity Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b877d4144ba0f02d5be60365a4bcc89b
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

Binary file not shown.

View File

@@ -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<TileRecord> 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<TileRecord> 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<string> AssignBuildingPrefabs(AddressableAssetSettings settings, AddressableAssetGroup group, string prefabsDir, List<TileRecord> tiles, bool removeUnselected)
{
var group = settings.FindGroup(GroupName);
var tileById = new Dictionary<string, TileRecord>(StringComparer.OrdinalIgnoreCase);
foreach (var tile in tiles)
{
if (!string.IsNullOrWhiteSpace(tile.TileId))
tileById[tile.TileId] = tile;
}
var assignedTileIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var selectedGuids = new HashSet<string>();
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<AddressableAssetGroupSchema>());
group = settings.CreateGroup(groupName, false, false, false, new List<AddressableAssetGroupSchema>());
group.AddSchema<BundledAssetGroupSchema>();
group.AddSchema<ContentUpdateGroupSchema>();
return group;
@@ -327,17 +438,37 @@ public static class GeoTileAddressablesBuilder
}
private static TileManifest BuildManifest(string buildTarget, string catalogFile, string catalogHashFile, List<TileRecord> tiles, float tileSizeMeters)
=> BuildManifest(buildTarget, catalogFile, catalogHashFile, tiles, tileSizeMeters, ResolveTileKey, null, null);
private static TileManifest BuildManifest(
string buildTarget,
string catalogFile,
string catalogHashFile,
List<TileRecord> tiles,
float tileSizeMeters,
Func<TileRecord, string> 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<TileRecord> 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);

View File

@@ -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);

View File

@@ -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<int>();
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)

View File

@@ -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<GeoTileMetadata>();
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;

View File

@@ -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<string, TileEntry> tiles = new Dictionary<string, TileEntry>();
private readonly Dictionary<string, GameObject> loaded = new Dictionary<string, GameObject>();
private readonly HashSet<string> loading = new HashSet<string>();
private readonly HashSet<string> queued = new HashSet<string>();
private readonly Queue<string> loadQueue = new Queue<string>();
private readonly Dictionary<string, TileEntry> buildingTiles = new Dictionary<string, TileEntry>();
private readonly Dictionary<string, GameObject> buildingLoaded = new Dictionary<string, GameObject>();
private readonly HashSet<string> buildingLoading = new HashSet<string>();
private readonly HashSet<string> buildingQueued = new HashSet<string>();
private readonly Queue<string> buildingLoadQueue = new Queue<string>();
private readonly Dictionary<string, string> tileBlockKey = new Dictionary<string, string>();
private readonly Dictionary<string, string> blockBuildingKey = new Dictionary<string, string>();
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<TileManifest>(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<string>();
HashSet<string> wantedBlocks = (loadBuildingsFor2kmBlocks && buildingTiles.Count > 0)
? new HashSet<string>()
: 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<string>();
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<string>();
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<string>();
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<int>();
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)