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.
// 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.
// 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.
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.
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.
// 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.
// 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.
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.
// 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.
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.
$ 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.
// 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.
// 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.
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.
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.
$ 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
- 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
retirewith swap-remove, explain whyimust 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.
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.