using Sand.ChunkPrototype; using Sand.Core; namespace Sand.App; internal sealed class ChunkPrototypeSimulationBackend : ISimulationBackend { private readonly PrototypeSparseSandAdapter _adapter; private readonly SimulationSettings _settings; private readonly ParticleLibrary _library; private readonly Dictionary _particleProfiles; private readonly PrototypeParticle _wallParticle; private readonly AppSimulationFrameStats _frameStats = new(); private int _frame; private int _trimCounter; public ChunkPrototypeSimulationBackend(int width, int height, int particleSize, IParticleLibrary library, SimulationSettings settings) { _adapter = new PrototypeSparseSandAdapter(width, height, settings.AmbientTemperature); _settings = settings; _library = library as ParticleLibrary ?? throw new ArgumentException("Expected ParticleLibrary implementation.", nameof(library)); _particleProfiles = BuildParticleProfiles(_library); foreach (var particle in _particleProfiles.Values) { _adapter.RegisterParticleProfile(particle); } _wallParticle = _particleProfiles.GetValueOrDefault("wall"); ParticleSize = particleSize; RefreshSettingsState(); } public string BackendName => "chunk"; public SimulationSettings Settings => _settings; public AppSimulationFrameStats FrameStats => _frameStats; public int Frame => _frame; public int ParticleCount => _adapter.ParticleCount; public int ParticleSize { get; } public ReadOnlySpan BuildRgbaFrame() { var frame = _adapter.BuildRgbaFrame(_settings.EnableWindVisuals, _settings.EnablePressureVisuals); var stats = _adapter.LastStepStats; _frameStats.VisualDirtyPageCount = stats.VisualDirtyPages; _frameStats.FrameBuildBytesTouched = stats.FrameBuildBytesTouched; _frameStats.RenderTimeMicroseconds = stats.RenderTimeMicroseconds; return frame; } public void Step(float dt) { if (_settings.PauseSim) { return; } _adapter.World.ClearDirtyChunks(); var processed = _adapter.Step(); _frame++; _trimCounter++; if (_trimCounter >= 30) { _adapter.TrimResidency(marginChunks: 1); _trimCounter = 0; } _frameStats.Frame = _frame; _frameStats.ProcessedCells = Math.Max(processed, _adapter.ParticleCount); _frameStats.ParticleCount = _adapter.ParticleCount; _frameStats.LoadedChunkCount = _adapter.World.LoadedChunkCount; _frameStats.ActiveChunkCount = _adapter.World.ActiveChunkCount; _frameStats.DirtyChunkCount = _adapter.World.DirtyChunkCount; _frameStats.SteppedChunkCount = _adapter.LastStepStats.SteppedChunks; _frameStats.SleepingChunkCount = _adapter.LastStepStats.SleepingChunks; _frameStats.FieldPageCount = _adapter.LastStepStats.FieldPages; _frameStats.MoveAttemptCount = _adapter.LastStepStats.MoveAttempts; _frameStats.VerticalMoveAttemptCount = _adapter.LastStepStats.VerticalMoveAttempts; _frameStats.DiagonalMoveAttemptCount = _adapter.LastStepStats.DiagonalMoveAttempts; _frameStats.LateralMoveAttemptCount = _adapter.LastStepStats.LateralMoveAttempts; _frameStats.SuccessfulMoveCount = _adapter.LastStepStats.SuccessfulMoves; _frameStats.SwapAttemptCount = _adapter.LastStepStats.SwapAttempts; _frameStats.StalledMovableCount = _adapter.LastStepStats.StalledMovableCells; _frameStats.MovementOnlyFastPathCount = _adapter.LastStepStats.MovementOnlyFastPathCount; _frameStats.FullRuntimeStepCount = _adapter.LastStepStats.FullRuntimeStepCount; _frameStats.FullRuntimeSolidCount = _adapter.LastStepStats.FullRuntimeSolidCount; _frameStats.FullRuntimeLiquidCount = _adapter.LastStepStats.FullRuntimeLiquidCount; _frameStats.FullRuntimeGasCount = _adapter.LastStepStats.FullRuntimeGasCount; _frameStats.MovedParticleCount = _adapter.LastStepStats.MovedParticles; _frameStats.SwappedParticleCount = _adapter.LastStepStats.SwappedParticles; _frameStats.VisualDirtyPageCount = _adapter.LastStepStats.VisualDirtyPages; _frameStats.ActivationTimeMicroseconds = _adapter.LastStepStats.ActivationTimeMicroseconds; _frameStats.MovementTimeMicroseconds = _adapter.LastStepStats.MovementTimeMicroseconds; _frameStats.RuntimeTimeMicroseconds = _adapter.LastStepStats.RuntimeTimeMicroseconds; _frameStats.FieldDecayTimeMicroseconds = _adapter.LastStepStats.FieldDecayTimeMicroseconds; _frameStats.RenderTimeMicroseconds = _adapter.LastStepStats.RenderTimeMicroseconds; UpdateBoundsStats(); } public void Clear() { foreach (var (x, y) in _adapter.Particles.ToArray()) { _adapter.RemoveParticle(x, y); } _adapter.ClearFields(); _frame = 0; _trimCounter = 0; _frameStats.Frame = 0; _frameStats.ProcessedCells = 0; _frameStats.ParticleCount = 0; _frameStats.MinActiveX = 0; _frameStats.MinActiveY = 0; _frameStats.MaxActiveX = 0; _frameStats.MaxActiveY = 0; _frameStats.LoadedChunkCount = 0; _frameStats.ActiveChunkCount = 0; _frameStats.DirtyChunkCount = 0; _frameStats.SteppedChunkCount = 0; _frameStats.SleepingChunkCount = 0; _frameStats.FieldPageCount = 0; _frameStats.MoveAttemptCount = 0; _frameStats.VerticalMoveAttemptCount = 0; _frameStats.DiagonalMoveAttemptCount = 0; _frameStats.LateralMoveAttemptCount = 0; _frameStats.SuccessfulMoveCount = 0; _frameStats.SwapAttemptCount = 0; _frameStats.StalledMovableCount = 0; _frameStats.MovementOnlyFastPathCount = 0; _frameStats.FullRuntimeStepCount = 0; _frameStats.FullRuntimeSolidCount = 0; _frameStats.FullRuntimeLiquidCount = 0; _frameStats.FullRuntimeGasCount = 0; _frameStats.MovedParticleCount = 0; _frameStats.SwappedParticleCount = 0; _frameStats.VisualDirtyPageCount = 0; _frameStats.FrameBuildBytesTouched = 0; _frameStats.ActivationTimeMicroseconds = 0; _frameStats.MovementTimeMicroseconds = 0; _frameStats.RuntimeTimeMicroseconds = 0; _frameStats.FieldDecayTimeMicroseconds = 0; _frameStats.RenderTimeMicroseconds = 0; RefreshSettingsState(); } public void RefreshSettingsState() { if (_settings.OuterWall) { for (var x = 0; x < _adapter.Width; x++) { _adapter.AddParticle(x, 0, _wallParticle); _adapter.AddParticle(x, _adapter.Height - 1, _wallParticle); } for (var y = 0; y < _adapter.Height; y++) { _adapter.AddParticle(0, y, _wallParticle); _adapter.AddParticle(_adapter.Width - 1, y, _wallParticle); } } else { for (var x = 0; x < _adapter.Width; x++) { RemoveBoundaryWallIfPresent(x, 0); RemoveBoundaryWallIfPresent(x, _adapter.Height - 1); } for (var y = 0; y < _adapter.Height; y++) { RemoveBoundaryWallIfPresent(0, y); RemoveBoundaryWallIfPresent(_adapter.Width - 1, y); } } } public void ClearParticleCircle(int centerX, int centerY, int brushRadius) { PaintCircle(centerX, centerY, brushRadius, particleId: null); } public void ClearParticlePourAtPixel(int centerX, int centerY, int brushRadius, int maxParticles, int seed) { PaintCirclePour(centerX, centerY, brushRadius, maxParticles, seed, particleId: null); } public void CreateParticleCircle(int centerX, int centerY, int brushRadius, string particleId) { PaintCircle(centerX, centerY, brushRadius, particleId); } public void CreateParticlePourAtPixel(int centerX, int centerY, int brushRadius, string particleId, int maxParticles, int seed) { PaintCirclePour(centerX, centerY, brushRadius, maxParticles, seed, particleId); } private void PaintCirclePour(int centerX, int centerY, int brushRadius, int maxParticles, int seed, string? particleId) { var gridCenterX = centerX / ParticleSize; var gridCenterY = centerY / ParticleSize; var diameter = (brushRadius * 2) + 1; var touched = 0; for (var i = 0; i < Math.Max(maxParticles * 6, diameter * diameter) && touched < maxParticles; i++) { var dx = ((seed + (i * 17)) % diameter) - brushRadius; var dy = ((seed + (i * 31)) % diameter) - brushRadius; if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) { continue; } if (particleId is null) { var x = gridCenterX + dx; var y = gridCenterY + dy; if (TryNormalizeCoordinate(ref x, ref y) && (!_settings.OuterWall || !IsBoundary(x, y)) && _adapter.RemoveParticle(x, y)) { touched++; } } else if (TryPaintAtCell(gridCenterX + dx, gridCenterY + dy, particleId)) { touched++; } } } public void ApplyWindBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) { _adapter.ApplyWindBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, forceX, forceY); } public void ApplyAirBrushAtPixel(int centerX, int centerY, int brushRadius, float forceX, float forceY) { _adapter.ApplyAirBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, forceX, forceY); } public void ApplyGravityBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) { _adapter.ApplyGravityBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, strength); } public void ApplyRepulsorBrushAtPixel(int centerX, int centerY, int brushRadius, float strength) { _adapter.ApplyRepulsorBrush(centerX / ParticleSize, centerY / ParticleSize, brushRadius, strength); } private void PaintCircle(int centerX, int centerY, int brushRadius, string? particleId) { var gridCenterX = centerX / ParticleSize; var gridCenterY = centerY / ParticleSize; for (var dx = -brushRadius; dx <= brushRadius; dx++) { for (var dy = -brushRadius; dy <= brushRadius; dy++) { if ((dx * dx) + (dy * dy) > brushRadius * brushRadius) { continue; } if (particleId is null) { var x = gridCenterX + dx; var y = gridCenterY + dy; if (TryNormalizeCoordinate(ref x, ref y) && (!_settings.OuterWall || !IsBoundary(x, y))) { _adapter.RemoveParticle(x, y); } continue; } TryPaintAtCell(gridCenterX + dx, gridCenterY + dy, particleId); } } } private bool TryPaintAtCell(int x, int y, string particleId) { if (!TryNormalizeCoordinate(ref x, ref y)) { return false; } if (!_particleProfiles.TryGetValue(particleId, out var particle) || particle.IsEmpty) { return false; } if (_settings.OuterWall && particle.MotionType != PrototypeParticleType.Wall && IsBoundary(x, y)) { return false; } return _adapter.AddParticle(x, y, particle); } private void RemoveBoundaryWallIfPresent(int x, int y) { if (_adapter.GetParticleTypeAt(x, y) == PrototypeParticleType.Wall) { _adapter.RemoveParticle(x, y); } } private void UpdateBoundsStats() { if (_adapter.ParticleCount == 0) { _frameStats.MinActiveX = 0; _frameStats.MinActiveY = 0; _frameStats.MaxActiveX = 0; _frameStats.MaxActiveY = 0; return; } var minX = int.MaxValue; var minY = int.MaxValue; var maxX = int.MinValue; var maxY = int.MinValue; foreach (var ((x, y), _) in _adapter.ParticleEntries) { minX = Math.Min(minX, x); minY = Math.Min(minY, y); maxX = Math.Max(maxX, x); maxY = Math.Max(maxY, y); } _frameStats.MinActiveX = minX; _frameStats.MinActiveY = minY; _frameStats.MaxActiveX = maxX; _frameStats.MaxActiveY = maxY; } private bool TryNormalizeCoordinate(ref int x, ref int y) { if (_settings.WrapParticles) { x = ((x % _adapter.Width) + _adapter.Width) % _adapter.Width; y = ((y % _adapter.Height) + _adapter.Height) % _adapter.Height; return true; } return x >= 0 && x < _adapter.Width && y >= 0 && y < _adapter.Height; } private bool IsBoundary(int x, int y) => x == 0 || y == 0 || x == _adapter.Width - 1 || y == _adapter.Height - 1; private static Dictionary BuildParticleProfiles(ParticleLibrary library) { var idLookup = new Dictionary(StringComparer.OrdinalIgnoreCase); for (ushort typeId = 1; typeId <= library.Definitions.Count; typeId++) { var definition = library.GetDefinition(typeId); idLookup[definition.Id] = typeId; } var result = new Dictionary(StringComparer.OrdinalIgnoreCase); for (ushort typeId = 1; typeId <= library.Definitions.Count; typeId++) { var definition = library.GetDefinition(typeId); result[definition.Id] = CreateParticleProfile(typeId, definition, idLookup); } return result; } private static PrototypeParticle CreateParticleProfile(ushort typeId, ParticleDef definition, IReadOnlyDictionary idLookup) { var motionType = ResolveMotionType(definition); if (motionType == PrototypeParticleType.Empty) { return default; } var runtime = ParticleRuntimeProfileBuilder.Build(definition); var velocity = definition.Velocity > 0f ? definition.Velocity : definition.Kind switch { ParticleKind.Solid => 0.45f, ParticleKind.Liquid => 0.35f, ParticleKind.Gas => 0.25f, _ => 0.35f, }; var friction = definition.Friction; var viscosity = definition.Viscosity; switch (motionType) { case PrototypeParticleType.Sand: velocity = MathF.Max(0.5f, velocity); friction = Math.Clamp(friction * 0.75f, 0f, 1f); viscosity = Math.Clamp(viscosity * 0.75f, 0f, 1.25f); break; case PrototypeParticleType.Water: velocity = MathF.Max(0.4f, velocity); friction = Math.Clamp(friction * 0.6f, 0f, 1f); viscosity = Math.Clamp(viscosity * 0.65f, 0f, 1.25f); break; case PrototypeParticleType.Steam: velocity = MathF.Max(0.45f, velocity + (runtime.Balance.UpwardBias * 0.35f)); friction = Math.Clamp(friction * 0.4f, 0f, 0.75f); viscosity = Math.Clamp(viscosity * 0.5f, 0f, 0.85f); break; } var flags = PrototypeParticleFlags.None; if (definition.Id is "water" or "wet_sand" or "mud") { flags |= PrototypeParticleFlags.WaterLike; } if (definition.Id is "fire" or "plasma" or "ember" or "energy") { flags |= PrototypeParticleFlags.FireLike | PrototypeParticleFlags.HotSource; } if (definition.Id == "lava" || definition.Id == "burning_wood" || definition.Id.StartsWith("molten_", StringComparison.Ordinal)) { flags |= PrototypeParticleFlags.HotSource; } if (definition.Id == "acid") { flags |= PrototypeParticleFlags.Acidic; } var hydrateTargetTypeId = definition.Id switch { "sand" when idLookup.TryGetValue("wet_sand", out var wetSand) => wetSand, "dirt" when idLookup.TryGetValue("mud", out var mud) => mud, _ => (ushort)0, }; var pressureResponse = MathF.Max(0.15f, runtime.Balance.PressureSensitivity); return new PrototypeParticle( TypeId: typeId, Id: definition.Id, MotionType: motionType, Kind: definition.Kind, BehaviorKind: runtime.Balance.BehaviorKind, R: definition.Color.R, G: definition.Color.G, B: definition.Color.B, Mass: MathF.Max(0.05f, definition.Mass), Velocity: Math.Clamp(velocity, 0.05f, 1.4f), Friction: Math.Clamp(friction, 0f, 1.5f), Viscosity: Math.Clamp(viscosity, 0f, 2f), IsStatic: definition.IsStatic || motionType == PrototypeParticleType.Wall, Flags: flags, IsMolten: definition.Id == "lava" || definition.Id.StartsWith("molten_", StringComparison.Ordinal), HydrateTargetTypeId: hydrateTargetTypeId, MeltTypeId: ResolveOptionalTarget(idLookup, definition.Melt), EvaporateTypeId: ResolveOptionalTarget(idLookup, definition.Evaporate), SolidifyTypeId: ResolveOptionalTarget(idLookup, definition.Solidify), FreezeTypeId: ResolveOptionalTarget(idLookup, definition.Freeze), BrokenTypeId: ResolveOptionalTarget(idLookup, definition.Broken), PressureThreshold: definition.PressureThreshold, PressureResistance: definition.PressureResistance, PressureTolerance: definition.PressureTolerance, PressureThresholdDuration: checked((short)Math.Clamp(definition.PressureThresholdDuration, 0, short.MaxValue)), PressureResponse: pressureResponse, ForceResponseMultiplier: MathF.Max(0.1f, runtime.Balance.ForceResponseMultiplier), LateralFlowMultiplier: MathF.Max(0.05f, runtime.Balance.LateralFlowMultiplier), DiagonalFlowMultiplier: MathF.Max(0.05f, runtime.Balance.DiagonalFlowMultiplier), UpwardBias: runtime.Balance.UpwardBias, SideDriftBias: runtime.Balance.SideDriftBias, InitialTemperature: definition.Temperature, MeltTemperature: definition.MeltTemperature ?? float.PositiveInfinity, EvaporateTemperature: definition.EvaporateTemperature ?? float.PositiveInfinity, SolidifyTemperature: definition.SolidifyTemperature ?? float.NegativeInfinity, FreezeTemperature: definition.FreezeTemperature ?? float.NegativeInfinity, BurnDuration: definition.BurnDuration, BurnTemperature: definition.BurnTemperature, BurnRate: MathF.Max(0.05f, definition.BurnRate), BurningInit: definition.Burning, DefaultLifetime: ResolveDefaultLifetime(definition, runtime.Balance), HeatEmission: definition.HeatEmission * MathF.Max(0.01f, runtime.Balance.HeatEmissionMultiplier), SmokeSpawnChance: runtime.Balance.SmokeSpawnChance, EmberSpawnChance: runtime.Balance.EmberSpawnChance, Hardness: definition.Hardness, Durability: definition.Durability, Flamability: definition.Flamability, Conductivity: definition.Conductivity, Conductive: definition.Conductive, AmbientCoolingMultiplier: runtime.Balance.AmbientCoolingMultiplier, NeighborHeatTransferMultiplier: runtime.Balance.NeighborHeatTransferMultiplier, PhaseTransitionHysteresis: runtime.Balance.PhaseTransitionHysteresis, ProduceTypeId: ResolveOptionalTarget(idLookup, definition.Produces), ProducesOnDeathTypeId: ResolveOptionalTarget(idLookup, definition.ProducesOnDeath)); } private static ushort ResolveOptionalTarget(IReadOnlyDictionary idLookup, string? particleId) { if (string.IsNullOrWhiteSpace(particleId)) { return 0; } return idLookup.TryGetValue(particleId, out var typeId) ? typeId : (ushort)0; } private static 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 static PrototypeParticleType ResolveMotionType(ParticleDef definition) { if (definition.Id is "air" or "wind" or "gravity_well" or "repulsor") { return PrototypeParticleType.Empty; } if (definition.Id == "wall" || definition.IsStatic) { return PrototypeParticleType.Wall; } if (definition.Id is "fire" or "plasma" or "smoke" or "steam" or "spark" or "energy") { return PrototypeParticleType.Steam; } if (definition.Id is "burning_wood") { return PrototypeParticleType.Wall; } if (definition.Id is "ember" or "snow") { return PrototypeParticleType.Sand; } return definition.Kind switch { ParticleKind.Gas => PrototypeParticleType.Steam, ParticleKind.Liquid => PrototypeParticleType.Water, ParticleKind.Solid => PrototypeParticleType.Sand, _ => PrototypeParticleType.Empty, }; } }