From 730c1f5fa7ad40a3de00bfc7db8a6ef86fe2e205 Mon Sep 17 00:00:00 2001 From: "Senzaiken\\Uri" Date: Wed, 17 Jun 2026 10:50:29 -0400 Subject: [PATCH] Rewrite map rendering system with progressive materialization, region caching, and memory-aware safeguards --- CentrED/Camera.cs | 32 +- CentrED/CentrEDGame.cs | 2 + CentrED/Config.cs | 3 +- CentrED/Map/LandObject.cs | 25 +- CentrED/Map/MapManager.cs | 1426 ++++++++++++++++++- CentrED/Map/RadarMap.cs | 22 +- CentrED/Map/StaticsManager.cs | 65 +- CentrED/Renderer/MapRenderer.cs | 159 ++- CentrED/Tools/AltitudeGradientTool.cs | 4 +- CentrED/Tools/BaseTool.cs | 10 +- CentrED/Tools/CoastlineTool.cs | 26 +- CentrED/Tools/ElevateTool.cs | 4 +- CentrED/Tools/MeshEditTool.cs | 4 +- CentrED/UI/UIManager.cs | 43 +- CentrED/UI/Windows/DebugWindow.cs | 59 +- CentrED/Utils/Metrics.cs | 120 +- Client/CentrEDClient.cs | 198 ++- Client/Map/ClientLandscapePacketHandlers.cs | 4 +- Shared/BlockCache.cs | 5 +- 19 files changed, 2069 insertions(+), 142 deletions(-) diff --git a/CentrED/Camera.cs b/CentrED/Camera.cs index 1a668023..d5a6e8c5 100644 --- a/CentrED/Camera.cs +++ b/CentrED/Camera.cs @@ -49,9 +49,37 @@ public void ResetCamera() Roll = 0f; } + private static readonly float[] ZoomLevels = + { + 0.02f, 0.05f, + 0.1f, 0.2f, 0.3f, 0.4f, 0.5f, 0.6f, 0.7f, 0.8f, 0.9f, 1.0f, + 1.5f, 2.0f, 2.5f, 3.0f, 3.5f, 4.0f + }; + public void ZoomIn(float delta) { - Zoom = Math.Clamp(Zoom + delta, 0.2f, 4f); + if (delta == 0f) + return; + + var notches = Math.Max(1, (int)Math.Round(Math.Abs(delta) / 0.1f)); + for (var i = 0; i < notches; i++) + Zoom = delta > 0 ? NextZoomLevel(Zoom, true) : NextZoomLevel(Zoom, false); + } + + private static float NextZoomLevel(float current, bool up) + { + const float eps = 1e-4f; + if (up) + { + foreach (var level in ZoomLevels) + if (level > current + eps) + return level; + return ZoomLevels[^1]; + } + for (var i = ZoomLevels.Length - 1; i >= 0; i--) + if (ZoomLevels[i] < current - eps) + return ZoomLevels[i]; + return ZoomLevels[0]; } public void Update() @@ -78,4 +106,4 @@ public void Update() WorldViewProj.M41, WorldViewProj.M42, WorldViewProj.M43, WorldViewProj.M44 ); } -} \ No newline at end of file +} diff --git a/CentrED/CentrEDGame.cs b/CentrED/CentrEDGame.cs index 986abadd..12c74ad6 100644 --- a/CentrED/CentrEDGame.cs +++ b/CentrED/CentrEDGame.cs @@ -68,6 +68,7 @@ protected override void BeginRun() protected override void UnloadContent() { + Metrics.StopProfiling(); CEDClient.Disconnect(); } @@ -125,6 +126,7 @@ protected override void Draw(GameTime gameTime) UIManager.DrawExtraWindows(); MapManager.AfterDraw(); Metrics.Stop("Draw"); + Metrics.CaptureFrame(); } catch (Exception e) { diff --git a/CentrED/Config.cs b/CentrED/Config.cs index 015befe8..afcdf2e1 100644 --- a/CentrED/Config.cs +++ b/CentrED/Config.cs @@ -24,6 +24,7 @@ public class ConfigRoot public bool ObjectBrightHighlight; public bool LegacyMouseScroll; public bool Viewports; + public bool PreloadMapOnConnect = true; public string GraphicsDriver = "Auto"; //Auto,SDL_GPU,D3D11,OpenGL public Dictionary Layout = new(); public Dictionary Keymap = new(); @@ -74,4 +75,4 @@ public static void Save() File.WriteAllText(_configFilePath, JsonSerializer.Serialize(Instance, SerializerOptions)); LastConfigSave = DateTime.Now; } -} \ No newline at end of file +} diff --git a/CentrED/Map/LandObject.cs b/CentrED/Map/LandObject.cs index 3f00ce44..4151d1a5 100644 --- a/CentrED/Map/LandObject.cs +++ b/CentrED/Map/LandObject.cs @@ -12,20 +12,26 @@ public class LandObject : TileObject public LandTile LandTile; public bool IsGhost => LandTile.Block == null; + private sbyte _averageZ; - public sbyte AverageZ() //TODO Calculate me once - { - int zTop = (int)(Vertices[0].Position.Z / TILE_Z_SCALE); - int zRight= (int)(Vertices[1].Position.Z/ TILE_Z_SCALE); - int zLeft= (int)(Vertices[2].Position.Z/ TILE_Z_SCALE); - int zBottom= (int)(Vertices[3].Position.Z/ TILE_Z_SCALE); + public sbyte AverageZ() + { + return _averageZ; + } + + private void UpdateAverageZ(Vector4 cornerZ) + { + int zTop = (int)(cornerZ.X / TILE_Z_SCALE); + int zRight= (int)(cornerZ.Y / TILE_Z_SCALE); + int zLeft= (int)(cornerZ.Z / TILE_Z_SCALE); + int zBottom= (int)(cornerZ.W / TILE_Z_SCALE); if (Math.Abs(zTop - zBottom) <= Math.Abs(zLeft - zRight)) { - return(sbyte) ((zTop + zBottom) >> 1); + _averageZ = (sbyte)((zTop + zBottom) >> 1); } else { - return (sbyte) ((zLeft + zRight) >> 1); + _averageZ = (sbyte)((zLeft + zRight) >> 1); } } @@ -64,6 +70,7 @@ public void UpdateCorners(ushort id) var alwaysFlat = AlwaysFlat(id); var flatView = CEDGame.MapManager.FlatView; Vector4 cornerZ = flatView ? Vector4.Zero : alwaysFlat ? new Vector4(Tile.Z * TILE_Z_SCALE) : GetCornerZ(); + UpdateAverageZ(cornerZ); var posX = (Tile.X - 1) * TILE_SIZE; var posY = (Tile.Y - 1) * TILE_SIZE; @@ -316,4 +323,4 @@ private bool CalculateNormal(LandTile tile, LandTile? top, LandTile? right, Land } return true; } -} \ No newline at end of file +} diff --git a/CentrED/Map/MapManager.cs b/CentrED/Map/MapManager.cs index 2a5b4346..79d50e6e 100644 --- a/CentrED/Map/MapManager.cs +++ b/CentrED/Map/MapManager.cs @@ -134,10 +134,31 @@ public MapManager(GraphicsDevice gd, GameWindow window, Keymap keymap) EnableBlockLoading(); Client.LandTileReplaced += OnLandTileReplaced; Client.LandTileElevated += OnLandTileElevated; - Client.StaticTileAdded += StaticsManager.Add; - Client.StaticTileRemoved += StaticsManager.Remove; - Client.StaticTileMoved += StaticsManager.Move; - Client.StaticTileElevated += StaticsManager.Elevate; + Client.StaticTileAdded += tile => + { + StaticsManager.Add(tile); + MarkSelectionBufferDirty(); + MarkStaticRegionDirtyAtTile(tile.X, tile.Y); + }; + Client.StaticTileRemoved += tile => + { + StaticsManager.Remove(tile); + MarkSelectionBufferDirty(); + MarkStaticRegionDirtyAtTile(tile.X, tile.Y); + }; + Client.StaticTileMoved += (tile, x, y) => + { + StaticsManager.Move(tile, x, y); + MarkSelectionBufferDirty(); + MarkStaticRegionDirtyAtTile(tile.X, tile.Y); + MarkStaticRegionDirtyAtTile(x, y); + }; + Client.StaticTileElevated += (tile, z) => + { + StaticsManager.Elevate(tile, z); + MarkSelectionBufferDirty(); + MarkStaticRegionDirtyAtTile(tile.X, tile.Y); + }; Client.StaticTileHued += HueStatic; Client.AfterStaticChanged += AfterStaticChanged; Client.Moved += (x, y) => TilePosition = new Point(x,y); @@ -171,6 +192,152 @@ private void OnConnected() StaticsManager.Initialize(Client.WidthInTiles, Client.HeightInTiles); VirtualLayer.Width = Client.WidthInTiles; VirtualLayer.Height = Client.HeightInTiles; + InitRegionCaches(); + _materializedBlocks.Clear(); + _materializationComplete = false; + _hasLastMaterializeViewRange = false; + _bgRadius = 0; + _bgRingPos = 0; + (_bgCenterX, _bgCenterY) = CameraBlock(); + _bgMaterializeDone = false; + EvaluateMemoryTiers(); + _adaptiveMaterializeCap = MaxMaterializeCap; + _lastInteractionFrame = long.MinValue; + _cacheRateTimestamp = 0; + _cacheRateLastCount = 0; + _cacheBlocksPerSecond = 0; + if (_backgroundFillEnabled) + { + Client.RequestAllBlocks(_bgCenterX, _bgCenterY); + } + } + + private long _debugAvailableMemoryOverrideBytes; + public int DebugAvailableMemoryOverrideMB + { + get => (int)(_debugAvailableMemoryOverrideBytes / (1024 * 1024)); + set + { + _debugAvailableMemoryOverrideBytes = value > 0 ? (long)value * 1024 * 1024 : 0; + EvaluateMemoryTiers(log: false); + EvictMaterializedBlocksOutsideView(); + } + } + + private void EvaluateMemoryTiers(bool log = true) + { + var available = _debugAvailableMemoryOverrideBytes > 0 + ? _debugAvailableMemoryOverrideBytes + : GC.GetGCMemoryInfo().TotalAvailableMemoryBytes; + _totalAvailableMemoryBytes = available; + + var blocks = (long)Client.Width * Client.Height; + var preloadBytes = blocks * EstimatedBytesPerBlock; + var preloadFits = blocks > 0 && (available <= 0 || preloadBytes <= available * PreloadMemoryFraction); + _backgroundFillEnabled = Config.Instance.PreloadMapOnConnect && preloadFits; + + var regionCacheBytes = blocks * EstimatedRegionCacheBytesPerBlock; + _regionCacheEvictionEnabled = available > 0 && regionCacheBytes > available * RegionCacheKeepAllFraction; + + _materializeBudgetBlocks = available > 0 + ? Math.Max(64, (long)(available * MaterializeMemoryFraction / EstimatedBytesPerBlock)) + : long.MaxValue; + _materializeEvictionEnabled = available > 0 && blocks > _materializeBudgetBlocks; + + if (!log) + return; + if (Config.Instance.PreloadMapOnConnect && !preloadFits) + { + Console.WriteLine( + $"[MapManager] Full-map preload skipped: estimated {preloadBytes / (1024 * 1024)} MB for " + + $"{Client.Width}x{Client.Height} blocks exceeds the safe budget of " + + $"{(long)(available * PreloadMemoryFraction) / (1024 * 1024)} MB. Blocks will load on demand instead."); + } + if (_regionCacheEvictionEnabled) + { + Console.WriteLine( + $"[MapManager] Region render caches will be evicted to a window around the view " + + $"(estimated full-map cache {regionCacheBytes / (1024 * 1024)} MB vs " + + $"{available / (1024 * 1024)} MB available)."); + } + if (_materializeEvictionEnabled) + { + Console.WriteLine( + $"[MapManager] Low memory for this map: capping materialized blocks at ~{_materializeBudgetBlocks:N0} " + + $"(of {blocks:N0}); zoom-out is floored and off-screen blocks are released as you pan."); + } + } + + private float ComputeMinZoom() + { + if (!_materializeEvictionEnabled || _materializeBudgetBlocks <= 0) + return 0f; + double sum = Camera.ScreenSize.Width + Camera.ScreenSize.Height; + if (sum <= 0) + return 0f; + var allowedLinearBlocks = Math.Max(8.0, Math.Sqrt(_materializeBudgetBlocks * ViewBudgetFraction)); + return (float)(2.0 * sum / (2.6 * TILE_SIZE * 8.0 * allowedLinearBlocks)); + } + + private void EnforceZoomFloor() + { + var minZoom = ComputeMinZoom(); + if (minZoom > 0f && Camera.Zoom < minZoom) + Camera.Zoom = minZoom; + } + + private void EvictMaterializedBlocksOutsideView() + { + if (!_materializeEvictionEnabled || _materializedBlocks.Count == 0) + return; + int bx1 = ViewRange.X1 / 8 - MaterializeKeepMarginBlocks; + int by1 = ViewRange.Y1 / 8 - MaterializeKeepMarginBlocks; + int bx2 = ViewRange.X2 / 8 + MaterializeKeepMarginBlocks; + int by2 = ViewRange.Y2 / 8 + MaterializeKeepMarginBlocks; + _blocksToDematerialize.Clear(); + foreach (var packed in _materializedBlocks) + { + int bx = packed >> 16, by = packed & 0xFFFF; + if (bx < bx1 || bx > bx2 || by < by1 || by > by2) + _blocksToDematerialize.Add(packed); + } + foreach (var packed in _blocksToDematerialize) + DematerializeBlock(packed >> 16, packed & 0xFFFF); + } + + private void DematerializeBlock(int bx, int by) + { + if (!_materializedBlocks.Remove(PackBlock(bx, by))) + return; + int minX = bx * 8, minY = by * 8; + for (int x = minX; x < minX + 8; x++) + for (int y = minY; y < minY + 8; y++) + RemoveTiles((ushort)x, (ushort)y); + MarkCacheRegionsDirtyForBlock(bx, by); + } + + private (int bx, int by) CameraBlock() + { + int tx = (int)(Camera.Position.X / TILE_SIZE); + int ty = (int)(Camera.Position.Y / TILE_SIZE); + int bx = Math.Clamp(tx / 8, 0, Math.Max(0, Client.Width - 1)); + int by = Math.Clamp(ty / 8, 0, Math.Max(0, Client.Height - 1)); + return (bx, by); + } + + private void MaybeRecenterBackgroundFill() + { + if (!_backgroundFillEnabled || _bgMaterializeDone) + return; + var (cbx, cby) = CameraBlock(); + if ((Math.Abs(cbx - _bgCenterX) > 16 || Math.Abs(cby - _bgCenterY) > 16) && + !_materializedBlocks.Contains(PackBlock(cbx, cby))) + { + _bgCenterX = cbx; + _bgCenterY = cby; + _bgRadius = 0; + _bgRingPos = 0; + } } private void OnDisconnected() @@ -192,15 +359,30 @@ public void DisableBlockLoading() private void OnBlockLoaded(Block block) { + if (_materializedBlocks.Contains(PackBlock(block.LandBlock.X, block.LandBlock.Y))) + { + MaterializeBlock(block); + return; + } + if (!IsBlockInViewRegion(block.LandBlock.X, block.LandBlock.Y)) + { + return; + } + if (!TryMaterializeBlock(block)) + { + _materializationComplete = false; + } + } + + private void MaterializeBlock(Block block) + { + var sw = System.Diagnostics.Stopwatch.StartNew(); ClearBlock(block); foreach (var landTile in block.LandBlock.Tiles) { AddTile(landTile); } - foreach (var staticTile in block.StaticBlock.AllTiles()) - { - StaticsManager.Add(staticTile); - } + StaticsManager.AddRange(block.StaticBlock.AllTiles()); //Recalculate tiles one and two tiles away from block, to fix corners and normals var landBlock = block.LandBlock; var minTileX = landBlock.X * 8; @@ -221,11 +403,195 @@ private void OnBlockLoaded(Block block) } } - UpdateLights(); + MarkCacheRegionsDirtyForBlock(landBlock.X, landBlock.Y); + _materializedBlocks.Add(PackBlock(landBlock.X, landBlock.Y)); + if (IsBlockInViewRegion(landBlock.X, landBlock.Y)) + MarkSelectionBufferDirty(); + sw.Stop(); + _frameMaterializeMs += sw.Elapsed.TotalMilliseconds; + _frameMaterializeCount++; + } + + private bool CanMaterializeMore() + { + return _frameMaterializeCount < (int)_adaptiveMaterializeCap && + _frameMaterializeMs < MaterializeBudgetMs; + } + + private void AdaptMaterializeCap() + { + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + var frameMs = _lastUpdateTimestamp == 0 + ? InteractiveTargetFrameMs + : System.Diagnostics.Stopwatch.GetElapsedTime(_lastUpdateTimestamp, now).TotalMilliseconds; + _lastUpdateTimestamp = now; + + var idle = _frameCounter - _lastInteractionFrame > IdleFramesBeforeFastFill; + var targetMs = idle ? IdleTargetFrameMs : InteractiveTargetFrameMs; + if (frameMs < targetMs) + _adaptiveMaterializeCap = Math.Min(MaxMaterializeCap, _adaptiveMaterializeCap + 8); + else + _adaptiveMaterializeCap = Math.Max(MinMaterializeCap, _adaptiveMaterializeCap * 0.7); + } + + private bool TryMaterializeBlock(Block block) + { + if (!CanMaterializeMore()) + return false; + MaterializeBlock(block); + return true; + } + + private bool IsBlockInViewRegion(ushort blockX, ushort blockY) + { + var r = ViewRange; + return blockX >= r.X1 / 8 && blockX <= r.X2 / 8 && + blockY >= r.Y1 / 8 && blockY <= r.Y2 / 8; + } + + private void UpdateMaterializedRegion() + { + if (!Client.Running || _bgMaterializeDone) + return; + + int bx1 = ViewRange.X1 / 8, by1 = ViewRange.Y1 / 8; + int bx2 = ViewRange.X2 / 8, by2 = ViewRange.Y2 / 8; + + if (!_hasLastMaterializeViewRange || ViewRange != _lastMaterializeViewRange) + { + _lastMaterializeViewRange = ViewRange; + _hasLastMaterializeViewRange = true; + var (cbx, cby) = CameraBlock(); + _fgCenterX = Math.Clamp(cbx, bx1, bx2); + _fgCenterY = Math.Clamp(cby, by1, by2); + _fgRadius = 0; + _fgRingPos = 0; + _fgAnyUnmaterialized = false; + _materializationComplete = false; + } + if (_materializationComplete) + return; + + int maxR = Math.Max(Math.Max(_fgCenterX - bx1, bx2 - _fgCenterX), + Math.Max(_fgCenterY - by1, by2 - _fgCenterY)); + var scanned = 0; + while (CanMaterializeMore() && scanned < MaterializeScanCap && _fgRadius <= maxR) + { + scanned++; + SpiralBlock(_fgCenterX, _fgCenterY, _fgRadius, _fgRingPos, out var bx, out var by); + var ringCount = _fgRadius == 0 ? 1 : 8 * _fgRadius; + if (++_fgRingPos >= ringCount) + { + _fgRingPos = 0; + _fgRadius++; + } + + if (bx < bx1 || bx > bx2 || by < by1 || by > by2) + continue; + if (_materializedBlocks.Contains(PackBlock(bx, by))) + continue; + _fgAnyUnmaterialized = true; + var block = Client.GetLoadedBlock((ushort)bx, (ushort)by); + if (block != null) + MaterializeBlock(block); + } + + if (_fgRadius > maxR) + { + if (_fgAnyUnmaterialized) + { + _fgRadius = 0; + _fgRingPos = 0; + _fgAnyUnmaterialized = false; + } + else + { + _materializationComplete = true; + UpdateLights(); + } + } } + private void BackgroundMaterializeStep() + { + if (_bgMaterializeDone || !_backgroundFillEnabled || !Client.Running) + return; + int w = Client.Width, h = Client.Height; + if (w == 0 || h == 0) + return; + + int cx = _bgCenterX, cy = _bgCenterY; + int maxR = Math.Max(Math.Max(cx, w - 1 - cx), Math.Max(cy, h - 1 - cy)); + + var scanned = 0; + while (CanMaterializeMore() && scanned < MaterializeScanCap && _bgRadius <= maxR) + { + scanned++; + SpiralBlock(cx, cy, _bgRadius, _bgRingPos, out var bx, out var by); + + var ringCount = _bgRadius == 0 ? 1 : 8 * _bgRadius; + if (++_bgRingPos >= ringCount) + { + _bgRingPos = 0; + _bgRadius++; + } + + if (bx >= 0 && bx < w && by >= 0 && by < h && + !_materializedBlocks.Contains(PackBlock(bx, by))) + { + var block = Client.GetLoadedBlock((ushort)bx, (ushort)by); + if (block != null) + MaterializeBlock(block); + } + } + + if (_bgRadius > maxR) + { + _bgRadius = 0; + _bgRingPos = 0; + if (_materializedBlocks.Count >= w * h) + { + _bgMaterializeDone = true; + UpdateLights(); + } + } + } + + private static void SpiralBlock(int cx, int cy, int r, int pos, out int bx, out int by) + { + if (r == 0) + { + bx = cx; by = cy; return; + } + int side = 2 * r; + if (pos < side) { bx = cx - r + pos; by = cy - r; } + else if (pos < 2 * side) { bx = cx + r; by = cy - r + (pos - side); } + else if (pos < 3 * side) { bx = cx + r - (pos - 2 * side); by = cy + r; } + else { bx = cx - r; by = cy + r - (pos - 3 * side); } + } + + public void EnsureRegionMaterialized(RectU16 region) + { + int bx1 = region.X1 / 8, by1 = region.Y1 / 8; + int bx2 = region.X2 / 8, by2 = region.Y2 / 8; + for (int bx = bx1; bx <= bx2; bx++) + { + for (int by = by1; by <= by2; by++) + { + if (_materializedBlocks.Contains(PackBlock(bx, by))) + continue; + var block = Client.GetLoadedBlock((ushort)bx, (ushort)by); + if (block != null) + MaterializeBlock(block); + } + } + } + + private static int PackBlock(int bx, int by) => (bx << 16) | by; + private void OnBlockUnloaded(Block block) { + _materializedBlocks.Remove(PackBlock(block.LandBlock.X, block.LandBlock.Y)); var tile = block.LandBlock.Tiles[0]; if (ViewRange.Contains(tile.X, tile.Y)) { @@ -236,6 +602,8 @@ private void OnBlockUnloaded(Block block) RemoveTiles(landTile.X, landTile.Y); } block.Disposed = true; + MarkSelectionBufferDirty(); + MarkCacheRegionsDirtyForBlock(block.LandBlock.X, block.LandBlock.Y); } private void OnLandTileReplaced(LandTile tile, ushort newId, sbyte newZ) @@ -245,9 +613,17 @@ private void OnLandTileReplaced(LandTile tile, ushort newId, sbyte newZ) { _ToRecalculate.Add(landTile); } + MarkSelectionBufferDirty(); + MarkCacheRegionsDirtyAtTile(tile.X, tile.Y); } public void OnLandTileElevated(LandTile tile, sbyte newZ) + { + RefreshLandTileNeighbors(tile); + MarkCacheRegionsDirtyAtTile(tile.X, tile.Y); + } + + public void RefreshLandTileNeighbors(LandTile tile) { for (int x = -2; x < 2; x++) { @@ -265,19 +641,35 @@ public void OnLandTileElevated(LandTile tile, sbyte newZ) } } } + MarkSelectionBufferDirty(); + } + + public void ClearGhosts() + { + foreach (var parent in GhostLandTiles.Keys) + { + parent.Reset(); + RefreshLandTileNeighbors(parent.LandTile); + } + GhostLandTiles.Clear(); + StaticsManager.ClearGhosts(); } private void HueStatic(StaticTile tile, ushort newHue) { StaticsManager.Get(tile)?.UpdateHue(newHue); + MarkSelectionBufferDirty(); + MarkStaticRegionDirtyAtTile(tile.X, tile.Y); } - + private void AfterStaticChanged(StaticTile tile) { foreach (var staticObject in StaticsManager.Get(tile.X, tile.Y)) { staticObject.UpdateDepthOffset(); } + MarkSelectionBufferDirty(); + MarkStaticRegionDirtyAtTile(tile.X, tile.Y); } private void AddTile(LandTile landTile) @@ -357,9 +749,11 @@ public Vector2 Position get => new(Camera.Position.X, Camera.Position.Y); set { - Camera.Position.X = value.X; - Camera.Position.Y = value.Y; - Client.InternalSetPos((ushort)(value.X / TILE_SIZE), (ushort)(value.Y / TILE_SIZE)); + var maxX = Client.WidthInTiles * TILE_SIZE; + var maxY = Client.HeightInTiles * TILE_SIZE; + Camera.Position.X = maxX > 0 ? Math.Clamp(value.X, 0, maxX) : value.X; + Camera.Position.Y = maxY > 0 ? Math.Clamp(value.Y, 0, maxY) : value.Y; + Client.InternalSetPos((ushort)(Camera.Position.X / TILE_SIZE), (ushort)(Camera.Position.Y / TILE_SIZE)); } } @@ -395,6 +789,145 @@ public static Vector2 ScreenToMapCoordinates(float x, float y) public VirtualLayerObject VirtualLayer = VirtualLayerObject.Instance; //Used for drawing public ImageOverlay ImageOverlay = new(); //Used for image overlay feature + private bool _selectionBufferDirty = true; + private long _lastCameraMotionFrame = long.MinValue; + private const int SelectionFullViewMaxTiles = 40000; + private const int SelectionWindowRadius = 64; + private int _lastSelectionMouseX = int.MinValue; + private int _lastSelectionMouseY = int.MinValue; + private bool _selectionCameraInitialized; + private Vector3 _lastSelectionCameraPosition; + private float _lastSelectionZoom; + private float _lastSelectionYaw; + private float _lastSelectionPitch; + private float _lastSelectionRoll; + private Rectangle _lastSelectionScreenSize; + private int _lastSelectionStateSignature; + private readonly FNAColor[] _selectionPixel = new FNAColor[1]; + private long _detailedObjectsCulled; + private long _frameCounter; + private const float LowZoomTerrainThreshold = 0.22f; + private const float LowZoomStaticThreshold = 0.35f; + private const float AnimatedStaticMinZoom = 0.2f; + private const float StaticCacheMinTextureSize = 30f; + + private const int RegionBlocks = 32; + private const int MaxRegionBuildsPerFrame = 64; + private const double RegionBuildBudgetMs = 6.0; + private const int RegionCacheKeepMargin = 1; + private const long EstimatedRegionCacheBytesPerBlock = 24 * 1024; + private const double RegionCacheKeepAllFraction = 0.25; + private static readonly int MapVertexSizeBytes = System.Runtime.CompilerServices.Unsafe.SizeOf(); + private bool _regionCacheEvictionEnabled; + private long _cachedRegionVertexBytes; + private long _totalAvailableMemoryBytes; + private int _regionsX, _regionsY; + private List?[] _terrainRegions = Array.Empty?>(); + private List?[] _staticRegions = Array.Empty?>(); + private bool[] _terrainRegionDirty = Array.Empty(); + private bool[] _staticRegionDirty = Array.Empty(); + private int _terrainCacheSignature; + private int _staticCacheSignature; + private bool _forceFullTerrainCache; + private bool _forceFullStaticCache; + private readonly List _visibleDirtyRegions = new(); + private int _terrainRegionsDrawn, _terrainRegionsBuilt, _staticRegionsDrawn, _staticRegionsBuilt; + + private readonly HashSet _materializedBlocks = new(); + private const double MaterializeMemoryFraction = 0.5; + private const int MaterializeKeepMarginBlocks = 16; + private const double ViewBudgetFraction = 0.6; + private long _materializeBudgetBlocks = long.MaxValue; + private bool _materializeEvictionEnabled; + private readonly List _blocksToDematerialize = new(); + private const int MaterializeScanCap = 8192; + private const double MaterializeBudgetMs = 70.0; + private const int MinMaterializeCap = 4; + private const int MaxMaterializeCap = 512; + private const int IdleFramesBeforeFastFill = 12; + private const double InteractiveTargetFrameMs = 14.0; + private const double IdleTargetFrameMs = 50.0; + private double _adaptiveMaterializeCap = MaxMaterializeCap; + private long _lastUpdateTimestamp; + private long _lastInteractionFrame = long.MinValue; + private double _frameMaterializeMs; + private int _frameMaterializeCount; + private RectU16 _lastMaterializeViewRange; + private bool _hasLastMaterializeViewRange; + private bool _materializationComplete; + private int _fgRadius; + private int _fgRingPos; + private int _fgCenterX; + private int _fgCenterY; + private bool _fgAnyUnmaterialized; + private bool _backgroundFillEnabled; + private int _bgRadius; + private int _bgRingPos; + private int _bgCenterX; + private int _bgCenterY; + private bool _bgMaterializeDone; + private long _cacheRateTimestamp; + private int _cacheRateLastCount; + private double _cacheBlocksPerSecond; + + private const long EstimatedBytesPerBlock = 48 * 1024; + private const double PreloadMemoryFraction = 0.5; + + public bool CacheInProgress => + Client.Running && _backgroundFillEnabled && !_bgMaterializeDone && Client.Width > 0; + public int CacheMaterializedBlocks => _materializedBlocks.Count; + public int CacheTotalBlocks => Client.Width * Client.Height; + public float CacheProgress + { + get + { + var total = CacheTotalBlocks; + return total > 0 ? Math.Clamp((float)_materializedBlocks.Count / total, 0f, 1f) : 0f; + } + } + public double CacheEtaSeconds + { + get + { + if (_cacheBlocksPerSecond <= 1.0) + return -1; + var remaining = CacheTotalBlocks - _materializedBlocks.Count; + return remaining <= 0 ? 0 : remaining / _cacheBlocksPerSecond; + } + } + + private void UpdateCacheRate() + { + var now = System.Diagnostics.Stopwatch.GetTimestamp(); + if (_cacheRateTimestamp == 0) + { + _cacheRateTimestamp = now; + _cacheRateLastCount = _materializedBlocks.Count; + return; + } + var elapsed = System.Diagnostics.Stopwatch.GetElapsedTime(_cacheRateTimestamp, now).TotalSeconds; + if (elapsed < 0.25) + return; + var delta = _materializedBlocks.Count - _cacheRateLastCount; + var instRate = delta / elapsed; + _cacheBlocksPerSecond = _cacheBlocksPerSecond <= 0 ? instRate : _cacheBlocksPerSecond * 0.7 + instRate * 0.3; + _cacheRateTimestamp = now; + _cacheRateLastCount = _materializedBlocks.Count; + } + + private sealed class CachedRenderBatch : IDisposable + { + public required Texture2D Texture { get; init; } + public required VertexBuffer VertexBuffer { get; init; } + public required int VertexCount { get; init; } + public required int PrimitiveCount { get; init; } + + public void Dispose() + { + VertexBuffer.Dispose(); + } + } + public void UpdateAllTiles() { foreach (var tile in LandTilesIdDictionary.Values) @@ -405,6 +938,8 @@ public void UpdateAllTiles() } } StaticsManager.UpdateAll(); + MarkTerrainCacheDirty(); + MarkStaticCacheDirty(); } public LandTile? GetLandTile(int x, int y) @@ -472,7 +1007,7 @@ public IEnumerable GetTiles(TileObject? t1, TileObject? t2, bool top } else { - var staticTiles = StaticsManager.Get(x, y).Where(CanDrawStatic); + var staticTiles = StaticsManager.Get(x, y).Where(so => CanDrawStatic(so, includeBuried: true)); if (topTilesOnly) { var topTile = staticTiles.LastOrDefault(); @@ -511,7 +1046,20 @@ public void Update(GameTime gameTime, bool isActive, bool processMouse, bool pro return; Metrics.Start("UpdateMap"); + _frameCounter++; + _frameMaterializeMs = 0; + _frameMaterializeCount = 0; var mouseState = Mouse.GetState(); + var movementKeys = _keymap.IsActionDown(Keymap.MoveLeft) || _keymap.IsActionDown(Keymap.MoveRight) || + _keymap.IsActionDown(Keymap.MoveUp) || _keymap.IsActionDown(Keymap.MoveDown); + if (mouseState.X != _prevMouseState.X || mouseState.Y != _prevMouseState.Y || + mouseState.LeftButton == ButtonState.Pressed || mouseState.RightButton == ButtonState.Pressed || + mouseState.MiddleButton == ButtonState.Pressed || + mouseState.ScrollWheelValue != _prevMouseState.ScrollWheelValue || movementKeys) + { + _lastInteractionFrame = _frameCounter; + } + AdaptMaterializeCap(); if (processMouse) { if (Client.Running) @@ -650,17 +1198,19 @@ public void Update(GameTime gameTime, bool isActive, bool processMouse, bool pro { if (_keymap.IsKeyPressed(Keys.Z)) { + ClearGhosts(); Client.Redo(); } } else if (_keymap.IsKeyPressed(Keys.Z)) { + ClearGhosts(); Client.Undo(); } if (_keymap.IsKeyPressed(Keys.R)) { - Reset(); + ReloadView(); } if (_keymap.IsKeyPressed(Keys.W)) { @@ -707,21 +1257,40 @@ public void Update(GameTime gameTime, bool isActive, bool processMouse, bool pro } } + EnforceZoomFloor(); Camera.Update(); + TrackSelectionInvalidation(); + var viewRangeChanged = false; if (Client.Running) { var newViewRange = CalculateViewRange(Camera); if (ViewRange != newViewRange) { + viewRangeChanged = true; ViewRange = newViewRange; - Client.RequestBlocks(ViewRange); + if (!_bgMaterializeDone) + Metrics.Measure("RequestBlocks", () => Client.RequestBlocks(ViewRange)); + MarkSelectionBufferDirty(); } } else { ViewRange = default; } - if (Client.Running && AnimatedStatics) + Metrics.SetCounter("ViewRangeChanged", viewRangeChanged ? 1 : 0); + if (Client.Running) + { + Metrics.Measure("Materialize", () => + { + UpdateMaterializedRegion(); + MaybeRecenterBackgroundFill(); + BackgroundMaterializeStep(); + }); + UpdateCacheRate(); + if (viewRangeChanged) + Metrics.Measure("DematerializeOutsideView", EvictMaterializedBlocksOutsideView); + } + if (Client.Running && AnimatedStatics && Camera.Zoom >= AnimatedStaticMinZoom) { _animatedStaticsManager.Process(gameTime); foreach (var animatedStaticTile in StaticsManager.AnimatedTiles) @@ -730,6 +1299,10 @@ public void Update(GameTime gameTime, bool isActive, bool processMouse, bool pro animatedStaticTile.Update(); } } + if (_ToRecalculate.Count > 0) + { + MarkSelectionBufferDirty(); + } foreach (var landObject in _ToRecalculate) { if (GhostLandTiles.TryGetValue(landObject, out var ghostLandObject)) @@ -748,15 +1321,47 @@ public void Reset() LandTiles = new LandObject[Client.Width * 8, Client.Height * 8]; LandTilesIdDictionary.Clear(); + ClearRegionCaches(); PrevSelected = null; Selected = null; RealSelected = null; GhostLandTiles.Clear(); StaticsManager.Clear(); ViewRange = default; + _materializedBlocks.Clear(); + _materializationComplete = false; + _hasLastMaterializeViewRange = false; + _bgRadius = 0; + _bgRingPos = 0; + (_bgCenterX, _bgCenterY) = CameraBlock(); + _bgMaterializeDone = false; + _backgroundFillEnabled = false; + MarkSelectionBufferDirty(); Client.ResetCache(); } + private const int ReloadViewMaxBlocks = 4096; + + public void ReloadView() + { + if (!Client.Running) + return; + int bx1 = ViewRange.X1 / 8, by1 = ViewRange.Y1 / 8; + int bx2 = ViewRange.X2 / 8, by2 = ViewRange.Y2 / 8; + if ((long)(bx2 - bx1 + 1) * (by2 - by1 + 1) > ReloadViewMaxBlocks) + return; + for (int bx = bx1; bx <= bx2; bx++) + { + for (int by = by1; by <= by2; by++) + { + var block = Client.GetLoadedBlock((ushort)bx, (ushort)by); + if (block != null) + MaterializeBlock(block); + } + } + MarkSelectionBufferDirty(); + } + public void UpdateLights() { foreach (var light in StaticsManager.LightTiles.Values) @@ -765,13 +1370,516 @@ public void UpdateLights() } } + private void MarkSelectionBufferDirty() + { + _selectionBufferDirty = true; + } + + private void MarkTerrainCacheDirty() + { + if (_terrainRegionDirty.Length > 0) + Array.Fill(_terrainRegionDirty, true); + } + + private void MarkStaticCacheDirty() + { + if (_staticRegionDirty.Length > 0) + Array.Fill(_staticRegionDirty, true); + } + + private void InitRegionCaches() + { + ClearRegionCaches(); + _regionsX = Math.Max(1, (Client.Width + RegionBlocks - 1) / RegionBlocks); + _regionsY = Math.Max(1, (Client.Height + RegionBlocks - 1) / RegionBlocks); + var n = _regionsX * _regionsY; + _terrainRegions = new List?[n]; + _staticRegions = new List?[n]; + _terrainRegionDirty = new bool[n]; + _staticRegionDirty = new bool[n]; + Array.Fill(_terrainRegionDirty, true); + Array.Fill(_staticRegionDirty, true); + _terrainCacheSignature = GetTerrainCacheSignature(); + _staticCacheSignature = GetStaticCacheSignature(); + } + + private void ClearRegionCaches() + { + foreach (var batches in _terrainRegions) + DisposeRegionBatches(batches); + foreach (var batches in _staticRegions) + DisposeRegionBatches(batches); + _terrainRegions = Array.Empty?>(); + _staticRegions = Array.Empty?>(); + _terrainRegionDirty = Array.Empty(); + _staticRegionDirty = Array.Empty(); + _regionsX = _regionsY = 0; + _cachedRegionVertexBytes = 0; + } + + private void DisposeRegionBatches(List? batches) + { + if (batches == null) + return; + foreach (var b in batches) + { + _cachedRegionVertexBytes -= (long)b.VertexCount * MapVertexSizeBytes; + b.Dispose(); + } + } + + private void EvictRegionCachesOutsideWindow( + List?[] regions, bool[] dirty, int rx1, int rx2, int ry1, int ry2) + { + if (!_regionCacheEvictionEnabled || _regionsX == 0) + return; + int kx1 = rx1 - RegionCacheKeepMargin, kx2 = rx2 + RegionCacheKeepMargin; + int ky1 = ry1 - RegionCacheKeepMargin, ky2 = ry2 + RegionCacheKeepMargin; + for (int idx = 0; idx < regions.Length; idx++) + { + if (regions[idx] == null) + continue; + int rx = idx / _regionsY, ry = idx % _regionsY; + if (rx >= kx1 && rx <= kx2 && ry >= ky1 && ry <= ky2) + continue; + DisposeRegionBatches(regions[idx]); + regions[idx] = null; + dirty[idx] = true; + } + } + + private int RegionIndex(int rx, int ry) => rx * _regionsY + ry; + + private (int rx, int ry) CameraRegion() + { + var (bx, by) = CameraBlock(); + return (Math.Clamp(bx / RegionBlocks, 0, Math.Max(0, _regionsX - 1)), + Math.Clamp(by / RegionBlocks, 0, Math.Max(0, _regionsY - 1))); + } + + private void MarkCacheRegionsDirtyForBlock(int bx, int by) + { + if (_terrainRegionDirty.Length == 0) + return; + for (int dbx = -1; dbx <= 1; dbx++) + { + for (int dby = -1; dby <= 1; dby++) + { + int nbx = bx + dbx, nby = by + dby; + if (nbx < 0 || nby < 0 || nbx >= Client.Width || nby >= Client.Height) + continue; + var idx = RegionIndex(nbx / RegionBlocks, nby / RegionBlocks); + _terrainRegionDirty[idx] = true; + _staticRegionDirty[idx] = true; + } + } + } + + private void MarkCacheRegionsDirtyAtTile(int tileX, int tileY) + { + MarkCacheRegionsDirtyForBlock(tileX / 8, tileY / 8); + } + + private void MarkStaticRegionDirtyAtTile(int tileX, int tileY) + { + if (_staticRegionDirty.Length == 0) + return; + int bx = tileX / 8, by = tileY / 8; + if (bx < 0 || by < 0 || bx >= Client.Width || by >= Client.Height) + return; + _staticRegionDirty[RegionIndex(bx / RegionBlocks, by / RegionBlocks)] = true; + } + + private int GetTerrainCacheSignature() + { + var hash = new HashCode(); + hash.Add(ShowLand); + hash.Add(ShowNoDraw); + hash.Add(FlatView); + hash.Add(MinZ); + hash.Add(MaxZ); + return hash.ToHashCode(); + } + + private int RegionDistance(int idx, int rx, int ry) + { + return Math.Max(Math.Abs(idx / _regionsY - rx), Math.Abs(idx % _regionsY - ry)); + } + + private void BuildTerrainRegion(int idx, int rx, int ry) + { + var batches = _terrainRegions[idx]; + if (batches == null) + _terrainRegions[idx] = batches = new List(); + else + { + DisposeRegionBatches(batches); + batches.Clear(); + } + _terrainRegionDirty[idx] = false; + if (!ShowLand || LandTiles == null) + return; + + var accum = new Dictionary>(); + int bx0 = rx * RegionBlocks, by0 = ry * RegionBlocks; + int bx1 = Math.Min(bx0 + RegionBlocks, Client.Width); + int by1 = Math.Min(by0 + RegionBlocks, Client.Height); + for (int bx = bx0; bx < bx1; bx++) + { + for (int by = by0; by < by1; by++) + { + if (!_materializedBlocks.Contains(PackBlock(bx, by))) + continue; + int minX = bx * 8, minY = by * 8; + for (int x = minX; x < minX + 8; x++) + { + for (int y = minY; y < minY + 8; y++) + { + var lo = LandTiles[x, y]; + if (lo == null || !lo.CanDraw || !CanDrawLand(lo)) + continue; + if (!accum.TryGetValue(lo.Texture, out var list)) + accum[lo.Texture] = list = new List(); + list.AddRange(lo.Vertices); + } + } + } + } + foreach (var (texture, vertices) in accum) + { + if (vertices.Count == 0) + continue; + var batch = BuildRenderBatch(texture, vertices); + _cachedRegionVertexBytes += (long)batch.VertexCount * MapVertexSizeBytes; + batches.Add(batch); + } + } + + private void DrawCachedTerrainRegions(RectU16 viewRange) + { + if (_regionsX == 0 || _terrainRegions.Length == 0) + return; + + var signature = GetTerrainCacheSignature(); + if (signature != _terrainCacheSignature) + { + _terrainCacheSignature = signature; + MarkTerrainCacheDirty(); + } + + int rx1 = Math.Clamp(viewRange.X1 / 8 / RegionBlocks - 1, 0, _regionsX - 1); + int rx2 = Math.Clamp(viewRange.X2 / 8 / RegionBlocks + 1, 0, _regionsX - 1); + int ry1 = Math.Clamp(viewRange.Y1 / 8 / RegionBlocks - 1, 0, _regionsY - 1); + int ry2 = Math.Clamp(viewRange.Y2 / 8 / RegionBlocks + 1, 0, _regionsY - 1); + + var (cbrx, cbry) = CameraRegion(); + _visibleDirtyRegions.Clear(); + for (int rx = rx1; rx <= rx2; rx++) + for (int ry = ry1; ry <= ry2; ry++) + if (_terrainRegionDirty[RegionIndex(rx, ry)]) + _visibleDirtyRegions.Add(RegionIndex(rx, ry)); + if (_visibleDirtyRegions.Count > 0) + { + _visibleDirtyRegions.Sort((a, b) => + RegionDistance(a, cbrx, cbry).CompareTo(RegionDistance(b, cbrx, cbry))); + var buildStart = System.Diagnostics.Stopwatch.GetTimestamp(); + int built = 0; + foreach (var idx in _visibleDirtyRegions) + { + if (!_forceFullTerrainCache && built > 0 && + (built >= MaxRegionBuildsPerFrame || + System.Diagnostics.Stopwatch.GetElapsedTime(buildStart).TotalMilliseconds >= RegionBuildBudgetMs)) + break; + BuildTerrainRegion(idx, idx / _regionsY, idx % _regionsY); + built++; + } + _terrainRegionsBuilt = built; + } + else + { + _terrainRegionsBuilt = 0; + } + + _mapRenderer.FlushPending(); + int drawn = 0; + for (int rx = rx1; rx <= rx2; rx++) + { + for (int ry = ry1; ry <= ry2; ry++) + { + var batches = _terrainRegions[RegionIndex(rx, ry)]; + if (batches == null) + continue; + foreach (var batch in batches) + _mapRenderer.DrawCachedVertices(batch.Texture, batch.VertexBuffer, batch.VertexCount, batch.PrimitiveCount); + drawn++; + } + } + _terrainRegionsDrawn = drawn; + EvictRegionCachesOutsideWindow(_terrainRegions, _terrainRegionDirty, rx1, rx2, ry1, ry2); + } + + private unsafe CachedRenderBatch BuildRenderBatch(Texture2D texture, List vertices) + { + var vertexBuffer = new VertexBuffer(_gfxDevice, typeof(MapVertex), vertices.Count, BufferUsage.WriteOnly); + var span = System.Runtime.InteropServices.CollectionsMarshal.AsSpan(vertices); + fixed (MapVertex* p = span) + { + vertexBuffer.SetDataPointerEXT(0, (IntPtr)p, + System.Runtime.CompilerServices.Unsafe.SizeOf() * vertices.Count, SetDataOptions.None); + } + + var tileCount = vertices.Count / 4; + _mapRenderer.EnsureQuadIndexCapacity(tileCount); + + return new CachedRenderBatch + { + Texture = texture, + VertexBuffer = vertexBuffer, + VertexCount = vertices.Count, + PrimitiveCount = tileCount * 2 + }; + } + + private int GetStaticCacheSignature() + { + var hash = new HashCode(); + hash.Add(ShowStatics); + hash.Add(ShowNoDraw); + hash.Add(FlatView); + hash.Add(MinZ); + hash.Add(MaxZ); + hash.Add(ObjectIdFilterEnabled); + hash.Add(ObjectIdFilterInclusive); + if (ObjectIdFilterEnabled) + foreach (var id in ObjectIdFilter) + hash.Add(id); + hash.Add(ObjectHueFilterEnabled); + hash.Add(ObjectHueFilterInclusive); + if (ObjectHueFilterEnabled) + foreach (var hue in ObjectHueFilter) + hash.Add(hue); + return hash.ToHashCode(); + } + + private bool ShouldDrawCachedStatics(Camera camera) + { + return ShowStatics && !WalkableSurfaces && camera.Zoom <= LowZoomStaticThreshold; + } + + private static bool IsStaticCacheable(StaticObject so) + { + return Math.Max(so.TextureBounds.Width, so.TextureBounds.Height) >= StaticCacheMinTextureSize; + } + + private void BuildStaticRegion(int idx, int rx, int ry) + { + var batches = _staticRegions[idx]; + if (batches == null) + _staticRegions[idx] = batches = new List(); + else + { + DisposeRegionBatches(batches); + batches.Clear(); + } + _staticRegionDirty[idx] = false; + if (!ShowStatics) + return; + + var accum = new Dictionary>(); + int bx0 = rx * RegionBlocks, by0 = ry * RegionBlocks; + int bx1 = Math.Min(bx0 + RegionBlocks, Client.Width); + int by1 = Math.Min(by0 + RegionBlocks, Client.Height); + for (int bx = bx0; bx < bx1; bx++) + { + for (int by = by0; by < by1; by++) + { + if (!_materializedBlocks.Contains(PackBlock(bx, by))) + continue; + int minX = bx * 8, minY = by * 8; + for (int x = minX; x < minX + 8; x++) + { + for (int y = minY; y < minY + 8; y++) + { + var statics = StaticsManager.GetRaw(x, y); + if (statics == null) + continue; + foreach (var so in statics) + { + if (so.IsAnimated || !IsStaticCacheable(so) || !so.CanDraw || !CanDrawStatic(so)) + continue; + if (!accum.TryGetValue(so.Texture, out var list)) + accum[so.Texture] = list = new List(); + list.AddRange(so.Vertices); + } + } + } + } + } + foreach (var (texture, vertices) in accum) + { + if (vertices.Count == 0) + continue; + var batch = BuildRenderBatch(texture, vertices); + _cachedRegionVertexBytes += (long)batch.VertexCount * MapVertexSizeBytes; + batches.Add(batch); + } + } + + private void DrawCachedStaticRegions(RectU16 viewRange) + { + if (_regionsX == 0 || _staticRegions.Length == 0) + return; + + var signature = GetStaticCacheSignature(); + if (signature != _staticCacheSignature) + { + _staticCacheSignature = signature; + MarkStaticCacheDirty(); + } + + int rx1 = Math.Clamp(viewRange.X1 / 8 / RegionBlocks - 1, 0, _regionsX - 1); + int rx2 = Math.Clamp(viewRange.X2 / 8 / RegionBlocks + 1, 0, _regionsX - 1); + int ry1 = Math.Clamp(viewRange.Y1 / 8 / RegionBlocks - 1, 0, _regionsY - 1); + int ry2 = Math.Clamp(viewRange.Y2 / 8 / RegionBlocks + 1, 0, _regionsY - 1); + + var (cbrx, cbry) = CameraRegion(); + _visibleDirtyRegions.Clear(); + for (int rx = rx1; rx <= rx2; rx++) + for (int ry = ry1; ry <= ry2; ry++) + if (_staticRegionDirty[RegionIndex(rx, ry)]) + _visibleDirtyRegions.Add(RegionIndex(rx, ry)); + if (_visibleDirtyRegions.Count > 0) + { + _visibleDirtyRegions.Sort((a, b) => + RegionDistance(a, cbrx, cbry).CompareTo(RegionDistance(b, cbrx, cbry))); + var buildStart = System.Diagnostics.Stopwatch.GetTimestamp(); + int built = 0; + foreach (var idx in _visibleDirtyRegions) + { + if (!_forceFullStaticCache && built > 0 && + (built >= MaxRegionBuildsPerFrame || + System.Diagnostics.Stopwatch.GetElapsedTime(buildStart).TotalMilliseconds >= RegionBuildBudgetMs)) + break; + BuildStaticRegion(idx, idx / _regionsY, idx % _regionsY); + built++; + } + _staticRegionsBuilt = built; + } + else + { + _staticRegionsBuilt = 0; + } + + _mapRenderer.FlushPending(); + int drawn = 0; + for (int rx = rx1; rx <= rx2; rx++) + { + for (int ry = ry1; ry <= ry2; ry++) + { + var batches = _staticRegions[RegionIndex(rx, ry)]; + if (batches == null) + continue; + foreach (var batch in batches) + _mapRenderer.DrawCachedVertices(batch.Texture, batch.VertexBuffer, batch.VertexCount, batch.PrimitiveCount); + drawn++; + } + } + _staticRegionsDrawn = drawn; + EvictRegionCachesOutsideWindow(_staticRegions, _staticRegionDirty, rx1, rx2, ry1, ry2); + } + + private void TrackSelectionInvalidation() + { + var stateSignature = GetSelectionStateSignature(); + var cameraMoved = !_selectionCameraInitialized || + Camera.Position != _lastSelectionCameraPosition || + Camera.Zoom != _lastSelectionZoom || + Camera.Yaw != _lastSelectionYaw || + Camera.Pitch != _lastSelectionPitch || + Camera.Roll != _lastSelectionRoll || + Camera.ScreenSize != _lastSelectionScreenSize; + var stateChanged = stateSignature != _lastSelectionStateSignature; + + if (cameraMoved || stateChanged) + { + MarkSelectionBufferDirty(); + if (cameraMoved) + _lastCameraMotionFrame = _frameCounter; + _selectionCameraInitialized = true; + _lastSelectionCameraPosition = Camera.Position; + _lastSelectionZoom = Camera.Zoom; + _lastSelectionYaw = Camera.Yaw; + _lastSelectionPitch = Camera.Pitch; + _lastSelectionRoll = Camera.Roll; + _lastSelectionScreenSize = Camera.ScreenSize; + _lastSelectionStateSignature = stateSignature; + } + } + + private int GetSelectionStateSignature() + { + var hash = new HashCode(); + hash.Add(ShowLand); + hash.Add(ShowStatics); + hash.Add(ShowNoDraw); + hash.Add(UseVirtualLayer); + hash.Add(VirtualLayerZ); + hash.Add(WalkableSurfaces); + hash.Add(FlatView); + hash.Add(MinZ); + hash.Add(MaxZ); + hash.Add(ObjectIdFilterEnabled); + hash.Add(ObjectIdFilterInclusive); + if (ObjectIdFilterEnabled) + { + foreach (var id in ObjectIdFilter) + { + hash.Add(id); + } + } + hash.Add(ObjectHueFilterEnabled); + hash.Add(ObjectHueFilterInclusive); + if (ObjectHueFilterEnabled) + { + foreach (var hue in ObjectHueFilter) + { + hash.Add(hue); + } + } + return hash.ToHashCode(); + } + private TileObject? PrevSelected; public TileObject? Selected { get; private set; } public TileObject? RealSelected { get; private set; } + private bool SelectionActive => Client.Running; + + private bool SelectionWindowed => (long)ViewRange.Width * ViewRange.Height > SelectionFullViewMaxTiles; + + // At low zoom the full view is too big to render every frame, so we only render the selection + // buffer for a bounded window around the cursor - enough to pick the tile under it. + private RectU16 SelectionRange() + { + if (!SelectionWindowed) + return ViewRange; + var world = Unproject(_prevMouseState.X, _prevMouseState.Y, 0); + int cx = Math.Clamp((int)world.X, ViewRange.X1, ViewRange.X2); + int cy = Math.Clamp((int)world.Y, ViewRange.Y1, ViewRange.Y2); + int x1 = Math.Max(ViewRange.X1, cx - SelectionWindowRadius); + int y1 = Math.Max(ViewRange.Y1, cy - SelectionWindowRadius); + int x2 = Math.Min(ViewRange.X2, cx + SelectionWindowRadius); + int y2 = Math.Min(ViewRange.Y2, cy + SelectionWindowRadius); + return new RectU16((ushort)x1, (ushort)y1, (ushort)x2, (ushort)y2); + } + private void UpdateMouseSelection(int x, int y) { - if (!_selectionBuffer.Bounds.Contains(x, y)) + if (!SelectionActive) + { + RealSelected = null; + } + else if (!_selectionBuffer.Bounds.Contains(x, y)) { RealSelected = null; } @@ -781,9 +1889,8 @@ private void UpdateMouseSelection(int x, int y) } else { - var pixels = new FNAColor[1]; - _selectionBuffer.GetData(0, new Microsoft.Xna.Framework.Rectangle(x, y, 1, 1), pixels, 0, 1); - var pixel = pixels[0]; + _selectionBuffer.GetData(0, new Microsoft.Xna.Framework.Rectangle(x, y, 1, 1), _selectionPixel, 0, 1); + var pixel = _selectionPixel[0]; var selectedIndex = pixel.R | (pixel.G << 8) | (pixel.B << 16); if (selectedIndex < 1) RealSelected = null; @@ -850,12 +1957,12 @@ public Vector3 Unproject(int x, int y, int z) private bool CanDrawLand(LandObject lo) { - if(!ShowLand || (lo.Tile.Id <= 2 && !ShowNoDraw)) + if(!ShowLand || (lo.Tile.Id <= 2 && !ShowNoDraw)) return false; return WithinZRange(lo.Tile.Z); } - public bool CanDrawStatic(StaticObject so) + public bool CanDrawStatic(StaticObject so, bool includeBuried = false) { var tile = so.StaticTile; var id = tile.Id; @@ -899,8 +2006,12 @@ public bool CanDrawStatic(StaticObject so) if (!ShowStatics) return false; + if (!WithinZRange(tile.Z)) + return false; + // Statics buried under raised terrain aren't drawn, but are still selectable for editing + // (so an area edit can elevate them back out from under the land). var landTile = LandTiles[tile.X, tile.Y]; - if (!WithinZRange(tile.Z) || !FlatView && landTile != null && CanDrawLand(landTile) && + if (!includeBuried && !FlatView && landTile != null && CanDrawLand(landTile) && WithinZRange(landTile.Tile.Z) && landTile.AverageZ() >= tile.PriorityZ + 5) return false; @@ -984,6 +2095,47 @@ private bool WithinZRange(short z) return z >= MinZ && z <= MaxZ; } + private bool ShouldClipDetailedObjects(Camera camera) + { + return camera.Zoom <= 0.5f; + } + + private bool ShouldDrawCachedTerrain(Camera camera, string technique) + { + return camera.Zoom <= LowZoomTerrainThreshold && technique == "Terrain" && !WalkableSurfaces; + } + + private bool IsInClipSpace(MapObject mapObject, Camera camera) + { + const float margin = 0.15f; + var allLeft = true; + var allRight = true; + var allAbove = true; + var allBelow = true; + foreach (var vertex in mapObject.Vertices) + { + var clip = Vector4.Transform(new Vector4(vertex.Position, 1f), camera.WorldViewProj); + var w = Math.Abs(clip.W); + if (w <= float.Epsilon) + { + w = 1f; + } + + allLeft &= clip.X < -w - margin; + allRight &= clip.X > w + margin; + allAbove &= clip.Y < -w - margin; + allBelow &= clip.Y > w + margin; + + if (!allLeft && !allRight && !allAbove && !allBelow) + { + return true; + } + } + + _detailedObjectsCulled++; + return false; + } + private bool DrawStatic(StaticObject so, Vector4 hueOverride = default) { if (!CanDrawStatic(so)) @@ -1006,13 +2158,17 @@ private void DrawLand(LandObject lo, Vector4 hueOverride = default) public void Draw() { + _mapRenderer.ResetFrameStats(); Metrics.Start("DrawMap"); if (!Client.Running || CEDGame.Closing) { DrawBackground(); return; } - Metrics.Measure("DrawSelection", DrawSelectionBuffer); + _detailedObjectsCulled = 0; + Metrics.SetCounter("ViewRangeTiles", ViewRange.Width * ViewRange.Height); + + Metrics.Measure("DrawSelection", DrawSelectionBufferIfNeeded); Metrics.Start("GetMouseSelection"); UpdateMouseSelection(_prevMouseState.X, _prevMouseState.Y); Metrics.Stop("GetMouseSelection"); @@ -1037,6 +2193,7 @@ public void Draw() Metrics.Measure("DrawImageOverlayAbove", () => DrawImageOverlay(true)); Metrics.Measure("ApplyLights", ApplyLights); Metrics.Measure("DrawVirtualLayer", DrawVirtualLayer); + RecordRendererStats(); Metrics.Stop("DrawMap"); } @@ -1049,6 +2206,45 @@ public void AfterDraw() } } + private void RecordRendererStats() + { + var stats = _mapRenderer.Stats; + Metrics.SetCounter("RendererDrawCalls", stats.DrawCalls); + Metrics.SetCounter("RendererCachedDrawCalls", stats.CachedDrawCalls); + Metrics.SetCounter("RendererFlushes", stats.Flushes); + Metrics.SetCounter("RendererTextureEvictions", stats.TextureEvictions); + Metrics.SetCounter("RendererVertexUploads", stats.VertexUploads); + Metrics.SetCounter("RendererVerticesUploaded", stats.VerticesUploaded); + Metrics.SetCounter("DetailedObjectsCulled", _detailedObjectsCulled); + Metrics.SetCounter("PendingBlockRequests", Client.PendingBlockRequests); + Metrics.SetCounter("ForegroundPendingBlockRequests", Client.ForegroundPendingBlockRequests); + Metrics.SetCounter("QueuedBlockRequests", Client.QueuedBlockRequests); + Metrics.SetCounter("ForegroundQueuedBlockRequests", Client.ForegroundQueuedBlockRequests); + Metrics.SetCounter("BackgroundQueuedBlockRequests", Client.BackgroundQueuedBlockRequests); + Metrics.SetCounter("LoadedBlockCount", Client.LoadedBlockCount); + Metrics.SetCounter("BlockCacheCapacity", Client.BlockCacheCapacity); + Metrics.SetCounter("BackgroundPreloadActive", Client.BackgroundPreloadActive ? 1 : 0); + Metrics.SetCounter("BackgroundPreloadRemaining", Client.BackgroundPreloadRemaining); + Metrics.SetCounter("MaterializedBlocks", _materializedBlocks.Count); + Metrics.SetCounter("MaterializationComplete", _materializationComplete ? 1 : 0); + Metrics.SetCounter("MaterializeCap", (long)_adaptiveMaterializeCap); + Metrics.SetCounter("CamTileX", (long)(Camera.Position.X / TILE_SIZE)); + Metrics.SetCounter("CamTileY", (long)(Camera.Position.Y / TILE_SIZE)); + Metrics.SetCounter("Zoomx1000", (long)(Camera.Zoom * 1000)); + Metrics.SetCounter("BgCenterBlockX", _bgCenterX); + Metrics.SetCounter("BgCenterBlockY", _bgCenterY); + Metrics.SetCounter("MapBlocksW", Client.Width); + Metrics.SetCounter("MapBlocksH", Client.Height); + Metrics.SetCounter("ManagedHeapMB", GC.GetTotalMemory(false) / (1024 * 1024)); + Metrics.SetCounter("ProcessWorkingSetMB", Environment.WorkingSet / (1024 * 1024)); + Metrics.SetCounter("AvailableMemoryMB", _totalAvailableMemoryBytes / (1024 * 1024)); + Metrics.SetCounter("RegionCacheMB", _cachedRegionVertexBytes / (1024 * 1024)); + Metrics.SetCounter("RegionCacheEviction", _regionCacheEvictionEnabled ? 1 : 0); + Metrics.SetCounter("MaterializeBudgetBlocks", _materializeBudgetBlocks == long.MaxValue ? -1 : _materializeBudgetBlocks); + Metrics.SetCounter("MaterializeEviction", _materializeEvictionEnabled ? 1 : 0); + Metrics.SetCounter("ZoomFloorx1000", (long)(ComputeMinZoom() * 1000)); + } + private void DrawBackground() { _mapRenderer.SetRenderTarget(null); @@ -1067,6 +2263,48 @@ private void DrawBackground() _spriteBatch.End(); } + private void DrawSelectionBufferIfNeeded() + { + if (DebugDrawSelectionBuffer) + { + DrawSelectionBuffer(); + Metrics.SetCounter("SelectionBufferRedrawn", 1); + return; + } + + if (!SelectionActive) + { + Metrics.SetCounter("SelectionBufferRedrawn", 0); + return; + } + + if (SelectionWindowed) + { + var mouseMoved = _prevMouseState.X != _lastSelectionMouseX || _prevMouseState.Y != _lastSelectionMouseY; + if (!mouseMoved && !_selectionBufferDirty) + { + Metrics.SetCounter("SelectionBufferRedrawn", 0); + return; + } + DrawSelectionBuffer(); + _lastSelectionMouseX = _prevMouseState.X; + _lastSelectionMouseY = _prevMouseState.Y; + _selectionBufferDirty = false; + Metrics.SetCounter("SelectionBufferRedrawn", 1); + return; + } + + if (!_selectionBufferDirty || _lastCameraMotionFrame == _frameCounter) + { + Metrics.SetCounter("SelectionBufferRedrawn", 0); + return; + } + + DrawSelectionBuffer(); + _selectionBufferDirty = false; + Metrics.SetCounter("SelectionBufferRedrawn", 1); + } + private void DrawSelectionBuffer() { MapEffect.WorldViewProj = Camera.FnaWorldViewProj; @@ -1080,21 +2318,32 @@ private void DrawSelectionBuffer() _DepthStencilState, BlendState.AlphaBlend ); - foreach (var (x,y) in ViewRange.Iterate()) + var range = SelectionRange(); + var clipDetailedObjects = ShouldClipDetailedObjects(Camera); + for (int x = range.X1; x <= range.X2; x++) { - var landTile = LandTiles[x, y]; - if (landTile != null) + for (int y = range.Y1; y <= range.Y2; y++) { - DrawLand(landTile, landTile.ObjectIdColor); + var landTile = LandTiles[x, y]; + if (landTile != null) + { + DrawLand(landTile, landTile.ObjectIdColor); + } } + } - var tiles = StaticsManager.Get(x, y); - if(tiles == null) continue; - foreach (var tile in tiles) + for (int x = range.X1; x <= range.X2; x++) + { + for (int y = range.Y1; y <= range.Y2; y++) { - if (tile.CanDraw) + var tiles = StaticsManager.GetRaw(x, y); + if (tiles == null) continue; + foreach (var tile in tiles) { - DrawStatic(tile, tile.ObjectIdColor); + if (tile.CanDraw && (!clipDetailedObjects || IsInClipSpace(tile, Camera))) + { + DrawStatic(tile, tile.ObjectIdColor); + } } } } @@ -1119,6 +2368,7 @@ private void DrawLights(Camera camera) DepthStencilState.None, BlendState.Additive ); + var clipDetailedObjects = ShouldClipDetailedObjects(camera); foreach (var kvp in StaticsManager.LightTiles) { var staticTile = kvp.Key; @@ -1127,7 +2377,10 @@ private void DrawLights(Camera camera) { if (CanDrawStatic(staticTile)) { - _mapRenderer.DrawMapObject(light, default); + if (!clipDetailedObjects || IsInClipSpace(light, camera)) + { + _mapRenderer.DrawMapObject(light, default); + } } } } @@ -1150,22 +2403,37 @@ private void DrawLand(Camera camera, RectU16 viewRange, string technique = "Terr _DepthStencilState, BlendState.AlphaBlend ); - - foreach (var (x,y) in viewRange.Iterate()) + if (ShouldDrawCachedTerrain(camera, technique)) { - var tile = LandTiles[x, y]; - if (tile != null && tile.CanDraw) + DrawCachedTerrainRegions(viewRange); + Metrics.SetCounter("TerrainRegionsDrawn", _terrainRegionsDrawn); + Metrics.SetCounter("TerrainRegionsBuilt", _terrainRegionsBuilt); + + foreach (var tile in GhostLandTiles.Values) { - var hueOverride = Vector4.Zero; - if (WalkableSurfaces && !UoFileManager.TileData.LandData[tile.LandTile.Id].IsWet) - { - hueOverride = IsWalkable(tile) ? WalkableHue : NonWalkableHue; + DrawLand(tile, GhostLandTilesHue); + } + _mapRenderer.End(); + return; + } + for (int x = viewRange.X1; x <= viewRange.X2; x++) + { + for (int y = viewRange.Y1; y <= viewRange.Y2; y++) + { + var tile = LandTiles[x, y]; + if (tile != null && tile.CanDraw) + { + var hueOverride = Vector4.Zero; + if (WalkableSurfaces && !UoFileManager.TileData.LandData[tile.LandTile.Id].IsWet) + { + hueOverride = IsWalkable(tile) ? WalkableHue : NonWalkableHue; + } + DrawLand(tile, hueOverride); } - DrawLand(tile, hueOverride); } } - + foreach (var tile in GhostLandTiles.Values) { DrawLand(tile, GhostLandTilesHue); @@ -1182,12 +2450,15 @@ private void DrawLandHeight() var font = _fontSystem.GetFont(18 * Camera.Zoom); var halfTile = TILE_SIZE * 0.5f * Camera.Zoom; _spriteBatch.Begin(); - foreach (var (x, y) in ViewRange.Iterate()) + for (int x = ViewRange.X1; x <= ViewRange.X2; x++) { - var tile = LandTiles[x, y]; - if (tile != null && tile.CanDraw) + for (int y = ViewRange.Y1; y <= ViewRange.Y2; y++) { - DrawTileHeight(tile, font, halfTile); + var tile = LandTiles[x, y]; + if (tile != null && tile.CanDraw) + { + DrawTileHeight(tile, font, halfTile); + } } } foreach (var tile in GhostLandTiles.Values) @@ -1230,20 +2501,44 @@ private void DrawStatics(Camera camera, RectU16 viewRange) _DepthStencilState, BlendState.AlphaBlend ); - foreach (var (x,y) in viewRange.Iterate()) + if (ShouldDrawCachedStatics(camera)) + { + DrawCachedStaticRegions(viewRange); + Metrics.SetCounter("StaticRegionsDrawn", _staticRegionsDrawn); + Metrics.SetCounter("StaticRegionsBuilt", _staticRegionsBuilt); + + foreach (var tile in StaticsManager.AnimatedTiles) + { + if (viewRange.Contains(tile.Tile.X, tile.Tile.Y)) + DrawStatic(tile); + } + + foreach (var tile in StaticsManager.GhostTiles) + { + DrawStatic(tile); + } + _mapRenderer.End(); + return; + } + + var clipDetailedObjects = ShouldClipDetailedObjects(camera); + for (int x = viewRange.X1; x <= viewRange.X2; x++) { - var tiles = StaticsManager.Get(x, y); - if(tiles == null) continue; - foreach (var tile in tiles) + for (int y = viewRange.Y1; y <= viewRange.Y2; y++) { - if (tile.CanDraw) + var tiles = StaticsManager.GetRaw(x, y); + if(tiles == null) continue; + foreach (var tile in tiles) { - var hueOverride = Vector4.Zero; - if (WalkableSurfaces && UoFileManager.TileData.StaticData[tile.Tile.Id].IsSurface) + if (tile.CanDraw && (!clipDetailedObjects || IsInClipSpace(tile, camera))) { - hueOverride = IsWalkable(tile) ? WalkableHue : NonWalkableHue; + var hueOverride = Vector4.Zero; + if (WalkableSurfaces && UoFileManager.TileData.StaticData[tile.Tile.Id].IsSurface) + { + hueOverride = IsWalkable(tile) ? WalkableHue : NonWalkableHue; + } + DrawStatic(tile, hueOverride); } - DrawStatic(tile, hueOverride); } } } @@ -1347,20 +2642,26 @@ public void ExportImage() var cameraBounds = CalculateViewRange(myCamera); Client.RequestBlocks(cameraBounds); - while(Client.WaitingForBlocks) + while(Client.WaitingForBlocks) Client.Update(); - + + EnsureRegionMaterialized(cameraBounds); + foreach (var landObject in _ToRecalculate) { landObject.Update(); } _ToRecalculate.Clear(); - + MapEffect.WorldViewProj = myCamera.FnaWorldViewProj; DrawLights(myCamera); _mapRenderer.SetRenderTarget(myRenderTarget, new FNARectangle(0,0, ExportWidth, ExportHeight)); + _forceFullTerrainCache = myCamera.Zoom <= LowZoomTerrainThreshold; DrawLand(myCamera, cameraBounds); + _forceFullTerrainCache = false; + _forceFullStaticCache = ShouldDrawCachedStatics(myCamera); DrawStatics(myCamera, cameraBounds); + _forceFullStaticCache = false; ApplyLights(); using var fs = new FileStream(ExportPath, FileMode.OpenOrCreate); if(ExportPath.EndsWith(".png")) @@ -1405,5 +2706,6 @@ public void OnWindowsResized(GameWindow window) SurfaceFormat.Color, DepthFormat.None ); + MarkSelectionBufferDirty(); } -} \ No newline at end of file +} diff --git a/CentrED/Map/RadarMap.cs b/CentrED/Map/RadarMap.cs index e7da6edf..2dda555e 100644 --- a/CentrED/Map/RadarMap.cs +++ b/CentrED/Map/RadarMap.cs @@ -13,6 +13,7 @@ public class RadarMap private Texture2D _texture = null!; public Texture2D Texture => _texture; + public bool IsReady => _texture != null; private RadarMap(GraphicsDevice gd) { @@ -36,17 +37,24 @@ private unsafe void RadarData(ReadOnlySpan data) var width = CEDClient.Width; var height = CEDClient.Height; uint[] buffer = System.Buffers.ArrayPool.Shared.Rent(data.Length); - for (ushort x = 0; x < width; x++) + try { - for (ushort y = 0; y < height; y++) + for (ushort x = 0; x < width; x++) { - buffer[y * width + x] = HuesHelper.Color16To32(data[x * height + y]) | 0xFF_00_00_00; + for (ushort y = 0; y < height; y++) + { + buffer[y * width + x] = HuesHelper.Color16To32(data[x * height + y]) | 0xFF_00_00_00; + } } - } - fixed (uint* ptr = buffer) + fixed (uint* ptr = buffer) + { + _texture.SetDataPointerEXT(0, null, (IntPtr)ptr, data.Length * sizeof(uint)); + } + } + finally { - _texture.SetDataPointerEXT(0, null, (IntPtr)ptr, data.Length * sizeof(uint)); + System.Buffers.ArrayPool.Shared.Return(buffer); } } @@ -54,4 +62,4 @@ private void RadarUpdate(ushort x, ushort y, ushort color) { _texture.SetData(0, new Rectangle(x, y, 1, 1), new[] { HuesHelper.Color16To32(color) | 0xFF_00_00_00 }, 0, 1); } -} \ No newline at end of file +} diff --git a/CentrED/Map/StaticsManager.cs b/CentrED/Map/StaticsManager.cs index bc7092bd..526e8f42 100644 --- a/CentrED/Map/StaticsManager.cs +++ b/CentrED/Map/StaticsManager.cs @@ -1,11 +1,12 @@ -using System.Collections.ObjectModel; +using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; namespace CentrED.Map; public class StaticsManager { - private static readonly ReadOnlyCollection EMPTY = []; + private static readonly List EmptyList = []; + private static readonly ReadOnlyCollection EmptyReadOnly = EmptyList.AsReadOnly(); private ushort _Width; private ushort _Height; @@ -14,6 +15,7 @@ public class StaticsManager private List?[] _tiles; private Dictionary _idDictionary = new(); + public IEnumerable AllObjects => _idDictionary.Values; private List _animatedTiles = []; public IReadOnlyList AnimatedTiles => _animatedTiles.AsReadOnly(); @@ -62,10 +64,17 @@ public ReadOnlyCollection Get(int x, int y) public ReadOnlyCollection Get(ushort x, ushort y) { - if (x > _Width || y > _Height) - return EMPTY; + if (x >= _Width || y >= _Height) + return EmptyReadOnly; var list = _tiles[Index(x, y)]; - return list?.AsReadOnly() ?? EMPTY; + return list?.AsReadOnly() ?? EmptyReadOnly; + } + + public List? GetRaw(int x, int y) + { + if ((uint)x >= _Width || (uint)y >= _Height) + return null; + return _tiles[Index((ushort)x, (ushort)y)]; } public StaticObject? Get(StaticTile staticTile) @@ -99,6 +108,41 @@ public void Add(StaticTile staticTile) _lightTiles.Add(so, new LightObject(so)); } } + + public void AddRange(IEnumerable staticTiles) + { + HashSet? touched = null; + foreach (var staticTile in staticTiles) + { + var so = new StaticObject(staticTile); + var index = Index(staticTile); + var list = _tiles[index]; + if (list == null) + { + list = []; + _tiles[index] = list; + } + list.Add(so); + (touched ??= []).Add(index); + _idDictionary.Add(so.ObjectId, so); + Count++; + if (so.IsAnimated) + { + _animatedTiles.Add(so); + } + if (so.IsLight) + { + _lightTiles.Add(so, new LightObject(so)); + } + } + if (touched != null) + { + foreach (var index in touched) + { + _tiles[index]?.Sort(); + } + } + } public void Remove(StaticTile staticTile) { @@ -206,7 +250,14 @@ public void ClearGhost(TileObject parent) { _ghostTiles.Remove(parent); } - + + public void ClearGhosts() + { + foreach (var parent in _ghostTiles.Keys) + parent.Reset(); + _ghostTiles.Clear(); + } + private int Index(StaticTile tile) => tile.X * _Height + tile.Y; private int Index(ushort x, ushort y) => x * _Height + y; -} \ No newline at end of file +} diff --git a/CentrED/Renderer/MapRenderer.cs b/CentrED/Renderer/MapRenderer.cs index e31b0f53..2998a60b 100644 --- a/CentrED/Renderer/MapRenderer.cs +++ b/CentrED/Renderer/MapRenderer.cs @@ -50,14 +50,35 @@ public MapVertex(Vector3 position, Vector3 texture, Vector4 hue, Vector3 normal) public class MapRenderer { + public readonly struct FrameStats + { + public FrameStats(int flushes, int drawCalls, int cachedDrawCalls, int textureEvictions, int vertexUploads, long verticesUploaded) + { + Flushes = flushes; + DrawCalls = drawCalls; + CachedDrawCalls = cachedDrawCalls; + TextureEvictions = textureEvictions; + VertexUploads = vertexUploads; + VerticesUploaded = verticesUploaded; + } + + public int Flushes { get; } + public int DrawCalls { get; } + public int CachedDrawCalls { get; } + public int TextureEvictions { get; } + public int VertexUploads { get; } + public long VerticesUploaded { get; } + } + #region Draw Batcher private class DrawBatcher { - private const int MAX_TILES_PER_BATCH = 4096; + private const int MAX_TILES_PER_BATCH = 8192; private const int MAX_VERTICES = MAX_TILES_PER_BATCH * 4; private const int MAX_INDICES = MAX_TILES_PER_BATCH * 6; + private readonly MapRenderer _owner; private readonly GraphicsDevice _gfxDevice; private readonly VertexBuffer _vertexBuffer; @@ -91,9 +112,11 @@ private static short[] GenerateIndexArray() private bool _beginCalled = false; private int _vertexCount = 0; + public int PendingVertexCount => _vertexCount; - public DrawBatcher(GraphicsDevice device) + public DrawBatcher(MapRenderer owner, GraphicsDevice device) { + _owner = owner; _gfxDevice = device; _vertexInfo = new MapVertex[MAX_VERTICES]; @@ -139,6 +162,9 @@ private unsafe void Flush() _vertexBuffer.SetDataPointerEXT (0, (IntPtr)p, Unsafe.SizeOf() * _vertexCount, SetDataOptions.Discard); } + _owner._flushes++; + _owner._vertexUploads++; + _owner._verticesUploaded += _vertexCount; _gfxDevice.SetVertexBuffer(_vertexBuffer); _gfxDevice.Indices = _indexBuffer; @@ -157,6 +183,7 @@ private unsafe void Flush() { pass.Apply(); _gfxDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, _vertexCount, 0, _vertexCount / 2); + _owner._drawCalls++; } _vertexCount = 0; @@ -170,12 +197,17 @@ public void End() public void DrawMapObject(MapObject o, Vector4 hueOverride) { - if (_vertexCount + o.Vertices.Length >= MAX_VERTICES) + DrawVertices(o.Vertices, o.Vertices.Length, hueOverride); + } + + public void DrawVertices(MapVertex[] vertices, int vertexCount, Vector4 hueOverride) + { + if (_vertexCount + vertexCount >= MAX_VERTICES) Flush(); - for (var i = 0; i < o.Vertices.Length; i++) + for (var i = 0; i < vertexCount; i++) { - _vertexInfo[_vertexCount] = o.Vertices[i]; + _vertexInfo[_vertexCount] = vertices[i]; if (hueOverride != default) { _vertexInfo[_vertexCount].Hue = hueOverride; @@ -190,14 +222,34 @@ public void DrawMapObject(MapObject o, Vector4 hueOverride) private readonly GraphicsDevice _gfxDevice; private readonly GameWindow _window; - private readonly DrawBatcher[] _batchers = new DrawBatcher[8]; - private readonly Texture2D[] _textures = new Texture2D[8]; + private readonly DrawBatcher[] _batchers = new DrawBatcher[32]; + private readonly Texture2D[] _textures = new Texture2D[32]; + private readonly long[] _batcherLastUsed = new long[32]; + private long _batcherUseCounter; private MapEffect _effect; private RasterizerState _rasterizerState; private SamplerState _samplerState; private DepthStencilState _depthStencilState; private BlendState _blendState; + private int _flushes; + private int _drawCalls; + private int _cachedDrawCalls; + private int _textureEvictions; + private int _vertexUploads; + private long _verticesUploaded; + + public FrameStats Stats => new(_flushes, _drawCalls, _cachedDrawCalls, _textureEvictions, _vertexUploads, _verticesUploaded); + + public void ResetFrameStats() + { + _flushes = 0; + _drawCalls = 0; + _cachedDrawCalls = 0; + _textureEvictions = 0; + _vertexUploads = 0; + _verticesUploaded = 0; + } private DrawBatcher GetBatcher(Texture2D texture) { @@ -205,6 +257,7 @@ private DrawBatcher GetBatcher(Texture2D texture) { if (_textures[i] == texture) { + _batcherLastUsed[i] = ++_batcherUseCounter; return _batchers[i]; } } @@ -214,6 +267,7 @@ private DrawBatcher GetBatcher(Texture2D texture) if (_textures[i] == null) { _textures[i] = texture; + _batcherLastUsed[i] = ++_batcherUseCounter; _batchers[i].Begin ( _effect, @@ -227,10 +281,12 @@ private DrawBatcher GetBatcher(Texture2D texture) } } - /* TODO: Don't always evict the first one */ - _batchers[0].End(); - _textures[0] = texture; - _batchers[0].Begin + var evictIndex = GetEvictionIndex(); + _textureEvictions++; + _batchers[evictIndex].End(); + _textures[evictIndex] = texture; + _batcherLastUsed[evictIndex] = ++_batcherUseCounter; + _batchers[evictIndex].Begin ( _effect, texture, @@ -239,7 +295,28 @@ private DrawBatcher GetBatcher(Texture2D texture) _depthStencilState, _blendState ); - return _batchers[0]; + return _batchers[evictIndex]; + } + + private int GetEvictionIndex() + { + var bestIndex = 0; + var bestVertexCount = _batchers[0].PendingVertexCount; + var bestLastUsed = _batcherLastUsed[0]; + + for (int i = 1; i < _batchers.Length; i++) + { + var vertexCount = _batchers[i].PendingVertexCount; + var lastUsed = _batcherLastUsed[i]; + if (vertexCount < bestVertexCount || vertexCount == bestVertexCount && lastUsed < bestLastUsed) + { + bestIndex = i; + bestVertexCount = vertexCount; + bestLastUsed = lastUsed; + } + } + + return bestIndex; } private bool _beginCalled = false; @@ -251,7 +328,7 @@ public MapRenderer(GraphicsDevice device, GameWindow window) for (int i = 0; i < _batchers.Length; i++) { - _batchers[i] = new DrawBatcher(device); + _batchers[i] = new DrawBatcher(this, device); } } @@ -281,6 +358,7 @@ BlendState blendState for (int i = 0; i < _batchers.Length; i++) { _textures[i] = null; + _batcherLastUsed[i] = 0; } } @@ -306,6 +384,11 @@ private unsafe void Flush() } } + public void FlushPending() + { + Flush(); + } + public unsafe void End() { Flush(); @@ -318,4 +401,52 @@ public void DrawMapObject(MapObject mapObject, Vector4 hueOverride) var batcher = GetBatcher(mapObject.Texture); batcher.DrawMapObject(mapObject, hueOverride); } -} \ No newline at end of file + + private IndexBuffer _quadIndexBuffer; + private int _quadIndexCapacityQuads; + + public void EnsureQuadIndexCapacity(int quads) + { + if (_quadIndexBuffer != null && quads <= _quadIndexCapacityQuads) + return; + int newCap = Math.Max(quads, Math.Max(2048, _quadIndexCapacityQuads * 2)); + _quadIndexBuffer?.Dispose(); + var indices = new int[newCap * 6]; + for (int q = 0, i = 0, v = 0; q < newCap; q++, v += 4) + { + indices[i++] = v; + indices[i++] = v + 1; + indices[i++] = v + 2; + indices[i++] = v + 3; + indices[i++] = v + 2; + indices[i++] = v + 1; + } + _quadIndexBuffer = new IndexBuffer(_gfxDevice, IndexElementSize.ThirtyTwoBits, indices.Length, BufferUsage.WriteOnly); + _quadIndexBuffer.SetData(indices); + _quadIndexCapacityQuads = newCap; + } + + public void DrawCachedVertices(Texture2D texture, VertexBuffer vertexBuffer, int vertexCount, int primitiveCount) + { + _gfxDevice.SetVertexBuffer(vertexBuffer); + _gfxDevice.Indices = _quadIndexBuffer; + + _gfxDevice.RasterizerState = _rasterizerState; + _gfxDevice.Textures[0] = texture; + _gfxDevice.SamplerStates[0] = _samplerState; + _gfxDevice.Textures[1] = HuesManager.Instance.Texture; + _gfxDevice.SamplerStates[1] = SamplerState.PointClamp; + _gfxDevice.Textures[2] = LightsManager.Instance.LightColorsTexture; + _gfxDevice.SamplerStates[2] = SamplerState.PointClamp; + _gfxDevice.DepthStencilState = _depthStencilState; + _gfxDevice.BlendState = _blendState; + + foreach (EffectPass pass in _effect.CurrentTechnique.Passes) + { + pass.Apply(); + _gfxDevice.DrawIndexedPrimitives(PrimitiveType.TriangleList, 0, 0, vertexCount, 0, primitiveCount); + _drawCalls++; + _cachedDrawCalls++; + } + } +} diff --git a/CentrED/Tools/AltitudeGradientTool.cs b/CentrED/Tools/AltitudeGradientTool.cs index 672c907c..26409201 100644 --- a/CentrED/Tools/AltitudeGradientTool.cs +++ b/CentrED/Tools/AltitudeGradientTool.cs @@ -299,7 +299,7 @@ private void ClearGhosts() { lo.Reset(); MapManager.GhostLandTiles.Remove(lo); - MapManager.OnLandTileElevated(lo.LandTile, lo.LandTile.Z); + MapManager.RefreshLandTileNeighbors(lo.LandTile); } _ghostedTiles.Clear(); @@ -485,7 +485,7 @@ private void CreateGhostTile(LandObject lo, sbyte newZ) var newTile = new LandTile(lo.LandTile.Id, lo.Tile.X, lo.Tile.Y, newZ); var ghostTile = new LandObject(newTile); MapManager.GhostLandTiles[lo] = ghostTile; - MapManager.OnLandTileElevated(ghostTile.LandTile, ghostTile.LandTile.Z); + MapManager.RefreshLandTileNeighbors(ghostTile.LandTile); _ghostedTiles.Add(lo); } diff --git a/CentrED/Tools/BaseTool.cs b/CentrED/Tools/BaseTool.cs index 07bf6808..5c895795 100644 --- a/CentrED/Tools/BaseTool.cs +++ b/CentrED/Tools/BaseTool.cs @@ -64,6 +64,7 @@ public override void OnDeactivated(TileObject? o) Pressed = false; AreaMode = false; TopTilesOnly = false; + MapManager.ClearGhosts(); } public sealed override void OnKeyPressed(Keys key) @@ -80,7 +81,7 @@ public sealed override void OnKeyPressed(Keys key) } } } - + public sealed override void OnKeyReleased(Keys key) { if (!Pressed) @@ -112,9 +113,9 @@ public sealed override void OnMouseReleased(TileObject? o) { if (AreaMode) { - foreach (var to in MapManager.GetTiles(AreaStartTile, o, TopTilesOnly)) + foreach (var to in MapManager.GetTiles(AreaStartTile, o, TopTilesOnly).ToList()) { - InternalApply(to); + InternalApply(to); GhostClear(to); } OnAreaOperationEnd(); @@ -126,8 +127,9 @@ public sealed override void OnMouseReleased(TileObject? o) } } Pressed = false; - + CEDClient.EndUndoGroup(); + MapManager.ClearGhosts(); } diff --git a/CentrED/Tools/CoastlineTool.cs b/CentrED/Tools/CoastlineTool.cs index be938d50..140cd5c8 100644 --- a/CentrED/Tools/CoastlineTool.cs +++ b/CentrED/Tools/CoastlineTool.cs @@ -22,6 +22,8 @@ public class CoastlineTool : BaseTool private bool _drawBrownShoreDepth = false; private string _customBottomTilesText = ""; + private readonly Dictionary> _brownShoreGhosts = new(); + private List _transitionTiles = new(); private List _terrainBottomTiles = []; private List _customTerrainBottomTiles = []; @@ -168,12 +170,18 @@ protected override void GhostApply(TileObject? o) { ushort brownShoreTileId = tilesToDraw[Random.Shared.Next(tilesToDraw.Count)]; sbyte brownShoreZ = (sbyte)(_waterZ - 10); - + var waterTile = waterLandObject.Tile; waterLandObject.Visible = false; var newTile = new LandTile(brownShoreTileId, waterTile.X, waterTile.Y, brownShoreZ); MapManager.GhostLandTiles[waterLandObject] = new LandObject(newTile); - MapManager.OnLandTileElevated(newTile, newTile.Z); + MapManager.RefreshLandTileNeighbors(newTile); + if (!_brownShoreGhosts.TryGetValue(o, out var shoreList)) + { + shoreList = new List(); + _brownShoreGhosts[o] = shoreList; + } + shoreList.Add(waterLandObject); } } } @@ -224,7 +232,7 @@ protected override void GhostApply(TileObject? o) selectedTile.Visible = false; var newTile = new LandTile(tile.Id, tile.X, tile.Y, newLandZ); MapManager.GhostLandTiles[selectedTile] = new LandObject(newTile); - MapManager.OnLandTileElevated(newTile, newTile.Z); + MapManager.RefreshLandTileNeighbors(newTile); } if (contextDirection.Contains(Direction.Up) || _sideUpEdge.Any(e => e == contextDirection || e == selectedDirection) ) @@ -288,7 +296,17 @@ protected override void GhostClear(TileObject? o) { landTile.Reset(); MapManager.GhostLandTiles.Remove(landTile); - MapManager.OnLandTileElevated(landTile.LandTile, landTile.LandTile.Z); + MapManager.RefreshLandTileNeighbors(landTile.LandTile); + } + if (_brownShoreGhosts.TryGetValue(o, out var shoreGhosts)) + { + foreach (var waterLo in shoreGhosts) + { + waterLo.Reset(); + MapManager.GhostLandTiles.Remove(waterLo); + MapManager.RefreshLandTileNeighbors(waterLo.LandTile); + } + _brownShoreGhosts.Remove(o); } } diff --git a/CentrED/Tools/ElevateTool.cs b/CentrED/Tools/ElevateTool.cs index 14879aaf..57b40dee 100644 --- a/CentrED/Tools/ElevateTool.cs +++ b/CentrED/Tools/ElevateTool.cs @@ -102,7 +102,7 @@ protected override void GhostApply(TileObject? o) lo.Visible = false; var newTile = new LandTile(tile.Id, tile.X, tile.Y, NewZ(tile)); MapManager.GhostLandTiles[lo] = new LandObject(newTile); - MapManager.OnLandTileElevated(newTile, newTile.Z); + MapManager.RefreshLandTileNeighbors(newTile); } } @@ -116,7 +116,7 @@ protected override void GhostClear(TileObject? o) else if (o is LandObject lo) { MapManager.GhostLandTiles.Remove(lo); - MapManager.OnLandTileElevated(lo.LandTile, lo.LandTile.Z); + MapManager.RefreshLandTileNeighbors(lo.LandTile); } } diff --git a/CentrED/Tools/MeshEditTool.cs b/CentrED/Tools/MeshEditTool.cs index ef1f4438..a06b3cc9 100644 --- a/CentrED/Tools/MeshEditTool.cs +++ b/CentrED/Tools/MeshEditTool.cs @@ -623,7 +623,7 @@ protected override void GhostApply(TileObject? o) // Store the ghost tile MapManager.GhostLandTiles[lo] = ghostTile; - MapManager.OnLandTileElevated(ghostTile.LandTile, ghostTile.LandTile.Z); + MapManager.RefreshLandTileNeighbors(ghostTile.LandTile); _ghostedTiles.Add(lo); } } @@ -636,7 +636,7 @@ protected override void GhostClear(TileObject? o) { lo.Reset(); MapManager.GhostLandTiles.Remove(lo); - MapManager.OnLandTileElevated(lo.LandTile, lo.LandTile.Z); + MapManager.RefreshLandTileNeighbors(lo.LandTile); } _ghostedTiles.Clear(); } diff --git a/CentrED/UI/UIManager.cs b/CentrED/UI/UIManager.cs index 7e221033..001bc0eb 100644 --- a/CentrED/UI/UIManager.cs +++ b/CentrED/UI/UIManager.cs @@ -299,6 +299,7 @@ protected virtual void DrawUI() DrawContextMenu(); DrawMainMenu(); DrawStatusBar(); + DrawCachingOverlay(); foreach (var window in AllWindows.Values) { window.Draw(); @@ -311,7 +312,41 @@ protected virtual void DrawUI() } ImGui.PopFont(); } - + + private void DrawCachingOverlay() + { + var mapManager = CEDGame.MapManager; + if (mapManager == null || !mapManager.CacheInProgress) + return; + + var viewport = ImGui.GetMainViewport(); + var size = new Vector2(380, 0); + var pos = new Vector2( + viewport.WorkPos.X + (viewport.WorkSize.X - size.X) * 0.5f, + viewport.WorkPos.Y + viewport.WorkSize.Y - 90); + ImGui.SetNextWindowPos(pos, ImGuiCond.Always); + ImGui.SetNextWindowSize(size, ImGuiCond.Always); + ImGui.SetNextWindowBgAlpha(0.75f); + + var flags = ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.NoMove | + ImGuiWindowFlags.NoSavedSettings | ImGuiWindowFlags.NoFocusOnAppearing | + ImGuiWindowFlags.NoNav | ImGuiWindowFlags.NoInputs; + if (ImGui.Begin("##CachingOverlay", flags)) + { + var progress = mapManager.CacheProgress; + var eta = mapManager.CacheEtaSeconds; + string etaStr = eta < 0 + ? "estimating time remaining..." + : eta >= 90 + ? $"~{eta / 60:F0} min remaining" + : $"~{eta:F0}s remaining"; + + ImGui.Text($"Caching map... {progress * 100:F0}% ({mapManager.CacheMaterializedBlocks:N0}/{mapManager.CacheTotalBlocks:N0} blocks)"); + ImGui.ProgressBar(progress, new Vector2(-1, 0), etaStr); + } + ImGui.End(); + } + private void DrawContextMenu() { var selected = contextMenuTile; @@ -376,10 +411,12 @@ private void DrawMainMenu() { if (ImGui.MenuItem(LangManager.Get(UNDO), "Ctrl+Z", false, CEDClient.CanUndo)) { + CEDGame.MapManager.ClearGhosts(); CEDClient.Undo(); } if (ImGui.MenuItem(LangManager.Get(REDO), "Ctrl+Shift+Z", false, CEDClient.CanRedo)) { + CEDGame.MapManager.ClearGhosts(); CEDClient.Redo(); } ImGui.EndMenu(); @@ -420,7 +457,7 @@ private void DrawMainMenu() } if (ImGui.MenuItem(LangManager.Get(CLEAR_CACHE), "CTRL+R")) { - CEDGame.MapManager.Reset(); + CEDGame.MapManager.ReloadView(); } //Credits //About @@ -463,7 +500,7 @@ private void DrawStatusBar() ImGui.Text($"Area: {bt.Area.Width}x{bt.Area.Height}"); ImGui.SameLine(); } - var rightAligned = $"X: {mapManager.TilePosition.X} Y: {mapManager.TilePosition.Y} Zoom: {mapManager.Camera.Zoom:F1} | FPS: {ImGui.GetIO().Framerate:F1}"; + var rightAligned = $"X: {mapManager.TilePosition.X} Y: {mapManager.TilePosition.Y} Zoom: {mapManager.Camera.Zoom:0.0#} | FPS: {ImGui.GetIO().Framerate:F1}"; ImGui.SetCursorPosX(ImGui.GetWindowWidth() - ImGui.CalcTextSize(rightAligned).X - ImGui.GetStyle().WindowPadding.X); ImGui.Text(rightAligned); CEDGame.UIManager.AddCurrentWindowRect(); diff --git a/CentrED/UI/Windows/DebugWindow.cs b/CentrED/UI/Windows/DebugWindow.cs index b1d3becf..74f533a6 100644 --- a/CentrED/UI/Windows/DebugWindow.cs +++ b/CentrED/UI/Windows/DebugWindow.cs @@ -13,6 +13,9 @@ public class DebugWindow : Window private int _gotoX; private int _gotoY; + private bool _profiling; + private bool _simulateLowRam; + private int _simulatedRamMB = 2048; protected override void InternalDraw() { @@ -58,9 +61,9 @@ private void DrawGeneralTab() { mapManager.TilePosition = new Point(x, y); } - if (ImGui.SliderFloat("Zoom", ref mapManager.Camera.Zoom, 0.2f, 4.0f)) + if (ImGui.SliderFloat("Zoom", ref mapManager.Camera.Zoom, 0.02f, 4.0f)) { - mapManager.Camera.Zoom = Math.Max(0.01f, mapManager.Camera.Zoom); + mapManager.Camera.Zoom = Math.Max(0.02f, mapManager.Camera.Zoom); } ImGui.NewLine(); ImGui.SliderFloat("Yaw", ref mapManager.Camera.Yaw, -180.0f, 180.0f); @@ -89,9 +92,57 @@ private void DrawPerformanceTab() if (ImGui.BeginTabItem("Performance")) { ImGui.Text($"FPS: {ImGui.GetIO().Framerate:F1}"); + + if (ImGui.Checkbox("Profile frames to file", ref _profiling)) + { + if (_profiling) + { + var path = System.IO.Path.Combine(System.AppContext.BaseDirectory, "perf_log.csv"); + Metrics.StartProfiling(path); + } + else + { + Metrics.StopProfiling(); + } + } + if (Metrics.Profiling) + { + ImGui.SameLine(); + ImGui.Text($"capturing {Metrics.ProfiledFrames} frames..."); + } + else if (!string.IsNullOrEmpty(Metrics.ProfilePath)) + { + ImGui.SameLine(); + ImGui.Text($"wrote {Metrics.ProfilePath}"); + } + ImGui.Separator(); + + var mapManager = CEDGame.MapManager; + if (mapManager != null) + { + if (ImGui.Checkbox("Simulate limited RAM", ref _simulateLowRam)) + { + mapManager.DebugAvailableMemoryOverrideMB = _simulateLowRam ? _simulatedRamMB : 0; + } + if (_simulateLowRam) + { + if (ImGui.SliderInt("Simulated RAM (MB)", ref _simulatedRamMB, 256, 16384)) + { + mapManager.DebugAvailableMemoryOverrideMB = _simulatedRamMB; + } + ImGui.TextDisabled("Drives the preload / region-cache / zoom-floor safeguards as if this were the available memory."); + } + ImGui.Separator(); + } + foreach (var nameValue in Metrics.Timers.OrderBy(t => t.Key)) { - ImGui.Text($"{nameValue.Key}: {nameValue.Value.TotalMilliseconds}ms"); + ImGui.Text($"{nameValue.Key}: {nameValue.Value.TotalMilliseconds:F3}ms"); + } + ImGui.Separator(); + foreach (var nameValue in Metrics.Counters.OrderBy(t => t.Key)) + { + ImGui.Text($"{nameValue.Key}: {nameValue.Value}"); } ImGui.EndTabItem(); } @@ -158,4 +209,4 @@ private void DrawStatic(StaticObject so) ImGui.Text($"id: {staticTile.Id.FormatId()} hue: {staticTile.Hue.FormatId()}"); } } -} \ No newline at end of file +} diff --git a/CentrED/Utils/Metrics.cs b/CentrED/Utils/Metrics.cs index 57014a09..4a7619ae 100644 --- a/CentrED/Utils/Metrics.cs +++ b/CentrED/Utils/Metrics.cs @@ -1,10 +1,14 @@ +using System.Diagnostics; +using System.Text; + namespace CentrED.Utils; public class Metrics { public Dictionary Timers = new(); - private readonly Dictionary starts = new(); - + public Dictionary Counters = new(); + private readonly Dictionary starts = new(); + public TimeSpan this[string name] { set => Timers[name] = value; @@ -12,12 +16,13 @@ public TimeSpan this[string name] public void Start(String name) { - starts[name] = DateTime.Now; + starts[name] = Stopwatch.GetTimestamp(); } public void Stop(String name) { - Timers[name] = DateTime.Now - starts[name]; + if (starts.TryGetValue(name, out var start)) + Timers[name] = Stopwatch.GetElapsedTime(start); } public void Measure(String name, Action callback) @@ -26,4 +31,109 @@ public void Measure(String name, Action callback) callback(); Stop(name); } -} \ No newline at end of file + + public void SetCounter(string name, long value) + { + Counters[name] = value; + } + + #region frame profiler + + private const int MaxProfileFrames = 30_000; + private const int FlushEveryFrames = 120; + + private StreamWriter? _profileWriter; + private List? _profileTimerCols; + private List? _profileCounterCols; + private string? _profilePath; + private long _lastFrameTimestamp; + private int _profileFrames; + + public bool Profiling => _profileWriter != null; + public int ProfiledFrames => _profileFrames; + public string? ProfilePath => _profilePath; + + public void StartProfiling(string path) + { + StopProfiling(); + try + { + _profileWriter = new StreamWriter(path, append: false); + _profilePath = path; + _profileTimerCols = null; + _profileCounterCols = null; + _profileFrames = 0; + _lastFrameTimestamp = 0; + } + catch (Exception e) + { + Console.WriteLine($"[Metrics] Failed to open profile file {path}: {e.Message}"); + _profileWriter = null; + } + } + + public void CaptureFrame() + { + if (_profileWriter == null) + return; + + var now = Stopwatch.GetTimestamp(); + var frameMs = _lastFrameTimestamp == 0 + ? 0.0 + : Stopwatch.GetElapsedTime(_lastFrameTimestamp, now).TotalMilliseconds; + _lastFrameTimestamp = now; + + if (_profileTimerCols == null) + { + _profileTimerCols = Timers.Keys.OrderBy(k => k).ToList(); + _profileCounterCols = Counters.Keys.OrderBy(k => k).ToList(); + var header = new StringBuilder("frame,frameMs"); + foreach (var t in _profileTimerCols) + header.Append(",t:").Append(t); + foreach (var c in _profileCounterCols) + header.Append(",c:").Append(c); + _profileWriter.WriteLine(header.ToString()); + } + + var line = new StringBuilder(); + line.Append(_profileFrames).Append(',').Append(frameMs.ToString("F3")); + foreach (var t in _profileTimerCols) + { + line.Append(','); + line.Append(Timers.TryGetValue(t, out var v) ? v.TotalMilliseconds.ToString("F3") : ""); + } + foreach (var c in _profileCounterCols!) + { + line.Append(','); + line.Append(Counters.TryGetValue(c, out var v) ? v.ToString() : ""); + } + _profileWriter.WriteLine(line.ToString()); + + _profileFrames++; + if (_profileFrames % FlushEveryFrames == 0) + _profileWriter.Flush(); + if (_profileFrames >= MaxProfileFrames) + StopProfiling(); + } + + public void StopProfiling() + { + if (_profileWriter != null) + { + try + { + _profileWriter.Flush(); + _profileWriter.Dispose(); + } + catch (Exception e) + { + Console.WriteLine($"[Metrics] Failed to finalize profile {_profilePath}: {e.Message}"); + } + _profileWriter = null; + } + _profileTimerCols = null; + _profileCounterCols = null; + } + + #endregion +} diff --git a/Client/CentrEDClient.cs b/Client/CentrEDClient.cs index 3f717a73..72b67ddf 100644 --- a/Client/CentrEDClient.cs +++ b/Client/CentrEDClient.cs @@ -1,4 +1,4 @@ -using System.Diagnostics.CodeAnalysis; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Sockets; using CentrED.Client.Map; @@ -29,6 +29,9 @@ public enum ClientState public sealed class CentrEDClient : ILogging { private const int RecvPipeSize = 1024 * 256; + private const int MaxBlockRequestsPerUpdate = 512; + private const int MaxBackgroundBlockRequestsPerUpdate = 192; + private const int MaxQueuedBackgroundBlockRequests = 768; private NetState? NetState { get; set; } private ClientLandscape? Landscape { get; set; } public bool CentrEdPlus { get; internal set; } @@ -47,7 +50,16 @@ public sealed class CentrEDClient : ILogging internal List? UndoGroup; internal Queue RequestedBlocksQueue = new(); + internal Queue BackgroundRequestedBlocksQueue = new(); internal HashSet RequestedBlocks = []; + internal HashSet ForegroundRequestedBlocks = []; + internal HashSet BackgroundQueuedBlocks = []; + private bool _backgroundPreloadActive; + private int _bgPreloadCenterX; + private int _bgPreloadCenterY; + private int _bgPreloadRadius; + private int _bgPreloadRingPos; + private int _bgPreloadProcessed; public List Clients { get; } = new(); public bool Running => State == ClientState.Running; public string Status { get; internal set; } = ""; @@ -68,8 +80,7 @@ private void Reset() Y = 0; UndoStack.Clear(); UndoGroup = null; - RequestedBlocksQueue.Clear(); - RequestedBlocks.Clear(); + ClearBlockRequests(); Clients.Clear(); State = ClientState.Disconnected; ServerState = ServerState.Running; @@ -77,6 +88,19 @@ private void Reset() Admin = new Admin([],[]); } + private void ClearBlockRequests() + { + RequestedBlocksQueue.Clear(); + BackgroundRequestedBlocksQueue.Clear(); + RequestedBlocks.Clear(); + ForegroundRequestedBlocks.Clear(); + BackgroundQueuedBlocks.Clear(); + _backgroundPreloadActive = false; + _bgPreloadRadius = 0; + _bgPreloadRingPos = 0; + _bgPreloadProcessed = 0; + } + private void RegisterPacketHandlers(NetState ns) { ns.RegisterPacketHandler(0x01, 0, Zlib.OnCompressedPacket); @@ -209,25 +233,146 @@ public void RequestBlocks(RectU16 areaInfo) continue; var chunk = new PointU16(x, y); - if(RequestedBlocks.Contains(chunk)) + if (RequestedBlocks.Contains(chunk)) + { + ForegroundRequestedBlocks.Add(chunk); + if (BackgroundQueuedBlocks.Remove(chunk)) + { + RequestedBlocksQueue.Enqueue(chunk); + } continue; + } toRequest.Add(chunk); } Landscape.BlockCache.Grow(Math.Max(1, areaInfo.Width * areaInfo.Height / 8)); - toRequest.ForEach(b => RequestedBlocks.Add(b)); - toRequest.ForEach(b => RequestedBlocksQueue.Enqueue(b));; + foreach (var block in toRequest) + { + RequestedBlocks.Add(block); + ForegroundRequestedBlocks.Add(block); + RequestedBlocksQueue.Enqueue(block); + } + } + + public void RequestAllBlocks(int centerBlockX = -1, int centerBlockY = -1) + { + BeginBackgroundMapPreload(centerBlockX, centerBlockY); + } + + public void BeginBackgroundMapPreload(int centerBlockX = -1, int centerBlockY = -1) + { + if (Landscape == null) + return; + + var totalBlocks = Width * Height; + Landscape.BlockCache.Grow(totalBlocks); + _backgroundPreloadActive = true; + _bgPreloadCenterX = centerBlockX >= 0 ? Math.Min(centerBlockX, Width - 1) : Width / 2; + _bgPreloadCenterY = centerBlockY >= 0 ? Math.Min(centerBlockY, Height - 1) : Height / 2; + _bgPreloadRadius = 0; + _bgPreloadRingPos = 0; + _bgPreloadProcessed = 0; + QueueBackgroundPreloadBlocks(); + } + + private void QueueBackgroundPreloadBlocks() + { + if (!_backgroundPreloadActive || Landscape == null) + return; + + int w = Width, h = Height; + int cx = _bgPreloadCenterX, cy = _bgPreloadCenterY; + int maxR = Math.Max(Math.Max(cx, w - 1 - cx), Math.Max(cy, h - 1 - cy)); + + var queued = 0; + var steps = 0; + const int MaxStepsPerUpdate = 8192; + while (_bgPreloadRadius <= maxR && + queued < MaxBackgroundBlockRequestsPerUpdate && + BackgroundRequestedBlocksQueue.Count < MaxQueuedBackgroundBlockRequests && + steps < MaxStepsPerUpdate) + { + steps++; + SpiralBlock(cx, cy, _bgPreloadRadius, _bgPreloadRingPos, out var bx, out var by); + var ringCount = _bgPreloadRadius == 0 ? 1 : 8 * _bgPreloadRadius; + if (++_bgPreloadRingPos >= ringCount) + { + _bgPreloadRingPos = 0; + _bgPreloadRadius++; + } + + if (bx < 0 || bx >= w || by < 0 || by >= h) + continue; + + _bgPreloadProcessed++; + var block = new PointU16((ushort)bx, (ushort)by); + if (Landscape.BlockCache.Contains(Block.Id((ushort)bx, (ushort)by))) + continue; + if (RequestedBlocks.Contains(block)) + continue; + + RequestedBlocks.Add(block); + BackgroundQueuedBlocks.Add(block); + BackgroundRequestedBlocksQueue.Enqueue(block); + queued++; + } + + if (_bgPreloadRadius > maxR) + { + _backgroundPreloadActive = false; + } + } + + private static void SpiralBlock(int cx, int cy, int r, int pos, out int bx, out int by) + { + if (r == 0) + { + bx = cx; by = cy; return; + } + int side = 2 * r; + if (pos < side) { bx = cx - r + pos; by = cy - r; } + else if (pos < 2 * side) { bx = cx + r; by = cy - r + (pos - side); } + else if (pos < 3 * side) { bx = cx + r - (pos - 2 * side); by = cy + r; } + else { bx = cx - r; by = cy + r - (pos - 3 * side); } + } + + private IEnumerable DequeueBlockRequests(int maxCount) + { + var count = 0; + while (RequestedBlocksQueue.Count > 0 && count < maxCount) + { + count++; + yield return RequestedBlocksQueue.Dequeue(); + } + + var backgroundCount = 0; + while (BackgroundRequestedBlocksQueue.Count > 0 && + count < maxCount && + backgroundCount < MaxBackgroundBlockRequestsPerUpdate) + { + var block = BackgroundRequestedBlocksQueue.Dequeue(); + if (!BackgroundQueuedBlocks.Remove(block) || !RequestedBlocks.Contains(block)) + { + continue; + } + + count++; + backgroundCount++; + yield return block; + } } private void UpdateRequestedBlocks() { - if (RequestedBlocksQueue.Count > 0) + QueueBackgroundPreloadBlocks(); + + var blocks = DequeueBlockRequests(MaxBlockRequestsPerUpdate).ToArray(); + if (blocks.Length > 0) { - var blocksCount = Math.Min(RequestedBlocksQueue.Count, 1000); - var packet = new RequestBlocksPacket(Enumerable.Range(0, blocksCount).Select(_ => RequestedBlocksQueue.Dequeue())); - if (blocksCount > 20) + var packet = new RequestBlocksPacket(blocks); + if (blocks.Length > 20) { SendCompressed(packet); } @@ -238,7 +383,35 @@ private void UpdateRequestedBlocks() } } - public bool WaitingForBlocks => RequestedBlocks.Count > 0; + public bool IsBlockLoaded(ushort blockX, ushort blockY) + { + return Landscape?.BlockCache.Contains(Block.Id(blockX, blockY)) ?? false; + } + + public Block? GetLoadedBlock(ushort blockX, ushort blockY) + { + return Landscape?.BlockCache.Get(Block.Id(blockX, blockY)); + } + + public bool WaitingForBlocks => ForegroundRequestedBlocks.Count > 0; + public int PendingBlockRequests => RequestedBlocks.Count; + public int ForegroundPendingBlockRequests => ForegroundRequestedBlocks.Count; + public int QueuedBlockRequests => RequestedBlocksQueue.Count + BackgroundRequestedBlocksQueue.Count; + public int ForegroundQueuedBlockRequests => RequestedBlocksQueue.Count; + public int BackgroundQueuedBlockRequests => BackgroundRequestedBlocksQueue.Count; + public int LoadedBlockCount => Landscape?.BlockCache.Count ?? 0; + public int BlockCacheCapacity => Landscape?.BlockCache.MaxSize ?? 0; + public bool BackgroundPreloadActive => _backgroundPreloadActive || BackgroundRequestedBlocksQueue.Count > 0; + public int BackgroundPreloadRemaining + { + get + { + if (Landscape == null) + return 0; + + return Math.Max(0, Width * Height - _bgPreloadProcessed); + } + } public bool IsValidX(int x) { @@ -351,6 +524,7 @@ public void SendWithUndo(Packet p) public void ResetCache() { + ClearBlockRequests(); Landscape?.BlockCache.Reset(); Landscape?.BlockCache.Resize(Math.Max(Width, Height) + 1); } @@ -654,4 +828,4 @@ public void LogDebug(string message) } #endregion -} \ No newline at end of file +} diff --git a/Client/Map/ClientLandscapePacketHandlers.cs b/Client/Map/ClientLandscapePacketHandlers.cs index 51fd3506..8a62c814 100644 --- a/Client/Map/ClientLandscapePacketHandlers.cs +++ b/Client/Map/ClientLandscapePacketHandlers.cs @@ -31,6 +31,8 @@ private void OnBlockPacket(SpanReader reader, NetState ns) BlockCache.Add(block); ns.Parent.OnBlockLoaded(block); ns.Parent.RequestedBlocks.Remove(coords); + ns.Parent.ForegroundRequestedBlocks.Remove(coords); + ns.Parent.BackgroundQueuedBlocks.Remove(coords); } } @@ -151,4 +153,4 @@ private void OnHueStaticPacket(SpanReader reader, NetState ns) InternalSetStaticHue(tile, newHue); ns.Parent.OnAfterStaticChanged(tile); } -} \ No newline at end of file +} diff --git a/Shared/BlockCache.cs b/Shared/BlockCache.cs index 526365f5..815a9267 100644 --- a/Shared/BlockCache.cs +++ b/Shared/BlockCache.cs @@ -9,6 +9,9 @@ public class BlockCache private readonly Queue _queue = new(); private int _maxSize = 256; + public int Count => _blocks.Count; + public int MaxSize => _maxSize; + public void Add(Block block) { var id = Block.Id(block); @@ -76,4 +79,4 @@ public void Resize(int newSize) Dequeue(out _); } } -} \ No newline at end of file +}