The World Vol 3 · Forces of Nature
ch 30 / 105
Chapter 30

Thousands of Small Bodies

Forty drops a tick

Every moving thing this volume has built so far was created on purpose, once, before the first tick, and was still on the field at the last one. Rain is the opposite kind of thing. Forty drops arrive every tenth of a second, not one of them is anybody, each is gone within three seconds, and the tick that makes and unmakes them has the same tenth of a second it always had.

A particle is one of this volume's bodies with a birth tick and a death tick, made by an emitter that is nothing but numbers and kept in a block of memory allocated once. Rain, fire and pollen are sets of numbers on one code path. A drop needs one thing a body does not have: a way to stop existing. The tempting answer is a condition, something like "delete it when it reaches the ground". That works for rain falling straight down on an empty field and fails everywhere else. Blow the drop sideways and it leaves the screen without reaching anything, so it keeps falling for as long as the world runs, invisible and still costing a tick. Give a spark buoyancy and it never arrives anywhere at all. The reliable answer is arithmetic instead: write down the tick the thing was born on and the tick it dies on, and the question "is this over" becomes a comparison between two integers that no wind can talk out of.

Then there is the memory. Forty drops a tick is 400 a second, 1.4 million an hour, 34 million a day, and this is one shower over one small valley. A program that asks for memory every time a drop appears spends its life handing scraps to the collector and taking them back. A program that never asks does not, and the way to never ask is to decide up front how many drops may exist at once and then live inside that number.

Four things follow from that sentence. The particle and the emitter, which are short. The arithmetic that says how many particles are alive at once, which is what a budget is chosen from. Two ways to hand a slot back, one of which loses track of what is in it. And the measurement that says what a tenth of a second actually buys, which turns out not to be the ceiling anybody expects.

Particle birth and death

Start with the two types. Neither is long, and between them they hold everything that separates a rainstorm from a bonfire.

▣ Build · stage 1 — the particle, and the weather that makes it
// internal/field/particle.go

// Particle is one of this volume's bodies with three more numbers on
// it: the tick it appeared, the tick it stops existing, and the count
// that tells one raindrop from another. Nothing else separates a
// raindrop from a spark from a speck of pollen.
type Particle struct {
	Body
	Born, Dies uint64
	Seq        uint64 // this emitter's count: the particle's name
}

// Life is how far through its lifespan the particle is at tick t: 0 on
// the tick it was born, 1 on the tick it dies. Everything that fades,
// cools or shrinks reads this one number and nothing else.
func (p Particle) Life(t uint64) float64 {
	span := p.Dies - p.Born
	if span == 0 {
		return 1
	}
	return float64(t-p.Born) / float64(span)
}

// Emitter is one weather, written down as numbers. Rain, fire and
// pollen are three values of this type, and not one line of code apart.
type Emitter struct {
	Name string

	At     Vec2 // the middle of the mouth
	Spread Vec2 // how far either side of At a particle may appear

	Rate float64 // particles per tick, fractions allowed

	Vel Vec2 // the velocity a particle leaves with
	Fan Vec2 // how far either side of Vel that velocity is drawn

	Mass, MassFan float64

	Life, LifeFan int // ticks a particle lasts, and the draw around it

	G    Vec2 // the constant acceleration: down for rain, up for a spark
	Push Vec2 // a force that ignores mass, which is what a wind is
	Air  Drag // the air fighting whatever speed the rest of it builds

	Stream uint64 // which of the seed's streams this weather draws from
}
// internal/field/particle.go — making one, and moving one

// Draw makes one particle out of the emitter's numbers and six draws
// from this weather's own stream. The order below is the order the
// numbers are taken in, and that order is the run's repeatability.
func (e Emitter) Draw(rng *rand.Rand, t, seq uint64) Particle {
	pos := Vec2{
		X: e.At.X + span(rng, e.Spread.X),
		Y: e.At.Y + span(rng, e.Spread.Y),
	}
	vel := Vec2{
		X: e.Vel.X + span(rng, e.Fan.X),
		Y: e.Vel.Y + span(rng, e.Fan.Y),
	}
	mass := e.Mass + span(rng, e.MassFan)
	if mass < 0.05 {
		mass = 0.05
	}
	life := e.Life + int(math.Round(span(rng, float64(e.LifeFan))))
	if life < 1 {
		life = 1
	}
	p := Particle{Body: NewBody(pos, mass), Born: t, Dies: t + uint64(life), Seq: seq}
	p.Vel = vel
	return p
}

// span is one draw from -w to +w. A width of zero takes no number at
// all, so widening a spread from zero shifts every draw after it.
func span(rng *rand.Rand, w float64) float64 {
	if w == 0 {
		return 0
	}
	return (rng.Float64()*2 - 1) * w
}

// Move is the tick this emitter's particles take: the three forces it
// carries, summed into the accumulator, then the update every moving
// thing in this volume runs.
func (e Emitter) Move(p *Particle) {
	p.ApplyForce(p.Weight(e.G))
	p.ApplyForce(e.Push)
	p.ApplyForce(e.Air.Force(p.Body))
	p.Step()
}

Move is the whole physics of weather and it is four lines, three of them calls this volume already wrote. A constant acceleration through the body's own mass, a wind that ignores mass, the air pushing back against speed, and the update. Nothing in those four lines knows what it is moving. Point G upward and the same code lifts a spark; raise Air.B and the same code turns a fall into a drift.

Every quantity that ought to vary between two drops of one shower comes in pairs: a middle and a width, drawn as middle plus or minus width. That is what makes a shower read as a shower and not a marching band. Note the guard inside span: a width of zero returns without touching the generator, so a weather that wants no spread costs no numbers. Widen a spread from zero later and every draw after it shifts by one position in the sequence, which will change the whole run.

Now somewhere to put them. The first arrangement anybody writes is a slice that grows when the emitter spawns and is rebuilt without the dead at the end of the tick.

▣ Build · stage 2 — a slice that grows, and a first rainstorm
// internal/field/cloud.go

// Cloud is the arrangement anybody writes first: a slice of live
// particles that grows when the emitter spawns and is rebuilt without
// the dead ones at the end of every tick. It has no budget and no
// ceiling.
type Cloud struct {
	Em Emitter

	live []Particle
	rng  *rand.Rand
	met  meter
	born uint64
}

// Step runs one tick: spawn, move, then rebuild the slice out of the
// particles that are still alive.
func (c *Cloud) Step(t uint64) {
	for range c.met.due(c.Em.Rate) {
		c.live = append(c.live, c.Em.Draw(c.rng, t, c.born))
		c.born++
	}
	for i := range c.live {
		c.Em.Move(&c.live[i])
	}
	keep := make([]Particle, 0, len(c.live))
	for _, p := range c.live {
		if p.Dies > t {
			keep = append(keep, p)
		}
	}
	c.live = keep
}
$ go run ./cmd/weather -w rain -ticks 120 -store cloud -shot rain.png
weather: rain on a 192x128 field, seed 5, store cloud, budget 4000
        rain   rate 40/tick  life 26±6  mass 1.00±0.30  stream 7  expect 1040 alive
  tick  live           born             refused  oldest         frame           numbers
     0  0              0                0           0/0  5acdf4935de9  e3b0c44298fc1c14
    30  1041           1200             0         29/31  189f82f073bd  4fe82e12ec3bc497
    60  1019           2400             0         30/31  4ce4b78f4d88  38a758ab1911df72
    90  1043           3600             0         31/32  28dcf708c779  e2a0d5d017e6a3f7
   120  1035           4800             0         30/31  c3cdd0d6737d  8b1421b8dd53dbff
rain: 4800 born, 1035 alive, 0 of them past their death tick, 0 refused
wrote rain.png

Twelve seconds of rain, 4,800 drops made, and the population stops climbing at about a thousand. Read the third and fourth columns together: born rises by 1,200 every thirty ticks forever, and live sits still. The oldest column is the check that the deaths are actually happening. It prints the age of the longest-lived drop over the lifespan that drop was given, and 31 out of 32 is a drop on its last tick. In a run where nothing is dying properly the first number climbs past the second and keeps going.

The picture is a slanting shower that thins toward the bottom of the field, which it should: drops start at two pixels a tick and end near seven, and things travelling faster are spread further apart. They also grow dimmer as they fall, because the colour each drop is drawn in comes from its own age.

∑ Interlude — how many are alive at once

A budget is a number you have to choose before you can allocate anything, and it can be worked out on paper. Suppose forty drops appear on every tick and each one lives exactly 26 ticks. On tick 100 the drops still alive are the ones born on ticks 75 through 100: 26 ticks' worth, at forty a tick, so 40 × 26 = 1,040 of them. Nothing about that answer depends on tick 100. It is the same on tick 500 and tick 500,000, because as many drops die each tick as are born.

n = r × L

The run above printed expect 1040 alive from that multiplication and then wobbled around it: 1,019, then 1,043, then 1,035. The gap either way is the lifespan spread. A drop lives 26 ticks give or take six, and the population follows the average of the spans actually drawn, which lands a little either side of the number in the middle. Three weathers running together want the sum of their three answers, and a pool has to be sized above the total, not at it, because a burst that overshoots the average has nowhere to go.

rthe emitter's rate: how many particles it starts per tick (40 for this rain)
Lthe lifespan: how many ticks one particle lasts (26, give or take six)
nhow many are alive at any one time once the first ones have started dying
r × Lrate multiplied by lifespan: the steady population, and the smallest sensible budget

One more piece before the memory question, because it is what makes a particle look like weather instead of a dot. Each one is drawn in a colour taken from its own age, and mixed into the frame instead of replacing what is there.

▣ Build · stage 3 — a colour that ages, a pixel that lets light through
// cmd/weather/main.go

// mix walks a fraction k of the way from one colour to another, alpha
// included, so a particle's whole fade is one subtraction per channel.
func mix(a, b render.Color, k float64) render.Color {
	if k < 0 {
		k = 0
	} else if k > 1 {
		k = 1
	}
	lerp := func(x, y uint8) uint8 { return uint8(float64(x) + (float64(y)-float64(x))*k) }
	return render.RGBA(lerp(a.A(), b.A()), lerp(a.R(), b.R()), lerp(a.G(), b.G()), lerp(a.B(), b.B()))
}

// plot writes one particle pixel. A particle is a thin thing seen
// through, so its colour is mixed into whatever the frame already holds
// instead of replacing it.
func plot(b *render.Buffer, x, y int, c render.Color) {
	if !b.In(x, y) || c.A() == 0 {
		return
	}
	if c.A() == 255 {
		b.Set(x, y, c)
		return
	}
	b.Set(x, y, render.Over(c, b.At(x, y)))
}

The fade costs nothing extra. Life already returns a number between 0 and 1, so the same call that decides whether a particle is dead also decides what colour it is on the way there. A raindrop starts nearly white and opaque and ends translucent blue; a spark starts pale yellow at full alpha and ends dark red at zero, so it goes out instead of being deleted in front of you. Both are two colours in a table.

Mixing instead of overwriting has a consequence to keep in mind: the answer depends on the order the particles were drawn in. Two half-transparent drops landing on one pixel do not produce the same colour in both orders. That is fine and even correct, since nearer things are drawn later, but it means the frame hash is a statement about a drawing order as well as about positions.

The fixed pool

The cloud works and it has two faults, one loud and one quiet. The loud one is that it allocates: a fresh array for the survivors on every single tick, for as long as the world runs. The quiet one is that it cannot refuse. A storm ten times the size makes the slice ten times longer, no line of code objects, and the array never shrinks back.

The classic fix is a pool: allocate the worst case once, mark which entries are in use, and hand entries out and take them back. Written the obvious way, that is a block of particles, a flag per entry, and a stack of the entries nobody is in.

▣ Build · stage 4 — a fixed block and a stack of free indices
// internal/field/slots.go

// Slots is the first pool: a fixed block of particles, a flag per slot
// saying whether anybody is in it, and a stack of the indices nobody is
// in. Spawning pops an index; dying pushes one back.
type Slots struct {
	Em Emitter

	slot  []Particle
	alive []bool
	free  []int

	rng     *rand.Rand
	met     meter
	born    uint64
	refused uint64
}

// Step runs one tick: fill what slots are free, move everybody who is
// in one, then empty the slots whose occupant has died.
func (s *Slots) Step(t uint64) {
	for range s.met.due(s.Em.Rate) {
		i, ok := s.take()
		if !ok {
			s.refused++
			continue
		}
		s.slot[i] = s.Em.Draw(s.rng, t, s.born)
		s.alive[i] = true
		s.born++
	}
	for i := range s.slot {
		if s.alive[i] {
			s.Em.Move(&s.slot[i])
		}
	}
	for i := range s.slot {
		if s.alive[i] && s.slot[i].Dies <= t {
			s.release(i)
		}
	}
}

// take pops one free index off the stack.
func (s *Slots) take() (int, bool) {
	if len(s.free) == 0 {
		return 0, false
	}
	i := s.free[len(s.free)-1]
	s.free = s.free[:len(s.free)-1]
	return i, true
}

// release gives a slot back.
func (s *Slots) release(i int) {
	s.free = append(s.free, i)
}

Two arrays and a stack, no allocation after NewSlots, and an emitter that can now say no: when the stack is empty, take fails and the spawn is counted as refused instead of quietly becoming the 40,001st particle.

⚠ Worked failure — the pool handed out a slot somebody was in

That release is the one this chapter shipped first, and it is still in the package behind a -leak flag, because a failure you can run teaches more than a failure you are told about. Set the flag, run the same rain, and the picture looks right. Read the last line instead.

$ go run ./cmd/weather -w rain -ticks 120 -store slots -leak
weather: rain on a 192x128 field, seed 5, store slots, budget 4000
        rain   rate 40/tick  life 26±6  mass 1.00±0.30  stream 7  expect 1040 alive
  tick  live           born             refused  oldest         frame           numbers
     0  0              0                0           0/0  5acdf4935de9  e3b0c44298fc1c14
    30  1074           1200             0         29/29  155ef1e80c89  20f65eb3de09269e
    60  1088           2400             0         38/26  a001522cdc4f  c504053e2fde293f
    90  1088           3600             0         34/32  cba15e8acd19  7d31b4bb6d57747b
   120  1088           4800             0         33/30  528aec0f176c  79dea26f88fefad4
rain: 4800 born, 1088 alive, 53 of them past their death tick, 0 refused, free list 4320 of 4000

Free list 4,320 of 4,000. There are four thousand slots in this pool and the stack of empty ones holds four thousand three hundred and twenty indices, which is not a quantity that can exist. Some index is on that stack more than once, and an index on the stack twice will be handed to two different particles.

The oldest column names the same crime from the other side. At tick 60 it reads 38/26: a drop 38 ticks old whose lifespan was 26. Twelve ticks after it should have stopped existing it is still being moved and still being drawn, and by the end 53 of the 1,088 particles on the field are past their death tick. The population settles at 1,088 instead of the 1,040 the arithmetic predicts, which is close enough to look like the ordinary spread and is not.

The cause is one missing line in release. A slot has two pieces of truth about it: the flag that says somebody is in it, and its presence or absence on the free stack. The first version pushed the index and left the flag set. So the retire loop came back on the next tick, found the same slot still flagged alive with a death tick still in the past, and pushed it again. And again, every tick. Meanwhile take popped those duplicate indices and wrote new drops straight over live ones, which is why some drops disappear halfway down and their slots read as ancient: the arithmetic of two facts that are supposed to agree, and one of them being updated.

// internal/field/slots.go — release, with the line that was missing

// release gives a slot back. Marking it empty and pushing it are one
// act: a slot on the stack that is still flagged alive is a slot that
// will be handed to somebody while its occupant is still in it.
func (s *Slots) release(i int) {
	s.alive[i] = false
	s.free = append(s.free, i)
}
$ go run ./cmd/weather -w rain -ticks 120 -store slots | tail -2
   120  1035           4800             0         30/31  ab7e48c6c84a  8b1421b8dd53dbff
rain: 4800 born, 1035 alive, 0 of them past their death tick, 0 refused, free list 2965 of 4000

One line, and the counters come back: 1,035 alive against a prediction of 1,040, nobody past their death tick, and 2,965 free slots plus 1,035 occupied ones adding up to exactly 4,000.

The bug is fixed and the lesson survives it. A free list keeps two facts that have to agree, and every place either one is written is a place they can stop agreeing. There is an arrangement with only one fact, and it is smaller: keep the live particles at the front of the block, always, and let one integer say where the front ends.

▣ Build · stage 5 — the live ones are the first n
// internal/field/system.go

// System is the pool this world ships: one fixed block of particles in
// which the live ones are always the first n slots. There is no flag
// and no stack. One number, n, is the entire truth about which slots
// are in use, and one number cannot disagree with itself.
type System struct {
	Em Emitter

	pool []Particle // len is the budget; the live ones are pool[:n]
	n    int

	rng     *rand.Rand
	met     meter
	born    uint64
	refused uint64
}

// Step runs one tick: the emitter fills the slots past n that it can
// pay for, every live particle takes this volume's forces and update,
// and the ones whose death tick has come hand their slots back.
func (s *System) Step(t uint64) {
	for range s.met.due(s.Em.Rate) {
		if s.n == len(s.pool) {
			s.refused++
			continue
		}
		s.pool[s.n] = s.Em.Draw(s.rng, t, s.born)
		s.n++
		s.born++
	}
	live := s.pool[:s.n]
	for i := range live {
		s.Em.Move(&live[i])
	}
	s.retire(t)
}

// retire frees the slot of every particle whose death tick has arrived.
// The last live particle is moved into the hole so the live ones stay
// contiguous, and i does not advance when that happens, because the
// particle just moved in has not been looked at yet.
func (s *System) retire(t uint64) {
	for i := 0; i < s.n; {
		if s.pool[i].Dies > t {
			i++
			continue
		}
		s.pool[i] = s.pool[s.n-1]
		s.n--
	}
}

Spawning writes at index n and increments it. Retiring copies the last live particle over the dead one and decrements it. Nothing is searched for, nothing is flagged, and the update loop is a straight walk over pool[:n] with no test inside it. The one place this is easy to get wrong is the two lines at the bottom: i must not advance after a swap, because the particle that has just been moved into slot i has not been examined yet, and it may itself be dead. Write that loop with range and it advances every time.

Swap-remove: the last live particle fills the hole and n steps back Two rows of twelve slots. In the top row, slots zero to seven hold particles a through h and are marked live, slot three is marked dead, and a marker after slot seven reads n equals eight. A line runs from slot seven back to slot three, labelled as the last live particle moving into the hole. In the bottom row, slot three now holds h, slots seven to eleven are empty, and the marker reads n equals seven. BEFORE · d HAS REACHED ITS DEATH TICK a b c d e f g h 0 1 2 3 4 5 6 7 n = 8 free the last live one fills the hole AFTER · ONE MOVE, ONE DECREMENT a b c h e f g 0 1 2 3 4 5 6 n = 7 free

Figure 30.1 — one integer says where the live particles end, so freeing a slot is a copy and a decrement, and the update loop never tests anything.

Two pools and a cloud, all producing the same rain. Now measure them, and add a fourth for the sake of the argument: the version that gives every particle its own scrap of heap.

▣ Build · stage 6 — one tick of a thousand raindrops, timed
$ go test -run '^$' -bench . -benchmem -benchtime 3000x ./internal/field/
goos: linux
goarch: amd64
pkg: theworld/internal/field
cpu: AMD Ryzen 7 3700X 8-Core Processor
BenchmarkHandful-16    	    3000	     29640 ns/op	      1049 live	   18891 B/op	      41 allocs/op
BenchmarkCloud-16      	    3000	     44945 ns/op	      1049 live	  151047 B/op	       1 allocs/op
BenchmarkSlots-16      	    3000	     32058 ns/op	      1049 live	       0 B/op	       0 allocs/op
BenchmarkSwap-16       	    3000	     30335 ns/op	      1049 live	       0 B/op	       0 allocs/op
PASS
ok  	theworld/internal/field	0.472s

These are measurements from the machine this chapter was written on, and yours will read differently; the ratios are the part that travels. Read the allocation columns first. Handful keeps a slice of pointers and calls new for every drop, so it allocates 41 times a tick. Cloud allocates once, but for 151 KB, because it copies every survivor into a fresh array and hands the old one to the collector. Both pools allocate nothing at all, and that zero is not an average or a steady state: after NewSystem returns, this code never asks for memory again.

The times are the interesting part, because they do not say what a pool is usually sold as saying. Forty-one allocations a tick is the fastest of the four here, a hair quicker than the pool, since copying pointers beats copying 80-byte particles. Zero allocations buys about 30% over the cloud and nothing at all over the pointers. A microbenchmark measures the allocation and not the collection that follows it, and it certainly does not measure a server that has been running for six weeks.

So price the garbage instead of the nanoseconds. The cloud throws away 151 KB a tick; at ten ticks a second that is 1.5 MB a second, 5.4 GB an hour, 130 GB a day of memory allocated and collected, per weather, forever, to hold a thousand raindrops. The pool holds 4,000 particles at 80 bytes each: 320 KB, allocated once, and the same 320 KB is still there next month. The free-list version costs 36 KB more for its flags and its stack, and it walks all 4,000 slots every tick to update 1,035 of them, testing a flag at each one. Swap-remove walks 1,035 and tests nothing.

One thing needs pinning before any of that can be trusted: swapping particles around inside the block reorders them, and the drawing code walks them in whatever order the block is now in. It would be reasonable to worry that the storage has leaked into the physics. It has not, and a test says so.

▣ Build · stage 7 — three arrangements, one weather
// internal/field/particle_test.go

// TestStoresAgree pins the claim that where a particle is kept is not
// part of the physics: three arrangements of the same memory produce
// the same weather, particle for particle, after 150 ticks.
func TestStoresAgree(t *testing.T) {
	cloud := run(NewCloud(testRain, 5), 150)
	slots := run(NewSlots(testRain, testBudget, 5), 150)
	swap := run(NewSystem(testRain, testBudget, 5), 150)

	want := sum(cloud)
	for _, w := range []struct {
		name string
		w    walker
	}{{"slots", slots}, {"swap", swap}} {
		if got := sum(w.w); got != want {
			t.Errorf("%s ended at %s, the cloud at %s", w.name, got, want)
		}
		if got := w.w.Live(); got != cloud.Live() {
			t.Errorf("%s holds %d particles, the cloud %d", w.name, got, cloud.Live())
		}
	}
}
$ go test -count=1 -v -run 'TestStores|TestSlots|TestLifespan|TestStreams' ./internal/field/
=== RUN   TestStoresAgree
--- PASS: TestStoresAgree (0.00s)
=== RUN   TestSlotsHandBackWhatTheyTake
--- PASS: TestSlotsHandBackWhatTheyTake (0.00s)
=== RUN   TestLifespanBoundsThePopulation
--- PASS: TestLifespanBoundsThePopulation (0.00s)
=== RUN   TestStreamsDoNotCollide
--- PASS: TestStreamsDoNotCollide (0.00s)
PASS
ok  	theworld/internal/field	0.011s

sum hashes the live particles after sorting them by the count the emitter stamped on each one at birth, which is the only ordering all three arrangements agree about. The three runs printed the same hash in the numbers column of the three runs above too: 4fe82e12ec3bc497 at tick 30 from the cloud, from the free list and from swap-remove. Their frame hashes differ, and that is the compositing order from earlier: same drops in the same places, mixed into the pixel in a different sequence.

The other three tests pin the rest of the chapter as arithmetic. One asserts that the free list never holds more indices than the pool has slots and that no live particle is past its death tick, then asserts that the leaking version breaks both. One asserts that rate times lifespan predicts the population to within five percent. One lights a fire next to the rain and asserts that not a single raindrop moves.

Rain, fire and pollen

Everything so far has run one weather. The claim in this chapter's opening rule was that the other two need no code, so here is the entire difference between them.

▣ Build · stage 8 — three weathers, one table
// cmd/weather/main.go — the three weathers of this chapter, in full

var weathers = []weather{
	{
		em: field.Emitter{
			Name: "rain", Stream: 7,
			At: field.Vec2{X: 96, Y: -3}, Spread: field.Vec2{X: 99, Y: 3},
			Rate: 40,
			Vel:  field.Vec2{X: 0.30, Y: 2.00}, Fan: field.Vec2{X: 0.10, Y: 0.40},
			Mass: 1.00, MassFan: 0.30,
			Life: 26, LifeFan: 6,
			G:    field.Vec2{X: 0, Y: 0.35},
			Push: field.Vec2{X: 0.10, Y: 0},
			Air:  field.Drag{B: 0.05},
		},
		lk: look{from: render.RGBA(255, 0xC8, 0xDC, 0xF0), to: render.RGBA(70, 0x4A, 0x90, 0xD2), w: 1, h: 2},
	},
	{
		em: field.Emitter{
			Name: "fire", Stream: 4,
			At: field.Vec2{X: 96, Y: 112}, Spread: field.Vec2{X: 5, Y: 1},
			Rate: 24,
			Vel:  field.Vec2{X: 0, Y: -1.00}, Fan: field.Vec2{X: 0.30, Y: 0.30},
			Mass: 0.60, MassFan: 0.25,
			Life: 26, LifeFan: 8,
			G:    field.Vec2{X: 0, Y: -0.11},
			Push: field.Vec2{X: 0.02, Y: 0},
			Air:  field.Drag{B: 0.035},
		},
		lk: look{from: render.RGBA(255, 0xFF, 0xE4, 0x8A), to: render.RGBA(0, 0xB0, 0x2A, 0x1E), w: 1, h: 1},
	},
	{
		em: field.Emitter{
			Name: "pollen", Stream: 5,
			At: field.Vec2{X: 40, Y: 72}, Spread: field.Vec2{X: 14, Y: 10},
			Rate: 6,
			Vel:  field.Vec2{X: 0.10, Y: -0.05}, Fan: field.Vec2{X: 0.20, Y: 0.20},
			Mass: 0.25, MassFan: 0.10,
			Life: 120, LifeFan: 40,
			G:    field.Vec2{X: 0, Y: 0.05},
			Push: field.Vec2{X: 0.09, Y: 0},
			Air:  field.Drag{B: 0.15},
		},
		lk: look{from: render.RGBA(210, 0xF6, 0xE7, 0xA8), to: render.RGBA(0, 0xC9, 0xB4, 0x6A), w: 1, h: 1},
	},
}
$ go run ./cmd/weather -w all -ticks 240 -every 60 -shot weather.png
weather: all on a 192x128 field, seed 5, store swap, budget 4000
        rain   rate 40/tick  life 26±6  mass 1.00±0.30  stream 7  expect 1040 alive
        fire   rate 24/tick  life 26±8  mass 0.60±0.25  stream 4  expect 624 alive
        pollen rate 6/tick  life 120±40  mass 0.25±0.10  stream 5  expect 720 alive
  tick  live           born             refused  oldest         frame           numbers
     0  0+0+0          0+0+0            0+0+0       0/0  5acdf4935de9  e3b0c44298fc1c14
    60  1019+625+360   2400+1440+360    0+0+0     59/87  2488432f9b6e  0c566ed1ed06bbbb
   120  1035+621+662   4800+2880+720    0+0+0   119/134  b6d7edb00c53  8a880b2d7a18b099
   180  1048+626+710   7200+4320+1080   0+0+0   153/158  0a71b62686f5  ec880d2930adf6eb
   240  1048+617+728   9600+5760+1440   0+0+0   155/157  6725729abb15  298e1b875de4dc03
rain: 9600 born, 1048 alive, 0 of them past their death tick, 0 refused
fire: 5760 born, 617 alive, 0 of them past their death tick, 0 refused
pollen: 1440 born, 728 alive, 0 of them past their death tick, 0 refused
wrote weather.png

Three weathers, 2,393 live particles, and the picture is a slanting shower over a fire that throws sparks up through it while pollen drifts east across the middle of The Hollow. What makes each one itself is on the table above and nowhere else. Rain falls under a downward G. Fire rises under an upward one, because a spark's buoyancy and its weight are both proportional to how much of it there is, so what is left after they fight is a constant acceleration the same way weight is. Pollen has almost no G at all and a Push it cannot escape, and its heavy drag means it reaches the wind's own speed within a few ticks and travels with it for two minutes.

The three Stream numbers are the reason this composes. Every weather builds its generator from the same world seed and a different stream, so lighting a fire in a world that already rains does not consume a number the rain was going to draw. The rain column is identical to the rain-only run, drop for drop, and the test above proves it rather than asserting it. Give two weathers the same stream number and each one's spawns land in the gaps left by the other's; the seed still makes the run repeatable, but the two weathers are welded together and neither can be tuned alone.

Which numbers those are is bookkeeping this world keeps in one place. Stream 0 makes the terrain, stream 1 runs the world's laws, stream 2 places anything released onto the field, and stream 3 is what a walker wanders with. The walkers had spoken for 3 before the first cloud arrived, so the weather begins at 4: fire on 4, pollen on 5, 6 kept for the snow that exercise 3 adds, and rain on 7. Rain skipped three numbers to get there and keeps the one it landed on, because a stream that has been drawn from once cannot be moved. Every drop this weather has ever made came out of it.

∑ Interlude — what a tenth of a second buys

A tick is 100 milliseconds, which is 100,000,000 nanoseconds. Timing the whole job, three emitters stepped and every live particle composited into the frame, gives 158,235 ns for 2,389 particles and 14,784,816 ns for 238,389 of them: 66 ns each at the small size, 62 ns each at the large one, so call it 62 ns per particle per tick, measured on the author's machine.

n = (T × s) / c

Spend the entire tick on weather and 100,000,000 / 62 is 1.6 million particles. No world spends its whole tick on weather; give it a tenth and the answer is 160,000. That is sixty-seven times what the three weathers above are using, which sounds like room to grow until you count pixels. The field is 192 by 128, so 24,576 of them. A raindrop covers two, a spark and a speck cover one, so the mixture above paints about 1.4 pixels per particle. Divide: at roughly 17,500 particles every pixel on the field has been painted once. The picture fills up nine times sooner than the clock runs out.

Tthe tick, in nanoseconds: 100,000,000 at ten ticks a second
cmeasured cost of one particle for one tick, moved and drawn: 62 ns here
sthe share of the tick this world is willing to spend on weather (0.1 above)
nhow many particles that share pays for: T times s, divided by c
Apixels on the field, 192 × 128 = 24,576: the other ceiling, and the lower one

Which makes the budget a decision about the picture, not about the clock. And a budget only means something if the code enforces it, so set one too low on purpose and watch the emitter refuse.

▣ Build · stage 9 — a pool too small, saying so
$ go run ./cmd/weather -w rain -ticks 120 -budget 600 -shot starved.png
weather: rain on a 192x128 field, seed 5, store swap, budget 600
        rain   rate 40/tick  life 26±6  mass 1.00±0.30  stream 7  expect 1040 alive
  tick  live           born             refused  oldest         frame           numbers
     0  0              0                0           0/0  5acdf4935de9  e3b0c44298fc1c14
    30  567            726              474       29/31  2880681bfd9c  7fea8b7251e1fb8c
    60  560            1422             978       31/32  f64aa1a8f947  91eb932c4f00f264
    90  571            2145             1455      30/31  a48f84b07ada  118abb1097ba1d29
   120  573            2831             1969      31/32  afa0f1f65637  f1c2516ff00f0280
rain: 2831 born, 573 alive, 0 of them past their death tick, 1969 refused
wrote starved.png

A budget of 600 against a demand of 1,040 refuses 1,969 drops in twelve seconds, 41% of everything the weather asked for, and the shower on screen is visibly thin. The refusals are the point. A cloud in the same position would have grown to 1,040, taken the memory it needed, and told nobody. A pool degrades in a way somebody can read off a counter, and a server that has been running unattended for a month is a place where the difference between "slower than you wanted" and "out of memory at 3 a.m." is the whole argument.

The pixel ceiling

The two numbers on a particle do more work than they look like they do. A death tick makes the end of a thing a fact rather than a judgement: something either has a tick in the past written on it or it does not, and no wind, camera or edge case can argue. That is what lets the pool be sized on paper, because a rate multiplied by a lifespan is a population, and a population is a budget. Take the death tick away and replace it with a condition and all three collapse together: you cannot predict how many are alive, so you cannot size anything, so you have to grow.

The pool itself generalizes past particles. Anything that arrives in bulk, lives briefly and is anonymous wants this arrangement: projectiles, damage numbers floating off a creature's head, the footprints a herd leaves, the network packets a server writes per tick. The condition for the cheapest version is exactly the anonymity. Swap-remove is allowed to move a particle to a different index because nothing outside the pool holds an index, and nothing holds one because there is no reason to name a raindrop. The moment something does need naming, a stable slot with a flag on it earns its extra array back, and the leak in this chapter's failure box is what has to be defended against instead. Choosing between them is a question about identity, not about speed.

The measurement is the piece to carry furthest. Everything in this chapter argued for the pool, and the benchmark then refused to award it a decisive time margin over the version that allocates for every particle. Both facts are true, and the reason to keep the pool is in the second and third columns rather than the first: no allocation at all, a fixed ceiling, and a refusal you can count. Then the same discipline applied to the frame found the actual ceiling somewhere the timings never mentioned, in the 24,576 pixels there are to paint. A number you have measured beats a number you have reasoned about, and the two disagreeing is usually the interesting part.

A budget you can defend

✓ Checkpoint — what the budget proves
  • I can say why a particle carries a death tick instead of a condition, and name a particle that would never satisfy the condition.
  • Given a rate and a lifespan, I can predict the steady population, and I can say why the run settles slightly under the prediction.
  • I can write retire with swap-remove, explain why i must not advance after the copy, and defend that arrangement over a free list on the grounds of what holds an index.
  • Reading a run's summary line, I can convict a pool from the sentence "free list 4,320 of 4,000" and say which of two facts stopped agreeing.
  • I can turn a measured cost per particle into how many fit inside a tick, and then find the smaller ceiling by counting pixels.
  • I can give two emitters different stream numbers and say what changes if they share one.
⚡ Exercises — try first, then reveal
Exercise 1 — kill what has left the field. Rain dies on a tick chosen so it lands about when it reaches the bottom, which is luck, not design. Add a second death rule to retire: a particle more than sixteen pixels outside the field is finished whatever its death tick says. Run 120 ticks and compare the live count.

In the retire loop, treat a particle as dead when p.Dies <= t or its position is off the extended field. With this rain the count barely moves, because the lifespan was already tuned to the fall; turn the wind up to Push{X: 0.6} and the difference appears, since drops now leave sideways long before their death tick and each one was costing a move and a bounds test every tick until it arrived. The general rule is the one from the opening: keep the tick as the death that always fires, and add position tests as an optimisation on top, never as the only way a thing can end.

Exercise 2 — find the pixel ceiling by running into it. The interlude predicts that about 17,500 particles paint every pixel of the field once. Multiply the three rates by 8, raise the budget to 40,000, and look at the frame. Does the prediction hold?

Eight times the rates gives roughly 19,000 live particles, and the frame is a wash of blue with a bright smear where the fire is: individual drops have stopped being visible, which is the ceiling arriving exactly where the arithmetic said. The interesting part is what the counters say at the same moment. Nothing is refused, nothing is late, and the tick is using well under a millisecond. The program is perfectly happy and the picture is ruined, which is the argument for sizing a particle budget by looking at it rather than by timing it.

Exercise 3 — snow. Add a fourth entry to the weathers table and nothing else: flakes that fall slowly, drift sideways, take a long time to cross the field, and fade out at the bottom. Give it stream 6 and check that the rain is untouched.

Snow is rain with the numbers turned down and the drag turned up: a low G around 0.05, a heavy Air around 0.12 so a flake reaches a terminal speed under half a pixel a tick, a sideways Push near 0.06, a wide Fan so no two flakes fall alike, and a Life of 220 or so to carry one from the top of the field to the bottom. At a rate of 8 that is 1,760 flakes alive, which the arithmetic tells you before you run it. The check that matters is the numbers column: run -w rain before and after adding the entry and the hashes are identical, because stream 6 took its numbers from somewhere stream 7 was never going to look.