namespace Sand.Core; public sealed partial class SandSimulation { private uint GetVisualSettingsStamp() { unchecked { var stamp = 17u; stamp = (stamp * 31u) + (_settings.EnablePressureVisuals ? 1u : 0u); stamp = (stamp * 31u) + (_settings.EnableWindVisuals ? 1u : 0u); stamp = (stamp * 31u) + (_settings.EnableTempVisuals ? 1u : 0u); stamp = (stamp * 31u) + (_settings.EnableGasEffect ? 1u : 0u); stamp = (stamp * 31u) + (_settings.EnableGlow ? 1u : 0u); stamp = (stamp * 31u) + (uint)BitConverter.SingleToInt32Bits(_settings.AmbientTemperature); return stamp; } } private void EnsureVisualSettingsState() { var stamp = GetVisualSettingsStamp(); if (_visualSettingsStamp == stamp) { return; } _visualSettingsStamp = stamp; MarkFullVisualDirty(); } private void MarkFullVisualDirty() { _fullVisualDirty = true; _hasDirtyVisualBounds = true; _minDirtyVisualX = 0; _minDirtyVisualY = 0; _maxDirtyVisualX = Width - 1; _maxDirtyVisualY = Height - 1; } private bool AreFieldVisualsEnabled() => _settings.EnablePressureVisuals || _settings.EnableWindVisuals; private void MarkVisualDirty(int x, int y) { if (!InBounds(x, y)) { return; } if (_deferVisualDirtyTracking) { return; } if (_fullVisualDirty) { return; } if (!_hasDirtyVisualBounds) { _minDirtyVisualX = _maxDirtyVisualX = x; _minDirtyVisualY = _maxDirtyVisualY = y; _hasDirtyVisualBounds = true; return; } _minDirtyVisualX = Math.Min(_minDirtyVisualX, x); _minDirtyVisualY = Math.Min(_minDirtyVisualY, y); _maxDirtyVisualX = Math.Max(_maxDirtyVisualX, x); _maxDirtyVisualY = Math.Max(_maxDirtyVisualY, y); } private void MarkVisualDirtyRect(int startX, int startY, int endX, int endY) { if (_deferVisualDirtyTracking) { return; } if (_fullVisualDirty) { return; } if (startX > endX || startY > endY) { return; } startX = Math.Max(0, startX); startY = Math.Max(0, startY); endX = Math.Min(Width - 1, endX); endY = Math.Min(Height - 1, endY); if (startX > endX || startY > endY) { return; } if (!_hasDirtyVisualBounds) { _minDirtyVisualX = startX; _minDirtyVisualY = startY; _maxDirtyVisualX = endX; _maxDirtyVisualY = endY; _hasDirtyVisualBounds = true; return; } _minDirtyVisualX = Math.Min(_minDirtyVisualX, startX); _minDirtyVisualY = Math.Min(_minDirtyVisualY, startY); _maxDirtyVisualX = Math.Max(_maxDirtyVisualX, endX); _maxDirtyVisualY = Math.Max(_maxDirtyVisualY, endY); } private bool TryGetDirtyVisualBounds(out int startX, out int startY, out int endX, out int endY) { if (_fullVisualDirty) { startX = 0; startY = 0; endX = Width - 1; endY = Height - 1; return true; } if (_hasDirtyVisualBounds) { startX = _minDirtyVisualX; startY = _minDirtyVisualY; endX = _maxDirtyVisualX; endY = _maxDirtyVisualY; return true; } startX = startY = endX = endY = 0; return false; } private void ClearDirtyVisualBounds() { _fullVisualDirty = false; _hasDirtyVisualBounds = false; } private void UpdateCachedVisualBuffers() { EnsureVisualSettingsState(); if (!TryGetDirtyVisualBounds(out var startX, out var startY, out var endX, out var endY)) { return; } for (var y = startY; y <= endY; y++) { for (var x = startX; x <= endX; x++) { var color = ResolveVisualColor(x, y); var pixelIndex = (y * Width) + x; var rgbIndex = pixelIndex * 3; var rgbaIndex = pixelIndex * 4; _rgbBuffer[rgbIndex] = color.R; _rgbBuffer[rgbIndex + 1] = color.G; _rgbBuffer[rgbIndex + 2] = color.B; _rgbaBuffer[rgbaIndex] = color.R; _rgbaBuffer[rgbaIndex + 1] = color.G; _rgbaBuffer[rgbaIndex + 2] = color.B; _rgbaBuffer[rgbaIndex + 3] = 255; } } ClearDirtyVisualBounds(); } private bool HasDynamicFieldAt(int x, int y) { return MathF.Abs(_windFieldX[x, y]) >= 0.02f || MathF.Abs(_windFieldY[x, y]) >= 0.02f || MathF.Abs(_forceFieldX[x, y]) >= 0.02f || MathF.Abs(_forceFieldY[x, y]) >= 0.02f || MathF.Abs(_airPressure[x, y]) >= 0.02f; } private void ExpandActiveFieldBoundsToInclude(int x, int y) { if (!_hasActiveFieldBounds) { _minActiveFieldX = _maxActiveFieldX = x; _minActiveFieldY = _maxActiveFieldY = y; _hasActiveFieldBounds = true; return; } _minActiveFieldX = Math.Min(_minActiveFieldX, x); _minActiveFieldY = Math.Min(_minActiveFieldY, y); _maxActiveFieldX = Math.Max(_maxActiveFieldX, x); _maxActiveFieldY = Math.Max(_maxActiveFieldY, y); } private void TrackDynamicFieldMutation(int x, int y, bool hadDynamicBefore) { if (!InBounds(x, y)) { return; } var hasDynamicAfter = HasDynamicFieldAt(x, y); if (hasDynamicAfter) { ExpandActiveFieldBoundsToInclude(x, y); } else if (hadDynamicBefore) { _fieldBoundsDirty = true; } if (AreFieldVisualsEnabled()) { MarkVisualDirty(x, y); } } private void ClearDynamicFieldsAt(int x, int y) { var hadDynamicBefore = HasDynamicFieldAt(x, y); _windFieldX[x, y] = 0f; _windFieldY[x, y] = 0f; _forceFieldX[x, y] = 0f; _forceFieldY[x, y] = 0f; _airPressure[x, y] = 0f; if (hadDynamicBefore) { _fieldBoundsDirty = true; if (AreFieldVisualsEnabled()) { MarkVisualDirty(x, y); } } } private bool TryGetOccupiedBoundsOnly(out int startX, out int startY, out int endX, out int endY) { if (_hasOccupiedBounds) { startX = _minOccupiedX; startY = _minOccupiedY; endX = _maxOccupiedX; endY = _maxOccupiedY; return true; } startX = startY = endX = endY = 0; return false; } private bool TryGetSimulationBounds(out int startX, out int startY, out int endX, out int endY) { EnsureOccupiedBounds(); if (_fieldBoundsDirty && (_hasActiveFieldBounds || _hasOccupiedBounds)) { var recomputeStartX = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Min(_minActiveFieldX, _minOccupiedX) : _hasActiveFieldBounds ? _minActiveFieldX : _minOccupiedX; var recomputeStartY = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Min(_minActiveFieldY, _minOccupiedY) : _hasActiveFieldBounds ? _minActiveFieldY : _minOccupiedY; var recomputeEndX = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Max(_maxActiveFieldX, _maxOccupiedX) : _hasActiveFieldBounds ? _maxActiveFieldX : _maxOccupiedX; var recomputeEndY = _hasActiveFieldBounds && _hasOccupiedBounds ? Math.Max(_maxActiveFieldY, _maxOccupiedY) : _hasActiveFieldBounds ? _maxActiveFieldY : _maxOccupiedY; RebuildActiveFieldBounds(recomputeStartX, recomputeStartY, recomputeEndX, recomputeEndY); } var hasBounds = false; startX = startY = endX = endY = 0; if (_hasOccupiedBounds) { startX = _minOccupiedX; startY = _minOccupiedY; endX = _maxOccupiedX; endY = _maxOccupiedY; hasBounds = true; } if (_hasActiveFieldBounds) { if (!hasBounds) { startX = _minActiveFieldX; startY = _minActiveFieldY; endX = _maxActiveFieldX; endY = _maxActiveFieldY; hasBounds = true; } else { startX = Math.Min(startX, _minActiveFieldX); startY = Math.Min(startY, _minActiveFieldY); endX = Math.Max(endX, _maxActiveFieldX); endY = Math.Max(endY, _maxActiveFieldY); } } return hasBounds; } private void MarkBoundsUnionDirty(bool hadFirstBounds, int firstStartX, int firstStartY, int firstEndX, int firstEndY) { int secondStartX; int secondStartY; int secondEndX; int secondEndY; var hasSecondBounds = AreFieldVisualsEnabled() ? TryGetSimulationBounds(out secondStartX, out secondStartY, out secondEndX, out secondEndY) : TryGetOccupiedBoundsOnly(out secondStartX, out secondStartY, out secondEndX, out secondEndY); if (hasSecondBounds) { if (!hadFirstBounds) { MarkVisualDirtyRect(secondStartX, secondStartY, secondEndX, secondEndY); return; } MarkVisualDirtyRect( Math.Min(firstStartX, secondStartX), Math.Min(firstStartY, secondStartY), Math.Max(firstEndX, secondEndX), Math.Max(firstEndY, secondEndY)); return; } if (hadFirstBounds) { MarkVisualDirtyRect(firstStartX, firstStartY, firstEndX, firstEndY); } } private ushort ResolveOptionalTypeId(string? particleId) { if (particleId is null) { return 0; } return _library.TryGetTypeId(particleId, out var typeId) ? typeId : (ushort)0; } private float ResolveDefaultLifetime(ParticleDef definition, ParticleBalanceProfile balance) { var lifetime = (definition.Lifetime ?? 0f) * MathF.Max(0.01f, balance.LifetimeMultiplier); if (lifetime <= 0f && balance.MaxLifetimeTicks > 0f) { lifetime = (balance.MinLifetimeTicks + balance.MaxLifetimeTicks) * 0.5f; } if (balance.MinLifetimeTicks > 0f) { lifetime = MathF.Max(lifetime, balance.MinLifetimeTicks); } if (balance.MaxLifetimeTicks > 0f) { lifetime = MathF.Min(lifetime, balance.MaxLifetimeTicks); } return lifetime; } private ToolProfile BuildToolProfile(string id, float defaultStrength, int fallbackRadiusCells) { if (_library.TryGetTypeId(id, out var typeId)) { var definition = _library.GetDefinition(typeId); var radiusCells = definition.Radius > 0f ? Math.Max(fallbackRadiusCells, (int)MathF.Ceiling(definition.Radius / (ParticleSize * 4f))) : fallbackRadiusCells; var strength = definition.WindStrength > 0f ? definition.WindStrength : definition.GravityStrength > 0f ? definition.GravityStrength : definition.RepulsionStrength > 0f ? definition.RepulsionStrength : defaultStrength; return new ToolProfile( definition.Id, radiusCells, strength, definition.ForceFalloff <= 0f ? 1f : definition.ForceFalloff, definition.Turbulence, definition.Affects.Length == 0 ? ["all"] : definition.Affects); } return new ToolProfile(id, fallbackRadiusCells, defaultStrength, 1f, 0f, ["all"]); } private void ApplyDirectionalBrush(int centerX, int centerY, int brushRadius, float forceX, float forceY, ToolProfile profile, float[,] fieldX, float[,] fieldY, bool addPressure = false) { var gridCenterX = centerX / ParticleSize; var gridCenterY = centerY / ParticleSize; var effectiveRadius = Math.Max(brushRadius, profile.RadiusCells); var magnitude = MathF.Sqrt((forceX * forceX) + (forceY * forceY)); if (magnitude <= 0.001f) { forceX = 1f; forceY = 0f; magnitude = 1f; } for (var dx = -effectiveRadius; dx <= effectiveRadius; dx++) { for (var dy = -effectiveRadius; dy <= effectiveRadius; dy++) { if ((dx * dx) + (dy * dy) > effectiveRadius * effectiveRadius) { continue; } var x = gridCenterX + dx; var y = gridCenterY + dy; if (!TryNormalizeCoordinate(ref x, ref y)) { continue; } if (_settings.OuterWall && IsBoundary(x, y)) { continue; } if (!ToolAffectsCell(profile.Affects, x, y)) { continue; } var distance = MathF.Sqrt((dx * dx) + (dy * dy)); var falloff = ComputeFalloff(distance, effectiveRadius, profile.Falloff); var turbulence = SampleTurbulence(x, y, profile.Turbulence); var hadDynamicBefore = HasDynamicFieldAt(x, y); fieldX[x, y] = Math.Clamp(fieldX[x, y] + (((forceX / magnitude) + turbulence.X) * falloff * profile.Strength), -8f, 8f); fieldY[x, y] = Math.Clamp(fieldY[x, y] + (((forceY / magnitude) + turbulence.Y) * falloff * profile.Strength), -8f, 8f); if (addPressure) { _airPressure[x, y] = Math.Clamp(_airPressure[x, y] + falloff * profile.Strength, -10f, 10f); } TrackDynamicFieldMutation(x, y, hadDynamicBefore); } } } private bool ToolAffectsCell(string[] affects, int x, int y) { if (affects.Length == 0 || affects.Contains("all", StringComparer.OrdinalIgnoreCase)) { return true; } var typeId = TypeId[x, y]; if (typeId == 0) { return true; } var definition = _library.GetDefinition(typeId); foreach (var affect in affects) { if (string.Equals(affect, definition.Id, StringComparison.OrdinalIgnoreCase)) { return true; } if (affect == "solid" && definition.Kind == ParticleKind.Solid) { return true; } if (affect == "liquid" && definition.Kind == ParticleKind.Liquid) { return true; } if (affect == "gas" && definition.Kind == ParticleKind.Gas) { return true; } } return false; } private static float ComputeFalloff(float distance, int radius, float exponent) { if (radius <= 0) { return 0f; } var normalized = 1f - (distance / radius); if (normalized <= 0f) { return 0f; } return MathF.Pow(normalized, MathF.Max(0.1f, exponent)); } private (float X, float Y) SampleTurbulence(int x, int y, float turbulence) { if (turbulence <= 0.001f) { return (0f, 0f); } var hash = Hash(x, y, Frame); var tx = (((hash & 0xFFu) / 255f) - 0.5f) * turbulence; var ty = ((((hash >> 8) & 0xFFu) / 255f) - 0.5f) * turbulence; return (tx, ty); } private void TrySpawnOnDeath(int x, int y, ushort typeId) { var spawnType = _producesOnDeathTarget[typeId]; if (spawnType == 0) { return; } SpawnNeighborParticle(x, y, spawnType, Hash(x, y, Frame + 17)); } private bool SpawnNeighborParticle(int x, int y, ushort spawnType, uint seed) { var offsets = new (int X, int Y)[] { (0, -1), (-1, 0), (1, 0), (0, 1), (-1, -1), (1, -1), (-1, 1), (1, 1), }; var start = (int)(seed % (uint)offsets.Length); for (var i = 0; i < offsets.Length; i++) { var offset = offsets[(start + i) % offsets.Length]; var nx = x + offset.X; var ny = y + offset.Y; if (!TryNormalizeCoordinate(ref nx, ref ny)) { continue; } if ((_settings.OuterWall && IsBoundary(nx, ny)) || TypeId[nx, ny] != 0) { continue; } SetCell(nx, ny, spawnType); return true; } return false; } private bool BreakCellFromPressure(int x, int y, ushort typeId, ref uint seed) { var brokenType = _brokenTarget[typeId]; if (brokenType != 0 && brokenType != typeId) { ReplaceCell(x, y, brokenType); Temperature[x, y] = MathF.Max(Temperature[x, y], _initialTemperature[brokenType]); return true; } var isHotBreak = Temperature[x, y] >= Math.Max(200f, _burnTemperature[typeId]); if (_idSmoke != 0 && isHotBreak && NextChance(ref seed, 0.08f)) { SpawnNeighborParticle(x, y, _idSmoke, seed); } ResetCell(x, y); return true; } private void EnsureOccupiedBounds() { if (_boundsDirty && _staleOccupiedBoundsFrames >= 8) { RecomputeOccupiedBounds(); } } private void MarkBoundsDirtyIfRemovingOccupiedCell(int x, int y) { if (ParticleCount == 0) { _hasOccupiedBounds = false; _boundsDirty = false; return; } if (!_hasOccupiedBounds) { _boundsDirty = true; return; } if (x == _minOccupiedX || x == _maxOccupiedX || y == _minOccupiedY || y == _maxOccupiedY) { _boundsDirty = true; } } private void ExpandOccupiedBoundsToInclude(int x, int y) { if (!_hasOccupiedBounds) { _minOccupiedX = _maxOccupiedX = x; _minOccupiedY = _maxOccupiedY = y; _hasOccupiedBounds = true; return; } _minOccupiedX = Math.Min(_minOccupiedX, x); _minOccupiedY = Math.Min(_minOccupiedY, y); _maxOccupiedX = Math.Max(_maxOccupiedX, x); _maxOccupiedY = Math.Max(_maxOccupiedY, y); } private void RecomputeOccupiedBounds() { _hasOccupiedBounds = false; for (var y = 0; y < Height; y++) { for (var x = 0; x < Width; x++) { if (TypeId[x, y] != 0) { ExpandOccupiedBoundsToInclude(x, y); } } } _boundsDirty = false; _staleOccupiedBoundsFrames = 0; } private void ClearBoundaryWalls() { for (var x = 0; x < Width; x++) { TryClearBoundaryWall(x, 0); TryClearBoundaryWall(x, Height - 1); } for (var y = 0; y < Height; y++) { TryClearBoundaryWall(0, y); TryClearBoundaryWall(Width - 1, y); } } private void TryClearBoundaryWall(int x, int y) { if (TypeId[x, y] == _idWall) { ParticleCount = Math.Max(0, ParticleCount - 1); MarkBoundsDirtyIfRemovingOccupiedCell(x, y); ResetCellWithoutCountChange(x, y); MarkVisualDirty(x, y); } } private void ApplyBoundaryWallsFast() { for (var x = 0; x < Width; x++) { SetBoundaryWall(x, 0); SetBoundaryWall(x, Height - 1); } for (var y = 0; y < Height; y++) { SetBoundaryWall(0, y); SetBoundaryWall(Width - 1, y); } } private void SetBoundaryWall(int x, int y) { if (TypeId[x, y] == _idWall) { return; } if (TypeId[x, y] == 0) { ParticleCount++; } TypeId[x, y] = _idWall; Temperature[x, y] = _initialTemperature[_idWall]; BurnTime[x, y] = _burnDuration[_idWall]; Burning[x, y] = _burningInit[_idWall]; SparkTime[x, y] = 0; Lifetime[x, y] = _defaultLifetime[_idWall]; _pressureDuration[x, y] = 0f; _cellAge[x, y] = 0f; _integrity[x, y] = _durability[_idWall]; ExpandOccupiedBoundsToInclude(x, y); MarkVisualDirty(x, y); MarkProcessed(x, y); } private bool InBounds(int x, int y) => x >= 0 && x < Width && y >= 0 && y < Height; private bool IsBoundary(int x, int y) => x == 0 || y == 0 || x == Width - 1 || y == Height - 1; private bool TryNormalizeCoordinate(ref int x, ref int y) { if (_settings.WrapParticles) { x = ((x % Width) + Width) % Width; y = ((y % Height) + Height) % Height; return true; } return InBounds(x, y); } private void MarkProcessed(int x, int y) { if (_activeStepToken != 0 && InBounds(x, y)) { _processedFrame[x, y] = _activeStepToken; } } private void ForEachNeighbor8(int x, int y, Action visitor) { for (var dx = -1; dx <= 1; dx++) { for (var dy = -1; dy <= 1; dy++) { if (dx == 0 && dy == 0) { continue; } var nx = x + dx; var ny = y + dy; if (TryNormalizeCoordinate(ref nx, ref ny)) { visitor(nx, ny); } } } } private static uint Hash(int x, int y, int frame) { unchecked { var seed = (uint)(x * 73856093) ^ (uint)(y * 19349663) ^ (uint)(frame * 83492791); seed ^= seed >> 13; return seed * 1274126177u; } } private static bool Chance(uint hash, float threshold) => (hash & 0xFFFF) / 65535f < threshold; private static bool NextChance(ref uint seed, float threshold) { unchecked { seed = (seed * 1664525u) + 1013904223u; } return Chance(seed, threshold); } private static float Clamp(float value, float min, float max) { if (min > max) { (min, max) = (max, min); } return MathF.Min(max, MathF.Max(min, value)); } private void RebuildActiveFieldBounds(int startX, int startY, int endX, int endY) { _hasActiveFieldBounds = false; for (var y = startY; y <= endY; y++) { for (var x = startX; x <= endX; x++) { if (HasDynamicFieldAt(x, y)) { ExpandActiveFieldBoundsToInclude(x, y); } } } _fieldBoundsDirty = false; } }