From 42b33ddd935b5b255eaac049331f3c42f9af5176 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Wed, 3 Dec 2025 16:52:25 +0100 Subject: [PATCH] Reduce explosion airtight cache memory usage (#40912) * Reduce explosion airtight cache memory usage This means you can happily add explosion prototypes again New approach has the tolerance value data in a shared storage with reference counting. * Oops fix index removal * Remove debug code and fix merge conflicts * Also address my other review * Oh it's in two places lmao --------- Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com> --- .../ExplosionAirtightGridComponent.cs | 100 +++++++++ .../EntitySystems/ExplosionGridTileFlood.cs | 19 +- .../EntitySystems/ExplosionSystem.Airtight.cs | 205 +++++++++++++----- .../EntitySystems/ExplosionSystem.GridMap.cs | 2 +- .../EntitySystems/ExplosionSystem.TileFill.cs | 19 +- .../EntitySystems/ExplosionSystem.cs | 3 + Resources/Prototypes/explosion.yml | 12 - 7 files changed, 280 insertions(+), 80 deletions(-) create mode 100644 Content.Server/Explosion/Components/ExplosionAirtightGridComponent.cs diff --git a/Content.Server/Explosion/Components/ExplosionAirtightGridComponent.cs b/Content.Server/Explosion/Components/ExplosionAirtightGridComponent.cs new file mode 100644 index 0000000000..68f576dcb9 --- /dev/null +++ b/Content.Server/Explosion/Components/ExplosionAirtightGridComponent.cs @@ -0,0 +1,100 @@ +using Content.Server.Explosion.EntitySystems; +using Content.Shared.Atmos; +using Content.Shared.FixedPoint; +using Robust.Shared.Map.Components; +using Robust.Shared.Utility; + +namespace Content.Server.Explosion.Components; + +/// +/// Stores data for airtight explosion traversal on a entity. +/// +/// +[RegisterComponent] +[Access(typeof(ExplosionSystem), Other = AccessPermissions.None)] +public sealed partial class ExplosionAirtightGridComponent : Component +{ + /// + /// Data for every tile on the current grid. + /// + /// + /// Intentionally not saved. + /// + [ViewVariables] + public readonly Dictionary Tiles = new(); + + /// + /// Data struct that describes the explosion-blocking airtight entities on a tile. + /// + public struct TileData + { + /// + /// Which index into the tolerance cache of this tile is using. + /// + public required int ToleranceCacheIndex; + + /// + /// Which directions this tile is blocking explosions in. Bitflag field. + /// + public required AtmosDirection BlockedDirections; + } + + /// + /// A set of tolerance values + /// + public struct ToleranceValues : IEquatable + { + /// + /// Special value that indicates the entity is "invulnerable" against a specific explosion type. + /// + /// + /// Here to deal with the limited range of over typical floats. + /// + public static readonly FixedPoint2 Invulnerable = FixedPoint2.MaxValue; + + /// + /// The intensities at which explosions of each type can instantly break through an entity. + /// + /// + /// + /// This is an array, with the index of each value corresponding to the "explosion type ID" cached by + /// . + /// + /// + /// Values are stored as to avoid possible precision issues resulting in + /// different-but-almost-identical tolerance values wasting memory. + /// + /// + /// If a value is , that indicates the tile is invulnerable. + /// + /// + public required FixedPoint2[] Values; + + public bool Equals(ToleranceValues other) + { + return Values.AsSpan().SequenceEqual(other.Values); + } + + public override bool Equals(object? obj) + { + return obj is ToleranceValues other && Equals(other); + } + + public override int GetHashCode() + { + var hc = new HashCode(); + hc.AddArray(Values); + return hc.ToHashCode(); + } + + public static bool operator ==(ToleranceValues left, ToleranceValues right) + { + return left.Equals(right); + } + + public static bool operator !=(ToleranceValues left, ToleranceValues right) + { + return !left.Equals(right); + } + } +} diff --git a/Content.Server/Explosion/EntitySystems/ExplosionGridTileFlood.cs b/Content.Server/Explosion/EntitySystems/ExplosionGridTileFlood.cs index da3ce635af..0274979c55 100644 --- a/Content.Server/Explosion/EntitySystems/ExplosionGridTileFlood.cs +++ b/Content.Server/Explosion/EntitySystems/ExplosionGridTileFlood.cs @@ -1,7 +1,8 @@ using System.Numerics; using Content.Shared.Atmos; -using Robust.Shared.Map; +using Content.Shared.FixedPoint; using Robust.Shared.Map.Components; +using static Content.Server.Explosion.Components.ExplosionAirtightGridComponent; using static Content.Server.Explosion.EntitySystems.ExplosionSystem; namespace Content.Server.Explosion.EntitySystems; @@ -11,6 +12,8 @@ namespace Content.Server.Explosion.EntitySystems; /// public sealed class ExplosionGridTileFlood : ExplosionTileFlood { + private readonly ExplosionSystem _explosionSystem; + public Entity Grid; private bool _needToTransform = false; @@ -45,7 +48,8 @@ public sealed class ExplosionGridTileFlood : ExplosionTileFlood Dictionary edgeTiles, EntityUid? referenceGrid, Matrix3x2 spaceMatrix, - Angle spaceAngle) + Angle spaceAngle, + ExplosionSystem explosionSystem) { Grid = grid; _airtightMap = airtightMap; @@ -53,6 +57,7 @@ public sealed class ExplosionGridTileFlood : ExplosionTileFlood _intensityStepSize = intensityStepSize; _typeIndex = typeIndex; _edgeTiles = edgeTiles; + _explosionSystem = explosionSystem; // initialise SpaceTiles foreach (var (tile, spaceNeighbors) in _edgeTiles) @@ -193,11 +198,11 @@ public sealed class ExplosionGridTileFlood : ExplosionTileFlood NewBlockedTiles.Add(tile); // At what explosion iteration would this blocker be destroyed? - var required = tileData.ExplosionTolerance[_typeIndex]; + var required = _explosionSystem.GetToleranceValues(tileData.ToleranceCacheIndex).Values[_typeIndex]; if (required > _maxIntensity) return; // blocker is never destroyed. - var clearIteration = iteration + (int) MathF.Ceiling(required / _intensityStepSize); + var clearIteration = iteration + (int) MathF.Ceiling((float)required / _intensityStepSize); if (FreedTileLists.TryGetValue(clearIteration, out var list)) list.Add(tile); else @@ -261,13 +266,13 @@ public sealed class ExplosionGridTileFlood : ExplosionTileFlood foreach (var tile in tiles) { var blockedDirections = AtmosDirection.Invalid; - float sealIntegrity = 0; + FixedPoint2 sealIntegrity = 0; // Note that if (grid, tile) is not a valid key, then airtight.BlockedDirections will default to 0 (no blocked directions) if (_airtightMap.TryGetValue(tile, out var tileData)) { blockedDirections = tileData.BlockedDirections; - sealIntegrity = tileData.ExplosionTolerance[_typeIndex]; + sealIntegrity = _explosionSystem.GetToleranceValues(tileData.ToleranceCacheIndex).Values[_typeIndex]; } // First, yield any neighboring tiles that are not blocked by airtight entities on this tile @@ -290,7 +295,7 @@ public sealed class ExplosionGridTileFlood : ExplosionTileFlood continue; // At what explosion iteration would this blocker be destroyed? - var clearIteration = iteration + (int) MathF.Ceiling(sealIntegrity / _intensityStepSize); + var clearIteration = iteration + (int) MathF.Ceiling((float) sealIntegrity / _intensityStepSize); // Get the delayed neighbours list if (!_delayedNeighbors.TryGetValue(clearIteration, out var list)) diff --git a/Content.Server/Explosion/EntitySystems/ExplosionSystem.Airtight.cs b/Content.Server/Explosion/EntitySystems/ExplosionSystem.Airtight.cs index 303c4e8cab..da2a571900 100644 --- a/Content.Server/Explosion/EntitySystems/ExplosionSystem.Airtight.cs +++ b/Content.Server/Explosion/EntitySystems/ExplosionSystem.Airtight.cs @@ -1,44 +1,59 @@ +using System.Linq; +using System.Runtime.InteropServices; using Content.Server.Atmos.Components; +using Content.Server.Explosion.Components; using Content.Shared.Atmos; using Content.Shared.Damage.Systems; using Content.Shared.Explosion; using Content.Shared.FixedPoint; +using Robust.Shared.Collections; using Robust.Shared.Map.Components; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; +using static Content.Server.Explosion.Components.ExplosionAirtightGridComponent; namespace Content.Server.Explosion.EntitySystems; public sealed partial class ExplosionSystem { - private readonly Dictionary _explosionTypes = new(); + // We keep track of which tiles are airtight, and how much damage from explosions those airtight blockers can take. + // This is quite complicated, as the data effectively needs to be tracked *per tile*, *per explosion type*. + // To avoid wasting significant memory, we calculate the values and share the actual backing storage of it. + // Stored values are reference counted so they can be evicted when no longer needed. + // At the time of writing, this compacts the storage for Box Station from ~5500 tolerance value sets to 13, + // at round start. + + // Use integers instead of prototype IDs for storage of explosion data. + // This allows us to replace a Dictionary with just a FixedPoint2[]. + private readonly Dictionary, int> _explosionTypes = new(); + // Index to look up if we already have an existing set of tolerance values stored, so the data can be shared. + private readonly Dictionary _toleranceIndex = new(); + // Storage for tolerance values. Entries form a free linked list when not occupied by a set of real values. + private ValueList _toleranceData; + // First free position in _toleranceData. + // -1 indicates there are no free slots left and the storage must be expanded. + private int _freeListHead = -1; private void InitAirtightMap() { - // Currently explosion prototype hot-reload isn't supported, as it would involve completely re-computing the - // airtight map. Could be done, just not yet implemented. + _explosionTypes.Clear(); - // for storing airtight entity damage thresholds for all anchored airtight entities, we will use integers in - // place of id-strings. This initializes the string <--> id association. - // This allows us to replace a Dictionary with just a float[]. int index = 0; foreach (var prototype in _prototypeManager.EnumeratePrototypes()) { - // TODO EXPLOSION - // just make this a field on the prototype _explosionTypes.Add(prototype.ID, index); index++; } } - // The explosion intensity required to break an entity depends on the explosion type. So it is stored in a - // Dictionary - // - // Hence, each tile has a tuple (Dictionary, AtmosDirection). This specifies what directions are - // blocked, and how intense a given explosion type needs to be in order to destroy ALL airtight entities on that - // tile. This is the TileData struct. - // - // We then need this data for every tile on a grid. So this mess of a variable maps the Grid ID and Vector2i grid - // indices to this tile-data struct. - private Dictionary> _airtightMap = new(); + private void ReloadExplosionPrototypes(PrototypesReloadedEventArgs prototypesReloadedEventArgs) + { + if (!prototypesReloadedEventArgs.Modified.Contains(typeof(ExplosionPrototype))) + return; + + InitAirtightMap(); + ReloadMap(); + } public void UpdateAirtightMap(EntityUid gridId, Vector2i tile, MapGridComponent? grid = null) { @@ -46,6 +61,12 @@ public sealed partial class ExplosionSystem UpdateAirtightMap(gridId, grid, tile); } + [Access(typeof(ExplosionGridTileFlood))] + public ToleranceValues GetToleranceValues(int idx) + { + return _toleranceData[idx].Values; + } + /// /// Update the map of explosion blockers. /// @@ -58,11 +79,12 @@ public sealed partial class ExplosionSystem /// public void UpdateAirtightMap(EntityUid gridId, MapGridComponent grid, Vector2i tile) { - var tolerance = new float[_explosionTypes.Count]; - var blockedDirections = AtmosDirection.Invalid; + var airtightGrid = EnsureComp(gridId); + + // Calculate tile new airtight state. - if (!_airtightMap.ContainsKey(gridId)) - _airtightMap[gridId] = new(); + var tolerance = new FixedPoint2[_explosionTypes.Count]; + var blockedDirections = AtmosDirection.Invalid; var anchoredEnumerator = _map.GetAnchoredEntitiesEnumerator(gridId, grid, tile); @@ -72,17 +94,97 @@ public sealed partial class ExplosionSystem continue; blockedDirections |= airtight.AirBlockedDirection; - var entityTolerances = GetExplosionTolerance(uid.Value); - for (var i = 0; i < tolerance.Length; i++) + GetExplosionTolerance(uid.Value, tolerance); + } + + // Log.Info($"UPDATE {gridId}/{tile}: {blockedDirections}"); + + if (blockedDirections == AtmosDirection.Invalid) + { + // No longer airtight + + if (!airtightGrid.Tiles.Remove(tile, out var tileData)) { - tolerance[i] = Math.Max(tolerance[i], entityTolerances[i]); + // Did not have this tile before and after, nothing to do. + return; } + + // Removing tile data. + DecrementRefCount(tileData.ToleranceCacheIndex); + return; } - if (blockedDirections != AtmosDirection.Invalid) - _airtightMap[gridId][tile] = new(tolerance, blockedDirections); + ref var tileEntry = ref CollectionsMarshal.GetValueRefOrAddDefault(airtightGrid.Tiles, tile, out var existed); + var cacheKey = new ToleranceValues { Values = tolerance }; + + // Remove previous tolerance reference if necessary. + if (existed) + { + ref var prevEntry = ref _toleranceData[tileEntry.ToleranceCacheIndex]; + if (prevEntry.Values == cacheKey) + { + // No change. + return; + } + + DecrementRefCount(tileEntry.ToleranceCacheIndex); + } + + ref var newCacheIndex = ref CollectionsMarshal.GetValueRefOrAddDefault(_toleranceIndex, cacheKey, out existed); + if (existed) + { + _toleranceData[newCacheIndex].RefCount += 1; + } else - _airtightMap[gridId].Remove(tile); + { + if (_freeListHead < 0) + ExpandCache(); + + newCacheIndex = _freeListHead; + ref var newCacheEntry = ref _toleranceData[newCacheIndex]; + _freeListHead = newCacheEntry.RefCount; + + newCacheEntry.Values = cacheKey; + newCacheEntry.RefCount = 1; + } + + tileEntry = new TileData + { + BlockedDirections = blockedDirections, + ToleranceCacheIndex = newCacheIndex, + }; + } + + private void ExpandCache() + { + var newCacheSize = Math.Max(8, _toleranceData.Count * 2); + var curSize = _toleranceData.Count; + + _toleranceData.EnsureLength(newCacheSize); + for (var i = curSize; i < newCacheSize; i++) + { + _toleranceData[i].RefCount = _freeListHead; + _freeListHead = i; + } + } + + private void DecrementRefCount(int index) + { + ref var cacheEntry = ref _toleranceData[index]; + + DebugTools.Assert(cacheEntry.RefCount > 0); + cacheEntry.RefCount -= 1; + + if (cacheEntry.RefCount == 0) + { + var prevValue = cacheEntry.Values; + cacheEntry.Values = default; + cacheEntry.RefCount = _freeListHead; + _freeListHead = index; + + var result = _toleranceIndex.Remove(prevValue); + DebugTools.Assert(result, "Failed to removed 0 refcounted index!"); + } } /// @@ -106,7 +208,7 @@ public sealed partial class ExplosionSystem /// /// Return a dictionary that specifies how intense a given explosion type needs to be in order to destroy an entity. /// - public float[] GetExplosionTolerance(EntityUid uid) + private void GetExplosionTolerance(EntityUid uid, Span explosionTolerance) { // How much total damage is needed to destroy this entity? This also includes "break" behaviors. This ASSUMES // that this will result in a non-airtight entity.Entities that ONLY break via construction graph node changes @@ -117,14 +219,14 @@ public sealed partial class ExplosionSystem totalDamageTarget = _destructibleSystem.DestroyedAt(uid, destructible); } - var explosionTolerance = new float[_explosionTypes.Count]; if (totalDamageTarget == FixedPoint2.MaxValue || !_damageableQuery.TryGetComponent(uid, out var damageable)) { for (var i = 0; i < explosionTolerance.Length; i++) { - explosionTolerance[i] = float.MaxValue; + explosionTolerance[i] = ToleranceValues.Invulnerable; } - return explosionTolerance; + + return; } // What multiple of each explosion type damage set will result in the damage exceeding the required amount? This @@ -157,38 +259,43 @@ public sealed partial class ExplosionSystem damagePerIntensity += value * mod * Math.Max(0, ev.DamageCoefficient); } - explosionTolerance[index] = damagePerIntensity > 0 + var toleranceValue = damagePerIntensity > 0 ? (float) ((totalDamageTarget - damageable.TotalDamage) / damagePerIntensity) - : float.MaxValue; - } + : ToleranceValues.Invulnerable; - return explosionTolerance; + explosionTolerance[index] = toleranceValue; + } } - /// - /// Data struct that describes the explosion-blocking airtight entities on a tile. - /// - public struct TileData + private void OnAirtightGridRemoved(EntityUid entity) { - public TileData(float[] explosionTolerance, AtmosDirection blockedDirections) + if (!TryComp(entity, out ExplosionAirtightGridComponent? airtightGrid)) + return; + + foreach (var tile in airtightGrid.Tiles.Values) { - ExplosionTolerance = explosionTolerance; - BlockedDirections = blockedDirections; + DecrementRefCount(tile.ToleranceCacheIndex); } - public float[] ExplosionTolerance; - public AtmosDirection BlockedDirections = AtmosDirection.Invalid; + RemComp(entity); } public override void ReloadMap() { - foreach (var(grid, dict) in _airtightMap) + var enumerator = EntityQueryEnumerator(); + while (enumerator.MoveNext(out var uid, out var airtightComp, out var mapGrid)) { - var comp = Comp(grid); - foreach (var index in dict.Keys) + foreach (var pos in airtightComp.Tiles.Keys) { - UpdateAirtightMap(grid, comp, index); + UpdateAirtightMap(uid, pos, mapGrid); } } } + + private struct CacheEntry + { + public ToleranceValues Values; + public int RefCount; // Doubles as freelist chain + } + } diff --git a/Content.Server/Explosion/EntitySystems/ExplosionSystem.GridMap.cs b/Content.Server/Explosion/EntitySystems/ExplosionSystem.GridMap.cs index 5c032d5c82..3767d0c238 100644 --- a/Content.Server/Explosion/EntitySystems/ExplosionSystem.GridMap.cs +++ b/Content.Server/Explosion/EntitySystems/ExplosionSystem.GridMap.cs @@ -38,7 +38,7 @@ public sealed partial class ExplosionSystem private void OnGridRemoved(GridRemovalEvent ev) { - _airtightMap.Remove(ev.EntityUid); + OnAirtightGridRemoved(ev.EntityUid); _gridEdges.Remove(ev.EntityUid); // this should be a small enough set that iterating all of them is fine diff --git a/Content.Server/Explosion/EntitySystems/ExplosionSystem.TileFill.cs b/Content.Server/Explosion/EntitySystems/ExplosionSystem.TileFill.cs index ac539da213..a274fa8660 100644 --- a/Content.Server/Explosion/EntitySystems/ExplosionSystem.TileFill.cs +++ b/Content.Server/Explosion/EntitySystems/ExplosionSystem.TileFill.cs @@ -1,5 +1,6 @@ using System.Linq; using System.Numerics; +using Content.Server.Explosion.Components; using Content.Shared.Administration; using Content.Shared.Explosion.Components; using Robust.Shared.Map; @@ -40,11 +41,7 @@ public sealed partial class ExplosionSystem if (totalIntensity <= 0 || slope <= 0) return null; - if (!_explosionTypes.TryGetValue(typeID, out var typeIndex)) - { - Log.Error("Attempted to spawn explosion using a prototype that was not defined during initialization. Explosion prototype hot-reload is not currently supported."); - return null; - } + var typeIndex = _explosionTypes[typeID]; Vector2i initialTile; EntityUid? epicentreGrid = null; @@ -103,8 +100,7 @@ public sealed partial class ExplosionSystem // set up the initial `gridData` instance encounteredGrids.Add(epicentreGrid.Value); - if (!_airtightMap.TryGetValue(epicentreGrid.Value, out var airtightMap)) - airtightMap = new(); + var airtightMap = CompOrNull(epicentreGrid)?.Tiles ?? new(); var initialGridData = new ExplosionGridTileFlood( (epicentreGrid.Value, Comp(epicentreGrid.Value)), @@ -115,7 +111,8 @@ public sealed partial class ExplosionSystem _gridEdges[epicentreGrid.Value], referenceGrid, spaceMatrix, - spaceAngle); + spaceAngle, + this); gridData[epicentreGrid.Value] = initialGridData; @@ -192,8 +189,7 @@ public sealed partial class ExplosionSystem // is this a new grid, for which we must create a new explosion data set if (!gridData.TryGetValue(grid, out var data)) { - if (!_airtightMap.TryGetValue(grid, out var airtightMap)) - airtightMap = new(); + var airtightMap = CompOrNull(grid)?.Tiles ?? new(); data = new ExplosionGridTileFlood( (grid, Comp(grid)), @@ -204,7 +200,8 @@ public sealed partial class ExplosionSystem _gridEdges[grid], referenceGrid, spaceMatrix, - spaceAngle); + spaceAngle, + this); gridData[grid] = data; } diff --git a/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs b/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs index 70863d6f54..b5163c6157 100644 --- a/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs +++ b/Content.Server/Explosion/EntitySystems/ExplosionSystem.cs @@ -104,6 +104,8 @@ public sealed partial class ExplosionSystem : SharedExplosionSystem _destructibleQuery = GetEntityQuery(); _damageableQuery = GetEntityQuery(); _airtightQuery = GetEntityQuery(); + + _prototypeManager.PrototypesReloaded += ReloadExplosionPrototypes; } private void OnReset(RoundRestartCleanupEvent ev) @@ -122,6 +124,7 @@ public sealed partial class ExplosionSystem : SharedExplosionSystem base.Shutdown(); _nodeGroupSystem.PauseUpdating = false; _pathfindingSystem.PauseUpdating = false; + _prototypeManager.PrototypesReloaded -= ReloadExplosionPrototypes; } private void RelayedResistance(EntityUid uid, ExplosionResistanceComponent component, diff --git a/Resources/Prototypes/explosion.yml b/Resources/Prototypes/explosion.yml index ad00333892..4d3febeda7 100644 --- a/Resources/Prototypes/explosion.yml +++ b/Resources/Prototypes/explosion.yml @@ -1,11 +1,3 @@ -# Does not currently support prototype hot-reloading. See comments in c# file. - -# Note that for every explosion type you define, explosions & nukes will start performing worse -# You should only define a new explopsion type if you really need to -# -# If you just want to modify properties other than `damagePerIntensity`, it'd be better to -# split off explosion damage & explosion visuals/effects into their own separate prototypes. - - type: explosion id: Default damagePerIntensity: @@ -135,7 +127,3 @@ texturePath: /Textures/Effects/fire.rsi fireStates: 3 fireStacks: 2 - -# STOP -# BEFORE YOU ADD MORE EXPLOSION TYPES CONSIDER IF AN EXISTING ONE IS SUITABLE -# ADDING NEW ONES IS PROHIBITIVELY EXPENSIVE -- 2.52.0