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>
This commit is contained in:
Pieter-Jan Briers
2025-12-03 16:52:25 +01:00
committed by GitHub
parent d0a784b9e6
commit 42b33ddd93
7 changed files with 282 additions and 82 deletions

View File

@@ -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;
/// <summary>
/// Stores data for airtight explosion traversal on a <see cref="MapGridComponent"/> entity.
/// </summary>
/// <seealso cref="ExplosionSystem"/>
[RegisterComponent]
[Access(typeof(ExplosionSystem), Other = AccessPermissions.None)]
public sealed partial class ExplosionAirtightGridComponent : Component
{
/// <summary>
/// Data for every tile on the current grid.
/// </summary>
/// <remarks>
/// Intentionally not saved.
/// </remarks>
[ViewVariables]
public readonly Dictionary<Vector2i, TileData> Tiles = new();
/// <summary>
/// Data struct that describes the explosion-blocking airtight entities on a tile.
/// </summary>
public struct TileData
{
/// <summary>
/// Which index into the tolerance cache of <see cref="ExplosionSystem"/> this tile is using.
/// </summary>
public required int ToleranceCacheIndex;
/// <summary>
/// Which directions this tile is blocking explosions in. Bitflag field.
/// </summary>
public required AtmosDirection BlockedDirections;
}
/// <summary>
/// A set of tolerance values
/// </summary>
public struct ToleranceValues : IEquatable<ToleranceValues>
{
/// <summary>
/// Special value that indicates the entity is "invulnerable" against a specific explosion type.
/// </summary>
/// <remarks>
/// Here to deal with the limited range of <see cref="FixedPoint2"/> over typical floats.
/// </remarks>
public static readonly FixedPoint2 Invulnerable = FixedPoint2.MaxValue;
/// <summary>
/// The intensities at which explosions of each type can instantly break through an entity.
/// </summary>
/// <remarks>
/// <para>
/// This is an array, with the index of each value corresponding to the "explosion type ID" cached by
/// <see cref="ExplosionSystem"/>.
/// </para>
/// <para>
/// Values are stored as <see cref="FixedPoint2"/> to avoid possible precision issues resulting in
/// different-but-almost-identical tolerance values wasting memory.
/// </para>
/// <para>
/// If a value is <see cref="Invulnerable"/>, that indicates the tile is invulnerable.
/// </para>
/// </remarks>
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);
}
}
}

View File

@@ -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;
/// </summary>
public sealed class ExplosionGridTileFlood : ExplosionTileFlood
{
private readonly ExplosionSystem _explosionSystem;
public Entity<MapGridComponent> Grid;
private bool _needToTransform = false;
@@ -45,7 +48,8 @@ public sealed class ExplosionGridTileFlood : ExplosionTileFlood
Dictionary<Vector2i, NeighborFlag> 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))

View File

@@ -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<string, int> _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<string, FixedPoint2> with just a FixedPoint2[].
private readonly Dictionary<ProtoId<ExplosionPrototype>, 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<ToleranceValues, int> _toleranceIndex = new();
// Storage for tolerance values. Entries form a free linked list when not occupied by a set of real values.
private ValueList<CacheEntry> _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<string, float> with just a float[].
int index = 0;
foreach (var prototype in _prototypeManager.EnumeratePrototypes<ExplosionPrototype>())
{
// 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<string, float>
//
// Hence, each tile has a tuple (Dictionary<string, float>, 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<EntityUid, Dictionary<Vector2i, TileData>> _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;
}
/// <summary>
/// Update the map of explosion blockers.
/// </summary>
@@ -58,11 +79,12 @@ public sealed partial class ExplosionSystem
/// </remarks>
public void UpdateAirtightMap(EntityUid gridId, MapGridComponent grid, Vector2i tile)
{
var tolerance = new float[_explosionTypes.Count];
var blockedDirections = AtmosDirection.Invalid;
var airtightGrid = EnsureComp<ExplosionAirtightGridComponent>(gridId);
if (!_airtightMap.ContainsKey(gridId))
_airtightMap[gridId] = new();
// Calculate tile new airtight state.
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++)
{
tolerance[i] = Math.Max(tolerance[i], entityTolerances[i]);
}
GetExplosionTolerance(uid.Value, tolerance);
}
if (blockedDirections != AtmosDirection.Invalid)
_airtightMap[gridId][tile] = new(tolerance, blockedDirections);
// Log.Info($"UPDATE {gridId}/{tile}: {blockedDirections}");
if (blockedDirections == AtmosDirection.Invalid)
{
// No longer airtight
if (!airtightGrid.Tiles.Remove(tile, out var tileData))
{
// Did not have this tile before and after, nothing to do.
return;
}
// Removing tile data.
DecrementRefCount(tileData.ToleranceCacheIndex);
return;
}
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!");
}
}
/// <summary>
@@ -106,7 +208,7 @@ public sealed partial class ExplosionSystem
/// <summary>
/// Return a dictionary that specifies how intense a given explosion type needs to be in order to destroy an entity.
/// </summary>
public float[] GetExplosionTolerance(EntityUid uid)
private void GetExplosionTolerance(EntityUid uid, Span<FixedPoint2> 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;
}
}
/// <summary>
/// Data struct that describes the explosion-blocking airtight entities on a tile.
/// </summary>
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<ExplosionAirtightGridComponent>(entity);
}
public override void ReloadMap()
{
foreach (var(grid, dict) in _airtightMap)
var enumerator = EntityQueryEnumerator<ExplosionAirtightGridComponent, MapGridComponent>();
while (enumerator.MoveNext(out var uid, out var airtightComp, out var mapGrid))
{
var comp = Comp<MapGridComponent>(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
}
}

View File

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

View File

@@ -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<ExplosionAirtightGridComponent>(epicentreGrid)?.Tiles ?? new();
var initialGridData = new ExplosionGridTileFlood(
(epicentreGrid.Value, Comp<MapGridComponent>(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<ExplosionAirtightGridComponent>(grid)?.Tiles ?? new();
data = new ExplosionGridTileFlood(
(grid, Comp<MapGridComponent>(grid)),
@@ -204,7 +200,8 @@ public sealed partial class ExplosionSystem
_gridEdges[grid],
referenceGrid,
spaceMatrix,
spaceAngle);
spaceAngle,
this);
gridData[grid] = data;
}

View File

@@ -104,6 +104,8 @@ public sealed partial class ExplosionSystem : SharedExplosionSystem
_destructibleQuery = GetEntityQuery<DestructibleComponent>();
_damageableQuery = GetEntityQuery<DamageableComponent>();
_airtightQuery = GetEntityQuery<AirtightComponent>();
_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,

View File

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