Files
Voile/Voile/Source/Systems/ParticleSystem.cs

327 lines
11 KiB
C#

using System.Numerics;
using Voile.Resources;
using Voile.Resources.DataReaders;
using Voile.Utils;
namespace Voile.Systems.Particles;
public struct Particle
{
public Particle()
{
}
public int EmitterIndex { get; set; }
public int ColorArgb { get; set; }
public Vector2 Position { get; set; }
public Vector2 Velocity { get; set; }
public float AngularVelocity { get; set; }
public float LifeTime { get; set; } = 1.0f;
public float LifeTimeRemaining { get; set; }
public float Scale { get; set; }
public float Rotation { get; set; }
public bool Alive { get; set; } = true;
}
public class ParticleEmitterSettings
{
public int MaxParticles { get; set; } = 16;
public float EmitRadius { get; set; }
public float LifeTime { get; set; } = 1.0f;
public float Explosiveness { get; set; }
public Vector2 Direction { get; set; } = -Vector2.UnitY;
public float LinearVelocity { get; set; } = 980.0f;
public float AngularVelocity { get; set; }
public float AngularVelocityRandom { get; set; }
public float LinearVelocityRandom { get; set; } = 0.5f;
public Vector2 Gravity { get; set; } = Vector2.UnitY * 980f;
public float ScaleBegin { get; set; } = 1.0f;
public float ScaleEnd { get; set; } = 0.0f;
public Color ColorBegin { get; set; } = Color.White;
public Color ColorEnd { get; set; } = Color.Black;
public float Damping { get; set; } = 1.0f;
}
public class ParticleEmitterSettingsResource : Resource
{
public ParticleEmitterSettings Settings { get; private set; }
public ParticleEmitterSettingsResource(string path, ParticleEmitterSettings settings) : base(path)
{
Settings = settings;
}
}
/// <summary>
/// Loads <see cref="ParticleEmitterSettingsResource"/> from a provided TOML data file.
/// </summary>
public class ParticleEmitterSettingsResourceLoader : ResourceLoader<ParticleEmitterSettingsResource>
{
public override IEnumerable<string> SupportedExtensions => new string[] {
"toml"
};
protected override ParticleEmitterSettingsResource LoadResource(string path)
{
// TODO: this is ugly, better to make some sort of wrapper API for TOML files.
var settings = new ParticleEmitterSettings();
using (var reader = new TomlDataReader("ParticleEmitterSettings"))
{
reader.Read(File.Open(path, FileMode.Open));
settings.MaxParticles = reader.GetInt("MaxParticles");
settings.EmitRadius = reader.GetInt("EmitRadius");
settings.LifeTime = reader.GetFloat("LifeTime", 1.0f);
settings.Explosiveness = reader.GetFloat("Explosiveness");
settings.Direction = reader.GetVector2("Direction", Vector2.Zero);
settings.LinearVelocity = reader.GetFloat("LinearVelocity", 980f);
settings.AngularVelocity = reader.GetFloat("AngularVelocity");
settings.AngularVelocityRandom = reader.GetFloat("AngularVelocityRandom", 1.0f);
settings.LinearVelocityRandom = reader.GetFloat("LinearVelocityRandom", 1.0f);
settings.Gravity = reader.GetVector2("Gravity", Vector2.UnitY * 980f);
settings.ScaleBegin = reader.GetFloat("ScaleBegin");
settings.ScaleEnd = reader.GetFloat("ScaleEnd");
settings.ColorBegin = reader.GetColor("ColorBegin", Color.White);
settings.ColorEnd = reader.GetColor("ColorEnd", Color.Black);
settings.Damping = reader.GetFloat("Damping", 1.0f);
}
return new ParticleEmitterSettingsResource(path, settings);
}
}
/// <summary>
/// Emits and simulates particles from a provided particle segment.
/// </summary>
public class ParticleEmitter : IUpdatableSystem
{
/// <summary>
/// A segment of particles this emitter simulates.
/// </summary>
public ReadOnlySpan<Particle> Particles => _particles.AsSpan();
/// <summary>
/// Origin position in the world of this emitter.
/// </summary>
public Vector2 OriginPosition => _originPosition;
/// <summary>
/// <see cref="ParticleEmitterSettings"/> for this emitter.
/// </summary>
public ParticleEmitterSettings Settings => _settingsResource.Value.Settings;
public int ParticleArrayOffset => _particles.Offset;
/// <summary>
/// Constructs a new <see cref="ParticleEmitter"/>.
/// </summary>
/// <param name="originPosition">World origin position.</param>
/// <param name="settingsResource">Emitter settings resource.</param>
/// <param name="particles">Particle segment that this emitter will simulate.</param>
public ParticleEmitter(Vector2 originPosition, ResourceRef<ParticleEmitterSettingsResource> settingsResource, ArraySegment<Particle> particles)
{
_originPosition = originPosition;
_settingsResource = settingsResource;
_maxParticles = _settingsResource.Value.Settings.MaxParticles;
_particleIndex = _maxParticles - 1;
_particles = particles;
_random = new LehmerRandom();
}
/// <summary>
/// Restart this emitter.
/// </summary>
/// <param name="particles">New particle segment.</param>
public void Restart(ArraySegment<Particle> particles)
{
for (int i = 0; i < _particles.Count; i++)
{
var particle = _particles[i];
particle.LifeTimeRemaining = 0.0f;
}
_particles = particles;
_maxParticles = Settings.MaxParticles;
_particleIndex = _maxParticles - 1;
}
/// <summary>
/// Updates this emitter's simulation.
/// </summary>
/// <param name="deltaTime"></param>
public void Update(double deltaTime)
{
var rate = (int)MathUtils.Lerp(1, _maxParticles, Settings.Explosiveness);
for (int i = 0; i < rate; i++)
{
Emit();
}
for (int i = 0; i < _maxParticles; i++)
{
var particle = _particles[i];
particle.LifeTimeRemaining = Math.Clamp(particle.LifeTimeRemaining - (float)deltaTime, 0.0f, particle.LifeTime);
var t = particle.LifeTimeRemaining / particle.LifeTime;
particle.Velocity += Settings.Gravity * (float)deltaTime;
particle.Position += particle.Velocity * (float)deltaTime;
particle.Rotation += particle.AngularVelocity * (float)deltaTime;
particle.Scale = MathUtils.Lerp(Settings.ScaleEnd, Settings.ScaleBegin, t);
var color = MathUtils.LerpColor(Settings.ColorEnd, Settings.ColorBegin, t);
particle.ColorArgb = color.Argb;
particle.Velocity -= particle.Velocity * Settings.Damping * (float)deltaTime;
_particles[i] = particle;
}
}
private void Emit()
{
Particle particle = _particles[_particleIndex];
if (!(particle.LifeTimeRemaining <= 0)) return;
// particle.Alive = true;
particle.Position = GetEmitPosition();
particle.Velocity = Settings.Direction * Settings.LinearVelocity;
particle.Velocity += Vector2.One * Settings.LinearVelocityRandom * ((float)_random.NextDouble() - 0.5f);
particle.AngularVelocity = Settings.AngularVelocity;
particle.AngularVelocity += 1f * Settings.AngularVelocityRandom * ((float)_random.NextDouble() - 0.5f);
particle.LifeTime = Settings.LifeTime;
particle.LifeTimeRemaining = particle.LifeTime;
_particles[_particleIndex] = particle;
_particleIndex = --_particleIndex <= 0 ? _maxParticles - 1 : --_particleIndex;
}
private Vector2 GetEmitPosition()
{
var settings = _settingsResource.Value.Settings;
// https://gamedev.stackexchange.com/questions/26713/calculate-random-points-pixel-within-a-circle-image
var angle = _random.NextDouble() * Math.PI * 2;
float radius = (float)Math.Sqrt(_random.NextDouble()) * settings.EmitRadius;
float x = radius * (float)Math.Cos(angle);
float y = radius * (float)Math.Sin(angle);
return new Vector2(x, y);
}
private LehmerRandom _random;
private int _maxParticles;
private int _particleIndex;
private Vector2 _originPosition = Vector2.Zero;
private ArraySegment<Particle> _particles;
private ResourceRef<ParticleEmitterSettingsResource> _settingsResource;
}
/// <summary>
/// CPU based particle simulator.
/// </summary>
public class ParticleSystem : IUpdatableSystem, IDisposable
{
/// <summary>
/// Maximum amount of particles emittable by the system.
/// </summary>
public int ParticleLimit { get; set; } = 8192;
/// <summary>
/// List of particle emitters created for this ParticleSystem.
/// </summary>
public IReadOnlyList<ParticleEmitter> Emitters => _emitters;
/// <summary>
/// Constructs a new <see cref="ParticleSystem"/>.
/// </summary>
public ParticleSystem()
{
_particleIndex = ParticleLimit - 1;
_particles = new Particle[ParticleLimit];
}
/// <summary>
/// Creates an emitter from a <see cref="ParticleEmitterSettingsResource"/>.
/// </summary>
/// <param name="originPosition"></param>
/// <param name="settingsResource"></param>
/// <returns></returns>
public int CreateEmitter(Vector2 originPosition, ResourceRef<ParticleEmitterSettingsResource> settingsResource)
{
var settings = settingsResource.Value.Settings;
if (_emitterSliceOffset + settings.MaxParticles >= ParticleLimit - 1)
{
_logger.Error("Cannot create an emitter! Reached particle limit.");
return -1;
}
var particles = new ArraySegment<Particle>(_particles, _emitterSliceOffset, settings.MaxParticles);
for (int i = 0; i < particles.Count; i++)
{
var particle = particles[i];
particle.LifeTime = settings.LifeTime;
}
var emitter = new ParticleEmitter(originPosition, settingsResource, particles);
_emitters.Add(emitter);
_emitterSliceOffset += settings.MaxParticles;
return _emitters.Count - 1;
}
/// <summary>
/// Restarts an emitter.
/// </summary>
/// <param name="id">Id of an emitter to restart.</param>
/// <exception cref="ArgumentException"></exception>
public void RestartEmitter(int id)
{
if (id > _emitters.Count - 1)
{
throw new ArgumentException($"Emitter with id {id} doesn't exist!");
}
var emitter = _emitters[id];
var particles = new ArraySegment<Particle>(_particles, emitter.ParticleArrayOffset, emitter.Settings.MaxParticles);
emitter.Restart(particles);
}
/// <summary>
/// Updates this particle simulation. It is recommended to use a fixed timestep for the particle simulation for performance reasons.
/// </summary>
/// <param name="deltaTime"></param>
public void Update(double deltaTime)
{
foreach (var emitter in _emitters)
{
emitter.Update(deltaTime);
}
}
public void Dispose()
{
CleanupParticles();
}
private void CleanupParticles() => Array.Clear(_particles);
private Particle[] _particles;
private int _particleIndex;
private int _emitterSliceOffset;
private List<ParticleEmitter> _emitters = new();
private Logger _logger = new(nameof(ParticleSystem));
}