Bunyip a game engine in Go GitHub

Example examples/particles

Particles

Particles

This program is a tour of the particle package. Several systems run at once: a campfire of fire and smoke, thousands of raindrops falling from a line above the screen as one instanced draw, sparks fired where the mouse is clicked, and a confetti burst on Space. A panel of sliders changes the fire's emission rate and gravity while it is burning.

This example uses the particle package's 2D effects over sprite drawing in gfx. An Emitter is a plain value describing what to emit and how the particles behave; particle.New turns one into a running System; the system is stepped from Update and drawn from Draw. The presets used here, particle.Fire, particle.Smoke, particle.Rain, particle.Sparks and particle.Confetti, return emitters already filled in, and a program changes the fields it cares about. The panel comes from ui; see the interface guide for that half.

The rain is the exception: it runs on a GPUSystem, which takes the same Emitter but keeps its particles in parallel arrays and draws the whole storm as one instanced call rather than a sprite each. Its emitter is also Stateless, so no individual particle state is kept between frames: the system retains its settings and clock, and every drop is a closed form of the seed, its index in the stream and the clock. Pass -drops 200000 and the storm still draws in one call.

Run it:

go run ./examples/particles -seconds 3 -shot out.png

The flags are -seconds N to quit after that long, -shot file.png to write a screenshot halfway through the run, -headless to render without a window, and -drops N for the size of the instanced storm. Escape quits, a left click throws sparks, and Space pops confetti.

Package and state

The game type holds the fonts and textures to destroy at the end, the four systems, a slice of confetti bursts, and the two slider values. It also keeps fireE, the fire's emitter, because a slider edits that value and hands it back to the running system.

// Command particles shows the particle package: a campfire of fire and
// smoke, rain from a line along the top of the screen, sparks where the
// mouse is clicked, and a confetti burst on Space. A panel of sliders
// retunes the fire's rate and gravity while it burns. Escape quits.
package main

import (
	"flag"
	"fmt"
	"os"

	"golang.org/x/image/font/gofont/goregular"

	"github.com/matjam/bunyip/engine"
	"github.com/matjam/bunyip/gfx"
	"github.com/matjam/bunyip/input"
	"github.com/matjam/bunyip/lin"
	"github.com/matjam/bunyip/particle"
	"github.com/matjam/bunyip/ui"
)

type game struct {
	seconds float64
	shot    string
	drops   int

	font  *gfx.Font
	ui    *ui.Context
	soft  *gfx.Texture
	fireE particle.Emitter
	fire  *particle.System
	smoke *particle.System
	// The rain is a GPUSystem rather than a System: tens of thousands of
	// drops as one instanced draw call rather than one sprite each.
	rain     *particle.GPUSystem
	sparks   *particle.System
	bursts   []*particle.System // confetti pops, dropped once Finished
	fireRate float32
	gravity  float32
	shotDone bool
	demoDone bool
}

Init: building the emitters

Init runs once with a live graphics device, which is where GPU resources are created. The font and the soft circle texture are made here and destroyed in Shutdown. particle.SoftCircle(64) returns a 64 by 64 image of a disc that fades out at its edge, the usual sprite for fire and smoke; gfx.TextureOptions{Linear: true} asks for smooth sampling, since the particles are scaled and rotated.

The hearth is placed in view units, with the origin at the top left and positive Y downwards, so ctx.Height-150 is 150 units up from the bottom of the view. Each emitter starts from a preset and is then adjusted:

  • Position is where particles are born, and Shape spreads them over a region. The rain uses particle.Line, a horizontal line the width of the view plus a margin, so drops arrive from off screen.
  • Prewarm advances the stateful fire and smoke before the first frame. Stateless rain ignores this setting: its closed-form stream already includes drops born before time zero.
  • Layer is the sort key the sprites are drawn with. Smoke is 1, fire 2, sparks 3 and confetti 4, so each draws over the one before it whatever order the Draw calls happen to be in.
  • Burst set to zero prevents the initial burst from New. The sparks preset already has a zero Rate, so later sparks only appear when the program calls Burst.

Size is a particle.Range, a minimum and a maximum the system picks between for each particle. The fire's Rate and Acceleration.Y are copied into the slider values so the panel starts at the preset's own numbers.

func (g *game) Init(ctx *engine.Context) error {
	var err error
	if g.font, err = ctx.Gfx.NewFont(goregular.TTF, 15, gfx.FontOptions{}); err != nil {
		return err
	}
	g.ui = ui.New(ctx.Gfx, ui.DarkTheme(g.font))
	if g.soft, err = ctx.Gfx.NewTexture(particle.SoftCircle(64), gfx.TextureOptions{Linear: true}); err != nil {
		return err
	}
	hearth := lin.V2(ctx.Width/2, ctx.Height-150)

	g.fireE = particle.Fire()
	g.fireE.Position = hearth
	g.fireE.Texture = g.soft
	g.fireE.Prewarm = 1.5
	g.fireE.Layer = 2
	g.fireRate, g.gravity = g.fireE.Rate, g.fireE.Acceleration.Y
	g.fire = particle.New(g.fireE)

	smoke := particle.Smoke()
	smoke.Position = hearth.Add(lin.V2(0, -30))
	smoke.Texture = g.soft
	smoke.Prewarm = 3
	smoke.Layer = 1
	g.smoke = particle.New(smoke)

	// Rain through the instanced path. Stateless means no per-particle
	// state is kept at all: every drop is a closed form of the seed, its
	// index and the clock, so the storm is already falling at time zero
	// with no Prewarm and costs the same memory at any size.
	rain := particle.Rain()
	rain.Position = lin.V2(-40, -20)
	rain.Shape = particle.Line(lin.V2(ctx.Width+80, 0))
	rain.Stateless = true
	rain.Max = g.drops
	rain.Rate = float32(g.drops) / 1.6 // the rate that fills Max over a lifetime
	g.rain = particle.NewGPU(rain)

	sparks := particle.Sparks()
	sparks.Burst = 0 // only on click
	sparks.Texture = g.soft
	sparks.Size = particle.Range{Min: 4, Max: 7}
	sparks.Layer = 3
	g.sparks = particle.New(sparks)
	return nil
}

Shutdown: releasing the GPU objects

Everything with a Destroy method is destroyed here, on the same goroutine that created it. The particle systems hold no GPU objects of their own; they draw with the texture they were given.

func (g *game) Shutdown(ctx *engine.Context) {
	g.soft.Destroy()
	g.font.Destroy()
}

Update: input and simulation

Update runs at the fixed step, so ctx.Delta is always the same duration and the effects advance at the same rate however fast the machine draws. ctx.Time is the seconds since the run began, which is what the -seconds and -shot flags are compared against.

g.ui.WantsMouse reports whether the interface is under the pointer. The click test asks it before throwing sparks, so dragging a slider does not also fire sparks behind the panel. A timed run pops the sparks and the confetti just before the screenshot, so the picture on this page shows the one-shot effects as well as the continuous ones.

Each system is stepped with Update(ctx.Delta). The confetti bursts are finite, so the loop over g.bursts drops the ones that report Finished, rebuilding the slice in place through g.bursts[:0] rather than allocating a new one each frame.

func (g *game) Update(ctx *engine.Context) error {
	in := ctx.Input
	if in.KeyPressed(input.KeyEscape) || (g.seconds > 0 && ctx.Time >= g.seconds) {
		ctx.Quit()
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
	}
	if in.MousePressed(input.MouseLeft) && !g.ui.WantsMouse() {
		g.sparks.SetPosition(in.MousePos())
		g.sparks.Burst(40)
	}
	if in.KeyPressed(input.KeySpace) {
		g.pop(lin.V2(ctx.Width/2, ctx.Height/2))
	}
	// A timed run pops both one-shot effects so a screenshot shows them.
	if g.seconds > 0 && !g.demoDone && ctx.Time >= g.seconds/2-0.5 {
		g.demoDone = true
		g.sparks.SetPosition(lin.V2(ctx.Width*0.75, ctx.Height*0.4))
		g.sparks.Burst(40)
		g.pop(lin.V2(ctx.Width/2, ctx.Height/2))
	}
	g.fire.Update(ctx.Delta)
	g.smoke.Update(ctx.Delta)
	g.rain.Update(ctx.Delta)
	g.sparks.Update(ctx.Delta)
	live := g.bursts[:0]
	for _, b := range g.bursts {
		b.Update(ctx.Delta)
		if !b.Finished() {
			live = append(live, b)
		}
	}
	g.bursts = live
	return nil
}

pop creates one confetti system per burst. Seed makes each burst differ from the last: a system with the same seed emits the same particles, which is what a replay or a test wants, so the counter here deliberately changes it.

// pop starts a confetti burst at p.
func (g *game) pop(p lin.Vec2) {
	e := particle.Confetti()
	e.Position = p
	e.Layer = 4
	e.Seed = uint64(len(g.bursts) + 1)
	g.bursts = append(g.bursts, particle.New(e))
}

Draw: the scene and interface

Draw runs once per frame and queues drawing; nothing is submitted until it returns. ctx.Clear is the frame's clear colour in linear space; gfx.RGB converts the sRGB bytes an image editor shows.

Layered sets the layer for one drawing closure and restores the previous layer when it returns, including on panic. The ground and logs use layer 0. The particle systems then draw into their emitters' layers. The particle counts and interface use layer 10, over the scene, without a matching SetLayer call at the end.

gfx.Sprite is a value. Origin is the fraction of the sprite the position refers to, so lin.V2(0.5, 0.5) centres it, and Rotation is in radians. A nil texture fills the rectangle with the sprite's colour. DebugText draws the counts with the built-in font.

The interface is rebuilt inside u.Begin, and u.Panel scopes its widgets with another closure. Each slider takes a pointer to the value it edits and returns whether it changed. Both sliders run every frame; || changed preserves that even when the first slider changed. Updating an emitter changes acceleration, damping and appearance curves for live particles, while their birth size, lifetime and palette tint stay fixed. Later births use the new settings.

func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	ctx.Clear = gfx.RGB(18, 20, 30)
	// The ground and a couple of logs under the fire.
	hearth := g.fire.Position()
	gr.Layered(0, func() {
		gr.FillRect(0, hearth.Y+8, ctx.Width, ctx.Height-hearth.Y-8, gfx.RGB(34, 40, 36))
		log := gfx.Sprite{Pos: hearth.Add(lin.V2(0, 4)), Size: lin.V2(70, 12), Origin: lin.V2(0.5, 0.5), Color: gfx.RGB(90, 60, 35)}
		log.Rotation = 0.35
		gr.Draw(nil, log)
		log.Rotation = -0.35
		gr.Draw(nil, log)
	})

	g.rain.Draw(gr)
	g.smoke.Draw(gr)
	g.fire.Draw(gr)
	g.sparks.Draw(gr)
	for _, b := range g.bursts {
		b.Draw(gr)
	}

	gr.Layered(10, func() {
		alive := g.fire.Alive() + g.smoke.Alive() + g.rain.Alive() + g.sparks.Alive()
		for _, b := range g.bursts {
			alive += b.Alive()
		}
		gr.DebugText(12, ctx.Height-46, fmt.Sprintf("Click for sparks, Space for confetti. %d particles live.", alive))
		gr.DebugText(12, ctx.Height-28, fmt.Sprintf("%d of them are stateless rain, drawn as one instanced call.", g.rain.Alive()))

		u := g.ui
		u.Begin(ctx.Input, func() {
			u.Panel("Fire", ui.Rect{X: 12, Y: 12, W: 240, H: 150}, func() {
				changed := u.Slider("Rate (per second)", &g.fireRate, 0, 400)
				changed = u.Slider("Gravity", &g.gravity, -200, 200) || changed
				if changed {
					g.fireE.Rate = g.fireRate
					g.fireE.Acceleration.Y = g.gravity
					g.fire.SetEmitter(g.fireE)
				}
				u.Label(fmt.Sprintf("%d flames, %d smoke", g.fire.Alive(), g.smoke.Alive()))
			})
		})
	})
	return nil
}

main

engine.Run owns the window, the renderer and the loop. Every field of engine.Config has a usable zero value; this one sets a title, a size, a resizable window, and headless mode from the flag. To turn on the Vulkan validation layers, set BUNYIP_VALIDATION=1 in the environment.

func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	headless := flag.Bool("headless", false, "render without a window, for screenshots")
	drops := flag.Int("drops", 3000, "raindrops in the instanced storm; try 200000")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip particles", Width: 960, Height: 640, Resizable: true, Headless: *headless},
		&game{seconds: *seconds, shot: *shot, drops: max(*drops, 1)})
	if err != nil {
		fmt.Fprintln(os.Stderr, "particles:", err)
		os.Exit(1)
	}
}

What to try

  • Change particle.Fire() in Init to particle.Smoke() and watch the same emitter shape produce a different effect, then set the fields the preset left alone.
  • Set the rain's Acceleration.X in Init and see the drops slant; acceleration is applied to every particle every second.
  • Add a slider for the confetti's particle count in Draw and read it in pop.
  • Raise the sparks' Layer above the panel in Init and see the sort key decide what covers what.
  • Remove the Prewarm lines in Init and start the program: the fire lights from nothing over the first second.

Source files

main.go

The whole directory on GitHub