Bunyip a game engine in Go GitHub

Package github.com/matjam/bunyip/particle

particle

Package particle simulates 2D particles on the CPU and draws them through the sprite stream: fire, smoke, sparks, rain and confetti from an Emitter of plain fields. To make a System, call New with an Emitter, then call Update each step and Draw each frame.

An Emitter sets how many particles a second (or a Burst of how many at once), where they start (a point, a circle, a rectangle, a line), their speed, direction and spread, lifetime, size and colour over life as Curves and Gradients, gravity and drag, spin, and the texture or region each is drawn with, additive or blended. The presets (Fire, Smoke, Sparks, Rain, Confetti and more) are starting points to tweak. A System owns the live particles and can be moved or stopped and asked how many are alive. To pause it, stop calling Update. Thousands of particles are cheap. Tens of thousands still draw as one batch but cost CPU in Update.

For hundreds of thousands, use GPUSystem instead. NewGPU takes the same Emitter and offers the same methods, but keeps its particles as numbers in parallel arrays, moves them with plain loops, and draws them as one instanced call rather than as sprites; Draw3D puts the same system in the 3D scene as camera-facing quads. Setting Emitter.Stateless avoids advancing per-particle state, computing every particle from the seed and the clock, for effects whose particles never interact. Stop fixes its final birth time while existing particles age out; Start resets the clock and emission. Finished checks their lifetimes even before the next Draw.

Emitter fields follow "zero means the default". An empty Emitter emits nothing but is valid, and every preset is an Emitter a game can tweak before or after New. Sizes and positions are in view units, angles in radians measured from +X towards +Y (so -Pi/2 points up the screen).

Index

Examples

Example

A campfire: a preset tweaked before New, updated each step and drawn each frame. Drawing needs a Graphics, so it is only shown here.

package main

import (
	"fmt"

	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/particle"
)

func main() {
	fire := particle.Fire()
	fire.Position = lin.V2(480, 400)
	fire.Rate = 120
	fire.Prewarm = 1
	sys := particle.New(fire)

	// In Update:
	sys.Update(1.0 / 60)
	// In Draw:
	//	sys.Draw(ctx.Gfx)

	fmt.Println(sys.Emitting(), sys.Alive() > 0)
}
Output
true true

Functions

Save source

func Save(e Emitter) ([]byte, error)

Save writes an emitter as indented JSON, the form an editor saves and asset.Emitter loads.

SoftCircle source

func SoftCircle(size int) *image.NRGBA

SoftCircle draws a white disc that fades to transparent at its edge, the texture most glowing particles want. Upload it with gfx.Graphics.NewTexture, with Linear filtering, and set it as an Emitter's Texture.

Types

type ColorKey source

type ColorKey struct {
	T     float32
	Color gfx.Color
}

ColorKey is one point of a colour over a particle's lifetime.

type Curve source

type Curve []Key

Curve is a value over a particle's lifetime: keyframes of (t, value) with t from 0 (born) to 1 (dying), interpolated linearly and held flat outside the first and last key. An empty Curve is 1 everywhere, so leaving a curve field blank means "no change".

Constant source

func Constant(v float32) Curve

Constant is a Curve that holds v for the whole lifetime.

CurveOf source

func CurveOf(points []lin.Vec2) Curve

CurveOf builds a Curve from (t, value) pairs, the other half of Points. The pairs must be in increasing t, which is what the editor keeps them in.

Keys source

func Keys(pairs ...float32) Curve

Keys builds a Curve from t, value pairs: Keys(0, 0, 0.2, 1, 1, 0) rises quickly then fades. Keys must be in increasing t; a trailing unpaired value is ignored.

Linear source

func Linear(a, b float32) Curve

Linear is a Curve from a at birth to b at death.

At source

func (c Curve) At(t float32) float32

At evaluates the curve at t in 0..1.

Points source

func (c Curve) Points() []lin.Vec2

Points returns the curve's keys as (t, value) pairs, the form ui.CurveEditor edits. An empty curve gives an empty slice, not the 1 it evaluates to.

type Emitter source

type Emitter struct {
	// Position is where the system starts; SetPosition moves it later.
	Position lin.Vec2

	// Rate is particles born per second while the system is emitting.
	// Zero births none over time, for effects that only Burst.
	Rate float32
	// Burst is how many particles are born at once by Start (including
	// the Start inside New). Zero is none. System.Burst takes its own
	// count.
	Burst int
	// Lifetime is how long each particle lives, in seconds. Zero is 1.
	Lifetime Range

	// Shape is where particles are born relative to the position. The
	// zero Shape is a point.
	Shape Shape
	// Direction is the angle particles travel in, radians from +X
	// towards +Y: 0 is right, -Pi/2 is up. Spread is the width of the
	// cone around it, so 2*Pi is every direction. Both default to zero.
	Direction float32
	Spread    float32
	// Speed is the starting speed in units per second. Zero is still.
	Speed Range

	// Acceleration is applied every second, so lin.V2(0, 400) is
	// gravity on a +Y-down screen. Zero is none.
	Acceleration lin.Vec2
	// Damping is the fraction of velocity lost per second. Zero is none.
	Damping float32
	// RadialAccel pushes particles away from where they were born
	// (negative pulls them back) and TangentialAccel pushes them around
	// it, both in units per second per second. Zero is none.
	RadialAccel     float32
	TangentialAccel float32

	// Size is a particle's width in view units at birth. Zero is the
	// texture's own width, or 8 with no texture. SizeOverLife scales it
	// over the lifetime; an empty curve is 1.
	Size         Range
	SizeOverLife Curve
	// Aspect is the height as a multiple of the width. Zero is the
	// texture's own proportion, or 1 with no texture.
	Aspect float32
	// Rotation is the starting angle in radians and Spin the turn in
	// radians per second. Zero is none.
	Rotation Range
	Spin     Range

	// Color tints particles at birth and ColorEnd at death; zero Color
	// is white and zero ColorEnd is Color. ColorOverLife, when set,
	// replaces both with keyframes across the lifetime. AlphaOverLife
	// multiplies the alpha; an empty curve is 1. Palette, when set,
	// gives each particle a random colour from it that multiplies the
	// colour over life, for mixed confetti.
	Color         gfx.Color
	ColorEnd      gfx.Color
	ColorOverLife []ColorKey
	AlphaOverLife Curve
	Palette       []gfx.Color

	// Texture is drawn for each particle; nil draws a plain quad, which
	// is fine for sparks and rain. Region draws a piece of a texture
	// instead, and Sheet plays frames across the lifetime: Frames lists
	// the frame indices to play (empty is every frame) and FrameOverLife
	// maps lifetime to a position in that list (empty is linear). Sheet
	// takes precedence over Region, and Region over Texture.
	Texture       *gfx.Texture
	Region        gfx.Region
	Sheet         *gfx.Sheet
	Frames        []int
	FrameOverLife Curve

	// TextureName names the image an emitter saved as JSON wants as its
	// Texture, relative to the emitter's own file. The engine never
	// reads it: asset.Emitter loads and sets the texture, and an editor
	// writes it. Zero is empty, for an effect that draws plain quads.
	TextureName string

	// Blend is the blend mode particles draw with; zero is alpha
	// blending, gfx.BlendAdd glows. Layer is the sprite layer; zero is 0.
	Blend gfx.Blend
	Layer int

	// WorldSpace keeps particles where they were born when the system
	// moves, as smoke should. The default, local space, carries them
	// with it, as a thruster flame should.
	WorldSpace bool

	// Max caps live particles; stateful births beyond it are discarded.
	// Stateless systems retain the newest births within the cap.
	// Nonpositive values mean 1000.
	Max int
	// Seed starts the random stream, so the same seed replays the same
	// effect. Zero is a fixed seed.
	Seed uint64
	// Prewarm simulates this many seconds at Start so a fire is already
	// burning on its first frame. Zero is none.
	Prewarm float32

	// Stateless makes a GPUSystem keep no per-particle state: every
	// particle is a closed-form function of the seed, its index in the
	// stream and the clock. Simulation history is unnecessary, but the
	// system still allocates capacity and draw buffers proportional to Max.
	// The effect is exactly the same for the same settings and clock, and is
	// already running at time zero with no Prewarm. It suits the effects
	// whose particles never interact: rain, snow, sparks, dust, stars.
	//
	// A stateless emitter ignores Burst, Prewarm, WorldSpace,
	// RadialAccel and TangentialAccel, which have no closed form, and it
	// needs a Rate and a Lifetime. Damping and Acceleration work. The
	// plain System ignores the field. Zero simulates each particle step
	// by step, which is the default.
	Stateless bool
}

Emitter describes an effect: how often particles are born, where, how they move, and how they look from birth to death. Every field has a documented zero default, so an effect can start from an empty Emitter or a preset and set only what it needs. Emitters are plain values: copy one, change a field, and pass it to New or System.SetEmitter.

Example

An emitter from scratch: a ring of green motes drifting outward and fading, with a curve for size and keyframes for colour.

package main

import (
	"fmt"

	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/particle"
)

func main() {
	e := particle.Emitter{
		Rate:         30,
		Lifetime:     particle.Range{Min: 1, Max: 2},
		Shape:        particle.Ring(20),
		Spread:       6.3, // every direction
		Speed:        particle.Range{Min: 10, Max: 30},
		RadialAccel:  40,
		Size:         particle.Range{Min: 4, Max: 8},
		SizeOverLife: particle.Keys(0, 0, 0.2, 1, 1, 0),
		ColorOverLife: []particle.ColorKey{
			{T: 0, Color: gfx.RGB(120, 255, 160)},
			{T: 1, Color: gfx.RGB(20, 80, 40)},
		},
		AlphaOverLife: particle.Linear(1, 0),
		Blend:         gfx.BlendAdd,
	}
	sys := particle.New(e)
	sys.Update(0.5)
	fmt.Println(sys.Alive())
}
Output
15

Confetti source

func Confetti() Emitter

Confetti pops 150 flat, spinning, coloured pieces upward that fall and fade; a burst-only effect that is Finished once they are gone.

Fire source

func Fire() Emitter

Fire is a rising flame: additive, yellow at the core fading through orange to dark red, shrinking as it climbs.

Load source

func Load(data []byte) (Emitter, error)

Load reads an emitter from the JSON Save writes. The GPU fields are left nil; set Texture, Region or Sheet after loading, or use asset.Emitter, which loads the texture TextureName asks for.

Rain source

func Rain() Emitter

Rain falls from a line 800 units wide at the system's position; set Shape to Line(lin.V2(width, 0)) to match the screen.

Smoke source

func Smoke() Emitter

Smoke drifts up slowly, spreading and thinning as it goes. It is a WorldSpace effect so a trail stays behind a moving source.

Sparks source

func Sparks() Emitter

Sparks fly out in every direction and fall under gravity: a burst of 40 per Start or Burst, additive, bright then fading.

MarshalJSON source

func (e Emitter) MarshalJSON() ([]byte, error)

MarshalJSON writes the emitter's plain fields. The texture, region and sheet are not written; TextureName is, so a loader can put them back.

UnmarshalJSON source

func (e *Emitter) UnmarshalJSON(data []byte) error

UnmarshalJSON reads an emitter written by MarshalJSON. Fields the file leaves out keep their zero, which is their documented default, so a short file is a valid emitter. The texture, region and sheet are left alone, so an emitter can be unmarshalled over one that already has them.

type GPUSystem source

type GPUSystem struct {
	// contains filtered or unexported fields
}

GPUSystem simulates particles on the CPU and draws them as GPU instances. It supports larger counts than the per-sprite System path. It keeps each particle as a few numbers in parallel arrays, moves them with plain loops over those arrays, and draws the whole system as one instanced draw call through gfx.DrawParticles or gfx.DrawParticles3D.

To use one, call NewGPU with an Emitter, then Update each step and Draw each frame, as with System. The difference is what it costs: no per-particle sprite is built and no vertices are written, so the frame cost is the simulation plus one upload. Raise Emitter.Max, which defaults to 1000 for the CPU path.

A GPUSystem draws every particle with one texture and one blend mode. Layering against sprites still works: a batch takes the layer the emitter names, and sprites on lower layers draw under it.

NewGPU source

func NewGPU(e Emitter) *GPUSystem

NewGPU makes an instanced system for an emitter and starts it: the emitter's Burst is born at its Position and its Prewarm runs. The arrays are allocated once, at the emitter's Max.

Alive source

func (s *GPUSystem) Alive() int

Alive is the number of live particles. For a stateless emitter it is what the last Draw or Draw3D produced, because those particles are computed as they are drawn rather than kept.

Burst source

func (s *GPUSystem) Burst(n int)

Burst births n particles now, whether or not the system is emitting, up to the emitter's Max. A stateless emitter ignores it: its stream is a function of the clock and nothing can be added to it.

Clear source

func (s *GPUSystem) Clear()

Clear kills every stateful particle at once without stopping emission. For a stateless emitter it only resets Alive's cached draw count; the next draw reconstructs the stream. Use Stop and continue Update to drain it.

Clock source

func (s *GPUSystem) Clock() float32

Clock is how many seconds a stateless system has run. It stays zero for a stateful one.

Draw source

func (s *GPUSystem) Draw(g *gfx.Graphics)

Draw queues every live particle as one instanced draw, with the emitter's blend mode and layer; both are restored afterwards.

Draw3D source

func (s *GPUSystem) Draw3D(g *gfx.Graphics, soft float32)

Draw3D queues every live particle as camera-facing quads in the 3D scene, one instanced draw, with the emitter's blend mode. The simulated plane is placed by SetPlane, so sizes and positions are in world units. soft fades a particle out over that many world units as it approaches the geometry behind it; zero draws hard edges.

Emitter source

func (s *GPUSystem) Emitter() Emitter

Emitter returns the emitter the system runs.

Emitting source

func (s *GPUSystem) Emitting() bool

Emitting reports whether the system births particles over time: it has been started, not stopped, and its emitter has a Rate.

Finished source

func (s *GPUSystem) Finished() bool

Finished reports that the system is not emitting and has no live particles, so a one-shot effect can be dropped. For a stateless system it checks lifetimes at the current clock, without requiring a Draw.

Plane source

func (s *GPUSystem) Plane() (origin, xAxis, yAxis lin.Vec3)

Plane returns the plane Draw3D places particles in.

Position source

func (s *GPUSystem) Position() lin.Vec2

Position is where the system is.

Quads source

func (s *GPUSystem) Quads() []gfx.ParticleQuad

Quads returns the instances the last Draw or Draw3D built, for a game that wants to place them itself. The slice is reused by the next build; copy what must be kept.

SetClock source

func (s *GPUSystem) SetClock(t float32)

SetClock moves a stateless system to a time, so an effect can be scrubbed, rewound or jumped forward without simulating the gap. Every particle is computed from the clock, so the result is the same as having run to it. It does nothing to a stateful system, whose particles are the accumulation of its steps. A stopped stateless system retains its final birth time when the clock changes.

SetEmitter source

func (s *GPUSystem) SetEmitter(e Emitter)

SetEmitter retunes the system: later births use the new emitter and every live particle is drawn with its look. Stateful particles keep the palette tint chosen at birth. The random stream, the position and the particles are kept. Raising Max reallocates; lowering it immediately truncates stateful particles to the new cap. Stateless particles are recomputed from the new settings, including Seed.

SetPlane source

func (s *GPUSystem) SetPlane(origin, xAxis, yAxis lin.Vec3)

SetPlane places the simulated plane in the world for Draw3D: a particle at (x, y) sits at origin + xAxis*x + yAxis*y. The default is the world's xy plane with y inverted, so the emitter's up (-Y, as on screen) is the world's up. The axes need not be unit length; scaling them scales the effect.

SetPosition source

func (s *GPUSystem) SetPosition(p lin.Vec2)

SetPosition moves the system. Particles in a WorldSpace system stay where they are; the rest move with it. Stateless systems always move their entire stream because they do not retain birth positions.

Start source

func (s *GPUSystem) Start()

Start begins emitting, births the emitter's Burst and runs its Prewarm. NewGPU calls it; call it again to restart a stopped system. A stateless emitter has neither a burst nor a prewarm: its stream is already populated at time zero. Start resets the clock and permits new births again.

Stop source

func (s *GPUSystem) Stop()

Stop ends emission over time. Live particles die out on their own; Finished reports when they have. A stateless system keeps this clock as its final birth time; calling Stop again keeps the original cutoff.

Update source

func (s *GPUSystem) Update(dt float64)

Update advances the simulation by dt seconds: particles age, move and die, and new ones are born at the emitter's Rate, with fractional births carried to the next update. A stateless emitter only advances its clock here; its particles are computed in Draw.

type Key source

type Key struct{ T, V float32 }

Key is one point on a Curve.

type Particle source

type Particle struct {
	Pos, Vel lin.Vec2
	Origin   lin.Vec2 // where it was born, the centre for radial and tangential acceleration
	Age      float32  // seconds alive
	Life     float32  // seconds it lives
	Size     float32  // width at birth
	Rotation float32
	Spin     float32
	Tint     gfx.Color // the palette colour, white without one
}

Particle is one live particle. Pos is in world units for a WorldSpace system and relative to the system's position otherwise.

type Range source

type Range struct{ Min, Max float32 }

Range is a span a particle picks a value from at birth, uniformly. When Max is not above Min every particle gets Min, so Range{Min: 2} is a fixed value.

type Shape source

type Shape struct {
	Kind   ShapeKind
	Radius float32  // ShapeCircle
	W, H   float32  // ShapeRect
	To     lin.Vec2 // ShapeLine: the far end, relative to the position
	// Edge births particles on the outline only: the rim of a circle or
	// the border of a rectangle.
	Edge bool
}

Shape is where particles are born, relative to the system's position. The zero Shape is a point. Point, Circle, Ring, Rect and Line make the common ones.

Circle source

func Circle(radius float32) Shape

Circle births particles anywhere in a disc.

Line source

func Line(to lin.Vec2) Shape

Line births particles along the segment from the position to position + to: a rain cloud along the top of the screen.

Point source

func Point() Shape

Point births every particle at the system's position.

Rect source

func Rect(w, h float32) Shape

Rect births particles anywhere in a box centred on the position.

Ring source

func Ring(radius float32) Shape

Ring births particles on the rim of a circle.

type ShapeKind source

type ShapeKind uint8

ShapeKind is the area particles are born in.

const (
	ShapePoint  ShapeKind = iota // the system's position
	ShapeCircle                  // a disc of Radius, or its rim with Edge
	ShapeRect                    // a W by H box centred on the position, or its border with Edge
	ShapeLine                    // the segment from the position to position + To
)

type System source

type System struct {
	// contains filtered or unexported fields
}

System runs one Emitter: it births particles, moves them and draws them. Update advances by a step; Draw queues every live particle. Create it with New; the zero value has no random source for births.

New source

func New(e Emitter) *System

New makes a system for an emitter and starts it: the emitter's Burst is born at its Position and its Prewarm runs. The particle slice is allocated once, at the emitter's Max.

Alive source

func (s *System) Alive() int

Alive is the number of live particles.

Burst source

func (s *System) Burst(n int)

Burst births n particles now, whether or not the system is emitting, up to the emitter's Max.

Clear source

func (s *System) Clear()

Clear kills every live particle at once.

Draw source

func (s *System) Draw(g *gfx.Graphics)

Draw queues every live particle, oldest first, with the emitter's blend mode and layer; both are restored afterwards.

Emitter source

func (s *System) Emitter() Emitter

Emitter returns the emitter the system runs.

Emitting source

func (s *System) Emitting() bool

Emitting reports whether the system births particles over time: it has been started, not stopped, and its emitter has a Rate.

Finished source

func (s *System) Finished() bool

Finished reports that the system is not emitting and has no live particles, so a one-shot effect can be dropped.

Example

A one-shot effect: confetti bursts on Start and the system is Finished once the pieces have fallen and faded.

package main

import (
	"fmt"

	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/particle"
)

func main() {
	e := particle.Confetti()
	e.Position = lin.V2(200, 100)
	sys := particle.New(e)
	fmt.Println(sys.Alive(), sys.Finished())
	for range 300 {
		sys.Update(1.0 / 60)
	}
	fmt.Println(sys.Alive(), sys.Finished())
}
Output
150 false
0 true

Particles source

func (s *System) Particles() []Particle

Particles returns the live particles, oldest first, for inspection. The slice is reused by Update; copy what must be kept.

Position source

func (s *System) Position() lin.Vec2

Position is where the system is.

SetEmitter source

func (s *System) SetEmitter(e Emitter)

SetEmitter retunes the system: later births use the new emitter and every live particle is drawn with its look. The random stream, the position and the particles are kept. Raising Max reallocates; lowering it blocks new births until the live count falls below the new cap. Existing particles keep their birth palette tint, size and lifetime; acceleration, damping and appearance curves use the new settings.

SetPosition source

func (s *System) SetPosition(p lin.Vec2)

SetPosition moves the system. Particles in a WorldSpace system stay where they are; the rest move with it.

Start source

func (s *System) Start()

Start begins emitting, births the emitter's Burst and runs its Prewarm. New calls it; call it again to restart a stopped system.

Stop source

func (s *System) Stop()

Stop ends emission over time. Live particles die out on their own; Finished reports when they have.

Update source

func (s *System) Update(dt float64)

Update advances the simulation by dt seconds; nonpositive dt does nothing. Particles age, move and die, and new ones are born at the emitter's Rate, with fractional births carried to the next update.

Source files

curve.go emitter.go example_test.go gpu.go gpu_bench_test.go gpu_test.go json.go json_test.go palette_regression_test.go particle_test.go presets.go stateless.go stateless_stop_test.go system.go