Gondwana Game Engine Development (master) - 2.6.1
Gondwana is a cross-platform 2.5D game and rendering engine written in C#/.NET 8. It provides fine-grained control over rendering, timing, and scene composition, with built-in support for parallax, z-ordering, pixel overhang, collision detection, and particle effects. Gondwana targets desktop and web platforms using SkiaSharp for graphics and pluggable audio backends for desktop and browser targets.
Loading...
Searching...
No Matches
ParticleSurface.cs
1using System.Buffers;
2using System.Drawing;
3using System.Runtime.CompilerServices;
9using SkiaSharp;
10
12
75public sealed partial class ParticleSurface : DirectDrawingMovableBase
76{
77 private readonly Particle[] _particles;
78 private readonly Random _rng = new();
79 private readonly SKPaint _paint = new() { IsAntialias = true };
80 private int _alive;
81 private long _particlesLastTick = 0;
82
83 // If you want textured particles, supply a tilesheet frame and draw bitmap quads instead of circles.
84 private readonly SKBitmap? _particleSprite;
85
90 public readonly List<ParticleEmitter> Emitters = new();
91
95 public int ActiveParticleCount => _alive;
96
129 public float GlobalEmitScale { get; set; } = 1f;
130
160 public SKColor GlobalColorTint { get; set; } = SKColors.White;
161
166 public float GravityX { get; set; } = 0f; // px/s^2
167
172 public float GravityY { get; set; } = 400f; // px/s^2
173
178 public float CullingMarginX { get; set; } = 32f;
179
183 public float CullingMarginY { get; set; } = 32f;
184
185 private ParticleSurface(RenderSurfaceHostBase renderSurfaceHost,
187 SceneLayer? sceneLayer,
188 View? view,
189 Rectangle? screenBounds,
190 Rectangle? worldBounds,
191 string? nickname = null,
192 int maxParticles = 2000,
193 SKBitmap? particleSprite = null)
194 : base(renderSurfaceHost, mode, sceneLayer, view, screenBounds, worldBounds, nickname)
195 {
196 _particles = ArrayPool<Particle>.Shared.Rent(maxParticles);
197 _particleSprite = particleSprite;
198 }
199
210 public ParticleSurface(RenderSurfaceHostBase renderSurfaceHost,
211 SceneLayer sceneLayer,
212 Rectangle? worldBounds,
213 string? nickname = null,
214 int maxParticles = 2000,
215 SKBitmap? particleSprite = null)
216 : this(renderSurfaceHost,
218 sceneLayer,
219 null,
220 null,
221 worldBounds,
222 nickname,
223 maxParticles,
224 particleSprite)
225 { }
226
237 public ParticleSurface(RenderSurfaceHostBase renderSurfaceHost,
238 View view,
239 Rectangle? screenBounds,
240 string? nickname = null,
241 int maxParticles = 2000,
242 SKBitmap? particleSprite = null)
243 : this(renderSurfaceHost,
245 null,
246 view,
247 screenBounds,
248 null,
249 nickname,
250 maxParticles,
251 particleSprite)
252 { }
253
273 public void Burst(ParticleEmitter emitter, int count) => EmitFrom(emitter, count);
274
279 protected override void Dispose(bool disposing)
280 {
281 if (disposing)
282 {
283 ArrayPool<Particle>.Shared.Return(_particles, clearArray: true);
284 _paint.Dispose();
285 _alive = 0;
286 _particlesLastTick = 0;
287 }
288 base.Dispose(disposing);
289 }
290
295 public override void Update(long tick)
296 {
297 if (tick <= _lastTick)
298 return;
299
300 // Compute dt from ticks (seconds)
301 float dt = 0f;
302
303 // no previous tick assume first frame, so skip dt-based updates
304 if (_particlesLastTick > 0)
305 dt = HighResTimer.GetDuration(_particlesLastTick, tick);
306
307 _particlesLastTick = tick;
308
309 // (A) per-emitter motion
310 for (int i = 0; i < Emitters.Count; i++)
311 Emitters[i].OnUpdate?.Invoke(Emitters[i], dt);
312
313 // (B) emission
314 for (int i = 0; i < Emitters.Count; i++)
315 {
316 var em = Emitters[i];
317 float rate = MathF.Max(0f, em.EmitRate * GlobalEmitScale);
318 em._accumulator += rate * dt;
319
320 int toEmit = (int)em._accumulator;
321 if (toEmit > 0) { em._accumulator -= toEmit; EmitFrom(em, toEmit); }
322 }
323
324 // (C) integrate & compact
325 int write = 0;
326 for (int i = 0; i < _alive; i++)
327 {
328 ref var p = ref _particles[i];
329
330 // integrate acceleration (including gravity)
331 p.VX += p.AX * dt;
332 p.VY += p.AY * dt;
333
334 // clamp max velocity
335 if (p.MaxVelocity > 0f)
336 {
337 // thank you, Pythagoras
338 float vx = p.VX, vy = p.VY;
339 float velocity2 = (vx * vx) + (vy * vy);
340 float max2 = p.MaxVelocity * p.MaxVelocity;
341
342 if (velocity2 > max2)
343 {
344 float inv = p.MaxVelocity / MathF.Sqrt(velocity2);
345 p.VX = vx * inv;
346 p.VY = vy * inv;
347 }
348 }
349
350 // integrate velocity
351 p.X += p.VX * dt;
352 p.Y += p.VY * dt;
353
354 // to every season...
355 p.Rotation += p.AngularVel * dt;
356 p.Life -= dt;
357
358 // cull if out of bounds (with margin)
359 Rectangle cullBounds = (Mode == DirectDrawingMode.SceneLayer)
361 : ScreenBounds;
362
363 bool isInView = p.X >= cullBounds.Left - CullingMarginX
364 && p.X <= cullBounds.Right + CullingMarginX
365 && p.Y >= cullBounds.Top - CullingMarginY
366 && p.Y <= cullBounds.Bottom + CullingMarginY;
367
368 if (p.Life > 0 && isInView)
369 _particles[write++] = p;
370 }
371 _alive = write;
372
373 ForceRefresh();
374
375 base.Update(tick);
376 }
377
407 protected override void OnDraw(BackbufferBase backbuffer, RectangleF destRectScreen)
408 {
409 var canvas = backbuffer.Canvas;
410
411 // Source bounds in the particle coordinate space
412 Rectangle srcBounds = (Mode == DirectDrawingMode.SceneLayer)
414 : ScreenBounds;
415
416 // Guard against divide-by-zero (shouldn't happen, but cheap insurance)
417 float sx = (srcBounds.Width > 0) ? (destRectScreen.Width / srcBounds.Width) : 1f;
418 float sy = (srcBounds.Height > 0) ? (destRectScreen.Height / srcBounds.Height) : 1f;
419
420 // Usually uniform; if not, pick one (or average) for size scaling
421 float sSize = (sx + sy) * 0.5f;
422
423 for (int i = 0; i < _alive; i++)
424 {
425 ref var p = ref _particles[i];
426
427 // blend mode
428 _paint.BlendMode = p.BlendMode;
429
430 // life-based fade
431 float t = 1f - (p.Life / p.MaxLife);
432 byte a = (byte)(255 * (1f - t));
433
434 // choose tint: emitter override if set, otherwise current global
435 var tint = p.Tint ?? GlobalColorTint;
436
437 // apply global tint
438 _paint.Color = ApplyTint(p.Color, a, tint);
439
440 // Map particle position from srcBounds space -> destRectScreen space
441 float x = destRectScreen.Left + (p.X - srcBounds.Left) * sx;
442 float y = destRectScreen.Top + (p.Y - srcBounds.Top) * sy;
443
444 if (p.ParticleSprite is null)
445 {
446 float size = (p.Size * sSize) * (1f + 0.5f * t);
447 canvas.DrawCircle(x, y, size, _paint);
448 }
449 else
450 {
451 float s = p.Size * sSize;
452 var dst = new SKRect(x - s * 0.5f, y - s * 0.5f,
453 x + s * 0.5f, y + s * 0.5f);
454
455 canvas.Save();
456 canvas.RotateDegrees(p.Rotation, x, y);
457 canvas.DrawBitmap(p.ParticleSprite, dst, _paint);
458 canvas.Restore();
459 }
460 }
461 }
462
463 private void EmitFrom(ParticleEmitter em, int count)
464 {
465 for (int i = 0; i < count && _alive < _particles.Length; i++)
466 {
467 ref var p = ref _particles[_alive++];
468
469 // Position
470 if (em.JitterX == 0f && em.JitterY == 0f)
471 {
472 p.X = em.Position.X;
473 p.Y = em.Position.Y;
474 }
475 else
476 {
477 (float dx, float dy) = SampleSpawnOffset(em);
478 p.X = em.Position.X + dx;
479 p.Y = em.Position.Y + dy;
480 }
481
482 // Velocity
483 p.VX = NextRange(em.VelocityRangeX.Min, em.VelocityRangeX.Max);
484 p.VY = NextRange(em.VelocityRangeY.Min, em.VelocityRangeY.Max);
485
486 // Acceleration (emitter override or surface default)
487 p.AX = em.GravityX ?? this.GravityX;
488 p.AY = em.GravityY ?? this.GravityY;
489
490 // Life/Size
491 p.MaxLife = p.Life = NextRange(em.LifeRange.Min, em.LifeRange.Max);
492 p.Size = NextRange(em.SizeRange.Min, em.SizeRange.Max);
493
494 // Color & spin
495 p.Color = em.Color;
496 p.Rotation = NextRange(0f, 360f);
497 p.AngularVel = NextRange(-180f, 180f);
498
499 // choose sprite once; no emitter lookups in hot loop
500 p.ParticleSprite = em.ParticleSprite ?? this._particleSprite;
501
502 // tint override
503 p.Tint = em.Tint; // null means "use global at render"
504
505 // max speed clamp
506 p.MaxVelocity = em.MaxVelocity ?? 0f; // 0 = no clamp
507
508 // blend mode
509 p.BlendMode = em.BlendMode;
510
511 // User hook for last-mile per-particle tweaks
512 em.OnSpawn?.Invoke(ref p);
513 }
514 }
515
516 private float NextRange(float min, float max) => (float)(_rng.NextDouble() * (max - min) + min);
517
518 private (float dx, float dy) SampleSpawnOffset(ParticleEmitter em)
519 {
520 float jx = MathF.Abs(em.JitterX);
521 float jy = MathF.Abs(em.JitterY);
522
523 // Handle "line" cases cleanly (one axis zero)
524 if (jx == 0f)
525 return (0f, NextRange(-jy, jy));
526
527 if (jy == 0f)
528 return (NextRange(-jx, jx), 0f);
529
530 switch (em.SpawnDistribution)
531 {
532 default:
533 case ParticleSpawnDistribution.Rectangle:
534 return (NextRange(-jx, jx), NextRange(-jy, jy));
535
536 case ParticleSpawnDistribution.Ellipse:
537 {
538 // Uniform over area of ellipse
539 float a = NextRange(0f, MathF.Tau);
540 float r = MathF.Sqrt(NextRange(0f, 1f)); // sqrt => uniform area
541 return (MathF.Cos(a) * r * jx, MathF.Sin(a) * r * jy);
542 }
543
545 {
546 // Uniform over area of annulus (ellipse-scaled)
547 float inner = Math.Clamp(em.RingInnerRadius01, 0f, 1f);
548 float a = NextRange(0f, MathF.Tau);
549
550 // area-uniform annulus: r = sqrt(inner^2 + u*(1-inner^2))
551 float u = NextRange(0f, 1f);
552 float r = MathF.Sqrt(inner * inner + u * (1f - inner * inner));
553
554 return (MathF.Cos(a) * r * jx, MathF.Sin(a) * r * jy);
555 }
556
557 case ParticleSpawnDistribution.Gaussian:
558 {
559 // Center-weighted, then clamped to ellipse boundary
560 float sd = Math.Clamp(em.GaussianStdDev01, 1e-4f, 10f);
561
562 float dx = NextGaussian() * (jx * sd);
563 float dy = NextGaussian() * (jy * sd);
564
565 // Clamp to ellipse: (dx/jx)^2 + (dy/jy)^2 <= 1
566 float nx = dx / jx;
567 float ny = dy / jy;
568 float q = nx * nx + ny * ny;
569
570 if (q > 1f)
571 {
572 float s = 1f / MathF.Sqrt(q);
573 dx *= s;
574 dy *= s;
575 }
576
577 return (dx, dy);
578 }
579 }
580 }
581
582 private float NextGaussian()
583 {
584 // Box–Muller transform (standard normal)
585 // Guard u1 from 0 to avoid log(0)
586 double u1 = 1.0 - _rng.NextDouble();
587 double u2 = 1.0 - _rng.NextDouble();
588 double mag = Math.Sqrt(-2.0 * Math.Log(u1));
589 double z0 = mag * Math.Cos(Math.Tau * u2);
590 return (float)z0;
591 }
592
593 // Fast, branch-free tint (multiplies RGB by tint).
594 // Alpha combines the particle's base alpha, life fade, and tint alpha.
595 [MethodImpl(MethodImplOptions.AggressiveInlining)]
596 private SKColor ApplyTint(SKColor c, byte lifeAlpha, SKColor tint)
597 {
598 int r = (c.Red * tint.Red) / 255;
599 int g = (c.Green * tint.Green) / 255;
600 int b = (c.Blue * tint.Blue) / 255;
601 int a = (c.Alpha * lifeAlpha * tint.Alpha) / (255 * 255);
602
603 return new SKColor((byte)r, (byte)g, (byte)b, (byte)a);
604 }
605}
View? View
Gets the view to which this direct drawing is attached, or null if the drawing is in scene-layer mode...
long _lastTick
The last engine tick value used for delta-time calculations in Update.
SceneLayer? SceneLayer
Gets the scene layer to which this direct drawing is attached, or null if the drawing is in view mode...
Rectangle WorldBounds
Gets or sets the world-space bounds of this direct drawing in pixels.
DirectDrawingMode Mode
Gets the drawing mode that determines how this direct drawing is positioned and transformed.
Rectangle ScreenBounds
Gets or sets the screen-space bounds of this direct drawing in pixels.
DirectDrawingMovableBase(RenderSurfaceHostBase renderSurfaceHost, DirectDrawingMode mode, SceneLayer? sceneLayer, View? view, Rectangle? screenBounds, Rectangle? worldBounds, string? name=null)
Initializes a new instance of the DirectDrawingMovableBase class with integrated movement capabilitie...
Configures how particles are spawned over time at a given location.
SKColor? Tint
Optional per-emitter tint. If set, it overrides the particle system's GlobalColorTint for particles s...
SKColor Color
Base color. Alpha will be modulated by lifetime fade. You can randomize in OnSpawn if desired.
float JitterX
Gets or sets the horizontal jitter value for spawning particles, which represents a random offset app...
SKBlendMode BlendMode
Blend mode used when rendering particles from this emitter. Defaults to SKBlendMode....
PointF Position
RenderHost / adapter pixel position where particles originate.
ParticleSpawnHandler? OnSpawn
Optional per-particle customization hook (e.g., perlin drift, hue jitter).
float JitterY
Gets or sets the vertical jitter value for spawning particles, which represents a random offset appli...
A flexible particle system for effects like smoke, sparks, fire, or snow, implemented as a DirectDraw...
override void Update(long tick)
Tick-driven update override. Computes delta internally and advances simulation.
float GravityY
Gets or sets the gravitational acceleration along the Y-axis, measured in pixels per second squared....
ParticleSurface(RenderSurfaceHostBase renderSurfaceHost, View view, Rectangle? screenBounds, string? nickname=null, int maxParticles=2000, SKBitmap? particleSprite=null)
Initializes a new instance of the ParticleSurface class for view-based rendering with screen-space co...
override void Dispose(bool disposing)
Releases the particle array back to the pool and disposes managed resources.
readonly List< ParticleEmitter > Emitters
Collection of particle emitters this system updates and renders. Add multiple emitters (e....
ParticleSurface(RenderSurfaceHostBase renderSurfaceHost, SceneLayer sceneLayer, Rectangle? worldBounds, string? nickname=null, int maxParticles=2000, SKBitmap? particleSprite=null)
Initializes a new instance of the ParticleSurface class for scene layer rendering with world-space co...
void Burst(ParticleEmitter emitter, int count)
Immediately spawns a fixed number of particles from the given emitter. Useful for explosions,...
int ActiveParticleCount
Gets the current number of active (alive) particles being simulated.
SKColor GlobalColorTint
Global tint color multiplied against every particle's own color during rendering.
float GlobalEmitScale
Global multiplier applied to all emitter EmitRate values in this particle system. Acts like a master ...
float GravityX
Gets or sets the horizontal component of gravity, measured in pixels per second squared....
float CullingMarginY
Gets or sets the vertical culling margin, in pixels, beyond the bounds of the viewport to keep elemen...
float CullingMarginX
Gets or sets the horizontal culling margin, in pixels, beyond the bounds of the viewport to keep elem...
override void OnDraw(BackbufferBase backbuffer, RectangleF destRectScreen)
Renders all live particles to the current backbuffer.
Represents a base class for managing a graphical backbuffer, and is the in-memory surface where rende...
SKCanvas Canvas
Gets the SkiaSharp canvas used for drawing operations on this backbuffer.
Represents a base class for hosting a render surface, providing functionality for managing rendering ...
ParticleSpawnDistribution
Defines the spatial distribution pattern used when spawning particles within an emitter.
@ Rectangle
Particles are spawned uniformly within a rectangular area. This is the default distribution.
DirectDrawingMode
Specifies where direct drawing operations are applied within the rendering pipeline.
@ View
Performs direct drawing at the view level using screen-space coordinates.
@ SceneLayer
Performs direct drawing within a scene layer using world-space coordinates.
Represents a single particle instance in the particle system.
Definition Particle.cs:15