Everything Pushing at Once
The same gust on two masses
A body now keeps its motion between ticks, and a push during a tick changes that motion instead of replacing it. Two pushes in one tick already add up, since pushing is adding and never assigning. What the body has no way to say is the most ordinary fact about being pushed: the same gust that carries a leaf across The Hollow does nothing measurable to a boulder sitting beside it.
The vector a body accumulates is a force, not an acceleration, and the body carries the one number that converts between them: its mass. Anything may add a force without knowing the body or the other callers. The tick divides the total by the mass once, at the moment it spends it, and the three lines of the update run as they did before.
Look at what the tick does with the accumulated vector and the reason is plain. It adds that vector straight into the velocity, so whatever anything hands the body is already an acceleration, already the answer to "how much does this move you". The wind would have to be told the mass of every leaf, seed, stone and animal it blows on, and so would gravity, and so would every behavior after them, and each of them would have to divide correctly, every time. One system forgetting is a boulder that sails away on a breeze.
Four things follow. The accumulator with a mass under it, which is a handful of lines and the whole engine of the volume. Mass in plain numbers, since the claim that a boulder moves less deserves arithmetic. A force to compose: a point of attraction whose pull grows as bodies fall toward it. And the failure that pull produces at close range, which is spectacular, entirely reproducible, and fixed by one line.
The force accumulator
The body gains a number and the update gains a line above the three it already has. Nothing else in the law moves: the velocity still takes on this tick's acceleration, the position still takes on the velocity that resulted, and the accumulator still empties, because a force belongs to the tick it arrived in.
One thing does get a new name. The method that has been called Push becomes
ApplyForce, and the field behind it stops being called an acceleration,
because what a caller hands over is no longer the answer to "how much does this move
you". Rename it in your own module now. This is the cheap kind of change: the method
name is checked by the compiler, so go build ./... lists every line that
still says Push and nothing runs until each of them has been read and
changed on purpose. The expensive kind is the change that keeps the old name and quietly
means something new, because then nothing at all points at the call sites that have
started lying.
// internal/field/body.go
// Body is one moving thing, held as two vectors, an accumulator and a
// number: where it is, how fast it is going, everything pushing on it
// this tick, and how much of it there is for those pushes to move.
// Position is in world pixels; velocity is world pixels per tick; the
// accumulator is a force, mass times world pixels per tick per tick.
type Body struct {
Pos, Vel Vec2
Mass float64
force Vec2 // this tick's forces, and no other tick's
}
// NewBody makes a body of the given mass standing still at pos. A mass
// of zero would put a division by zero in the middle of every tick, so
// it is refused here instead of turning up later as a NaN.
func NewBody(pos Vec2, mass float64) Body {
if mass <= 0 {
panic("field: a body needs a positive mass")
}
return Body{Pos: pos, Mass: mass}
}
// ApplyForce adds one force to whatever this tick has already piled onto
// the body. Anything in the world may call it, in any order, knowing
// nothing about what else has.
func (b *Body) ApplyForce(f Vec2) { b.force = b.force.Add(f) }
// Force reports what has been applied to the body so far this tick.
// Nothing outside this package can write it.
func (b *Body) Force() Vec2 { return b.force }
// Accel is what the accumulated force does to this body: the sum
// divided by the mass.
func (b *Body) Accel() Vec2 { return b.force.Scale(1 / b.Mass) }
// Step is the update. The three lines of the law are unchanged; the new
// one above them turns the tick's accumulated force into the
// acceleration those lines have always wanted.
func (b *Body) Step() {
acc := b.force.Scale(1 / b.Mass)
b.Vel = b.Vel.Add(acc)
b.Pos = b.Pos.Add(b.Vel)
b.force = Vec2{}
}
// Weight is the pull of a downward acceleration g on this body: a force
// built by multiplying by the mass, which is how it moves every body the
// same way.
func (b *Body) Weight(g Vec2) Vec2 { return g.Scale(b.Mass) }
// cmd/forces/main.go — two pushes a tick, then one
// gravity is the constant push these runs fall things with: half a world
// pixel per tick per tick, straight down. wind is a force, and the same
// force whatever it blows on.
var (
gravity = field.Vec2{X: 0, Y: 0.5}
wind = field.Vec2{X: 0.2, Y: 0}
)
// sum pushes one body from two directions at once and prints what the
// accumulator holds at each stage of a tick.
func sum() {
b := field.NewBody(field.Vec2{X: 96, Y: 20}, 2)
fmt.Printf("forces: one body, mass %.1f, at %s\n", b.Mass, vec(b.Pos))
for t := 1; t <= 3; t++ {
b.ApplyForce(wind)
if t < 3 {
b.ApplyForce(b.Weight(gravity))
fmt.Printf("t=%d pushed by wind %s and weight %s\n", t, vec(wind), vec(b.Weight(gravity)))
} else {
fmt.Printf("t=%d pushed by wind %s only\n", t, vec(wind))
}
fmt.Printf(" force %s accel %s", vec(b.Force()), vec(b.Accel()))
b.Step()
fmt.Printf(" vel %s pos %s\n", vec(b.Vel), vec(b.Pos))
}
}
$ go run ./cmd/forces -mode sum
forces: one body, mass 2.0, at 96.000,20.000
t=1 pushed by wind 0.200,0.000 and weight 0.000,1.000
force 0.200,1.000 accel 0.100,0.500 vel 0.100,0.500 pos 96.100,20.500
t=2 pushed by wind 0.200,0.000 and weight 0.000,1.000
force 0.200,1.000 accel 0.100,0.500 vel 0.200,1.000 pos 96.300,21.500
t=3 pushed by wind 0.200,0.000 only
force 0.200,0.000 accel 0.100,0.000 vel 0.300,1.000 pos 96.600,22.500
Two pushes went in and one vector came out. The wind contributed 0.2 across and nothing down, the weight contributed nothing across and 1.0 down, and the total is 0.200, 1.000 because adding two vectors puts each number where it belongs without either caller being asked. Neither push knows the other exists. Neither knows the mass. The body divides by 2 on its own behalf and gets 0.100, 0.500, and only then does the velocity hear about any of it.
The third tick is worth a moment. The same two calls ran on ticks 1 and 2, and on tick 3 only the wind pushed, so the force reads 0.200, 0.000 and the downward acceleration is zero. The weight left no residue, which is the clearing line doing what it was written for. Look one column right, though: the downward velocity is still 1.000 and the body is still falling a whole pixel a tick. Forces evaporate at the end of a tick and motion does not, and the two columns on that line are the difference in one picture.
Where the division sits is the design. It happens once, inside the update, after everything has been added, which means no caller anywhere needs to know what it is pushing on. The alternative is every system dividing for itself, and that is not merely more work: a system that divides early and a system that divides late still agree here, because dividing each push separately and adding the results happens to give the same total. They stop agreeing the moment a force is computed from something the body already carries. One division, at one address, after the last caller has spoken.
Figure 26.1 — during a tick anything may add to the sum; only the tick itself divides it by the mass and empties it.
Mass 1 and mass 4
Take the wind on its own, a steady 0.2 to the east, and give it two bodies: one of mass 1, one of mass 4. The wind pushes both with the same force, because that is what being the same wind means.
A force of 0.2 on a body of mass 1 produces an acceleration of 0.2. The same force on a body of mass 4 produces 0.05, because the push is being shared out over four times as much body. Divide, do not multiply. Physics writes the relationship as force equals mass times acceleration, and a simulation always reads it backwards: the world hands you a force, the body owns a mass, and the acceleration is the thing you have to work out.
a = F / m
Now walk five ticks by hand. Each tick the velocity gains the acceleration, then the position gains the new velocity, and the accumulator empties.
| tick | a (m=1) | v (m=1) | x (m=1) | a (m=4) | v (m=4) | x (m=4) |
|---|---|---|---|---|---|---|
| 1 | 0.20 | 0.20 | 0.20 | 0.05 | 0.05 | 0.05 |
| 2 | 0.20 | 0.40 | 0.60 | 0.05 | 0.10 | 0.15 |
| 3 | 0.20 | 0.60 | 1.20 | 0.05 | 0.15 | 0.30 |
| 4 | 0.20 | 0.80 | 2.00 | 0.05 | 0.20 | 0.50 |
| 5 | 0.20 | 1.00 | 3.00 | 0.05 | 0.25 | 0.75 |
Every entry in the heavy body's columns is exactly a quarter of the light body's, and it stays a quarter forever, because nothing in the arithmetic ever stops being proportional. Four times the mass, a quarter of the acceleration, a quarter of the speed after any number of ticks, a quarter of the distance covered.
// cmd/forces/main.go
// twoMasses gives two bodies the same push and prints how far each gets.
func twoMasses(ticks int) {
light := field.NewBody(field.Vec2{}, 1)
heavy := field.NewBody(field.Vec2{}, 4)
fmt.Printf("forces: the same push %s on two masses\n", vec(wind))
fmt.Println("tick a(m=1) v(m=1) x(m=1) a(m=4) v(m=4) x(m=4)")
for t := 1; t <= ticks; t++ {
light.ApplyForce(wind)
heavy.ApplyForce(wind)
la, ha := light.Accel().X, heavy.Accel().X
light.Step()
heavy.Step()
if t <= 5 || t == ticks {
fmt.Printf("%4d %8.2f %8.2f %8.2f %10.2f %8.2f %8.2f\n",
t, la, light.Vel.X, light.Pos.X, ha, heavy.Vel.X, heavy.Pos.X)
}
}
fmt.Printf("after %d ticks the light body is %.3f times as far along as the heavy one\n",
ticks, light.Pos.X/heavy.Pos.X)
}
$ go run ./cmd/forces -mode mass -ticks 40
forces: the same push 0.200,0.000 on two masses
tick a(m=1) v(m=1) x(m=1) a(m=4) v(m=4) x(m=4)
1 0.20 0.20 0.20 0.05 0.05 0.05
2 0.20 0.40 0.60 0.05 0.10 0.15
3 0.20 0.60 1.20 0.05 0.15 0.30
4 0.20 0.80 2.00 0.05 0.20 0.50
5 0.20 1.00 3.00 0.05 0.25 0.75
40 0.20 8.00 164.00 0.05 2.00 41.00
after 40 ticks the light body is 4.000 times as far along as the heavy one
Five rows of hand arithmetic, five rows of program, no daylight between them. Four seconds later the light body has crossed 164 pixels and the heavy one 41, which on a 192-pixel field is the difference between blown clean off it and barely moved. The wind pushed both exactly as hard the entire time.
A stone falls faster than a leaf in air, and a stone is heavier, so the mass rule can look backwards. Push both bodies with wind and gravity at once and the accumulator answers it.
// cmd/forces/main.go
// fall pushes the same two bodies with wind and weight together.
func fall(ticks int) {
light := field.NewBody(field.Vec2{X: 20, Y: 8}, 1)
heavy := field.NewBody(field.Vec2{X: 20, Y: 8}, 4)
fmt.Printf("forces: wind %s and gravity %s on masses 1 and 4\n", vec(wind), vec(gravity))
for t := 1; t <= ticks; t++ {
for _, b := range []*field.Body{&light, &heavy} {
b.ApplyForce(wind)
b.ApplyForce(b.Weight(gravity))
b.Step()
}
}
fmt.Printf("after %d ticks m=1 at %s m=4 at %s\n", ticks, vec(light.Pos), vec(heavy.Pos))
fmt.Printf("they fell %.3f and %.3f, and drifted %.3f and %.3f\n",
light.Pos.Y-8, heavy.Pos.Y-8, light.Pos.X-20, heavy.Pos.X-20)
}
$ go run ./cmd/forces -mode fall -ticks 20
forces: wind 0.200,0.000 and gravity 0.000,0.500 on masses 1 and 4
after 20 ticks m=1 at 62.000,113.000 m=4 at 30.500,113.000
they fell 105.000 and 105.000, and drifted 42.000 and 10.500
Identical falls, different drifts, out of one accumulator that treated both pushes the same way. Gravity arrives as a force built by multiplying the mass by 0.5, so the heavy body is handed a downward push of 2.0 and the light one a push of 0.5. The update then divides each by its own mass and both come back to 0.5, down, on every tick forever. The mass cancels itself. The wind has no such arrangement, because 0.2 east is 0.2 east whatever it is blowing on, so the heavy body drifts a quarter as far while falling in perfect step.
That is also the honest version of the leaf and the stone. In a room full of air a leaf falls slowly because the air pushes back hard for its mass, not because it is light. Take the air away and they land together, which is what this run shows: the only downward force here is weight.
The 0.34-pixel pass
Constant pushes make a poor demonstration of composition, since a constant is the same everywhere and never surprises anyone. A point of attraction is the better test: its force points a different way for every body and changes strength with every step any of them takes. Anything drawn toward anything uses this calculation with different names on it.
The pull points from the body toward the attracting point, so its direction is the vector between them with its length thrown away, which is what normalizing gives you. Its strength is a constant times both masses, divided by the distance multiplied by itself.
F = G · M · m / (r · r)
These runs use G = 0.25 and a point of mass 400, so G · M is 100. Divide the force by the body's own mass and that mass cancels, exactly as weight's did, leaving an acceleration of 100 / (r · r) for every body at that distance whatever it weighs. Here is that number where the swarm actually goes. One tick adds the acceleration straight to the velocity, so the third column is also the speed a single tick hands the body:
| distance r | r · r | acceleration, in pixels per tick per tick |
|---|---|---|
| 100 | 10,000 | 0.01 |
| 50 | 2,500 | 0.04 |
| 25 | 625 | 0.16 |
| 12 | 144 | 0.69 |
| 5 | 25 | 4.00 |
| 1 | 1 | 100.00 |
| 0.34 | 0.1156 | 865.05 |
Halving the distance quadruples the pull, and the bottom two rows are where the trouble lives. A body one pixel from the point is handed 100 pixels per tick of new speed in one tick; a body a third of a pixel away is handed 865. The field is 192 pixels across. Nothing about the arithmetic is wrong, and the same law describes real orbits. What breaks is what a program does with it.
// internal/field/attract.go
// Attractor is a point that pulls on bodies: a place, a mass, and the
// constant that sets how strong the pull is at a given distance.
type Attractor struct {
At Vec2
Mass float64
G float64
}
// Pull is the force this attractor puts on one body: along the line
// between them, proportional to both masses, and falling off with the
// square of the distance.
func (a Attractor) Pull(b Body) Vec2 {
d := a.At.Sub(b.Pos)
r := d.Len()
return d.Unit().Scale(a.G * a.Mass * b.Mass / (r * r))
}
// cmd/forces/main.go — place the swarm, then tick it
// Stream 2 of this seed: the terrain draws from stream 0 and the
// world's laws from stream 1, so placing a swarm disturbs neither.
rng := rand.New(rand.NewPCG(seed, 2))
const n = 24
bodies := make([]field.Body, n)
for i := range bodies {
pos := field.Vec2{X: 16 + rng.Float64()*160, Y: 12 + rng.Float64()*104}
bodies[i] = field.NewBody(pos, 1+float64(rng.IntN(4)))
bodies[i].Vel = field.Vec2{X: rng.Float64()*0.8 - 0.4, Y: rng.Float64()*0.8 - 0.4}
}
point := field.Attractor{At: field.Vec2{X: 96, Y: 64}, Mass: 400, G: 0.25}
buf := render.NewBuffer(FieldW, FieldH)
for t := 0; t <= ticks; t++ {
if t > 0 {
// Slice order, one push, one step: the accumulation order is
// the code's, not the runtime's.
for i := range bodies {
bodies[i].ApplyForce(point.Pull(bodies[i]))
bodies[i].Step()
}
}
draw(buf, bodies, point)
...
}
// cmd/forces/main.go — the frame itself
// draw paints one frame: the night, the point, and every body still on
// the field.
func draw(b *render.Buffer, bodies []field.Body, a field.Attractor) {
b.Fill(night)
cx, cy := int(a.At.X), int(a.At.Y)
b.Line(cx-4, cy, cx+4, cy, pointC)
b.Line(cx, cy-4, cx, cy+4, pointC)
b.FillRect(render.Rect{X0: cx - 1, Y0: cy - 1, X1: cx + 2, Y1: cy + 2}, pointC)
for _, body := range bodies {
if !visible(body.Pos) {
continue
}
x, y := int(body.Pos.X), int(body.Pos.Y)
b.FillRect(render.Rect{X0: x, Y0: y, X1: x + 2, Y1: y + 2}, bodyC)
}
}
$ go run ./cmd/forces -mode swarm -seed 5 -ticks 120
forces: 24 bodies on a 192x128 field, seed 5
point 96.000,64.000 mass 400, G 0.25
tick on the field fastest nearest frame numbers
0 24 0.45 22.82 c3bcb49d980c 639d72db9e9b7ba5
30 19 43.81 0.34 6014d4798b1b 93530f3d90d51e51
60 11 848.86 2.89 b1d7fc6ada89 a0d2796635991a4a
90 5 848.86 14.33 afabfcd63164 a134377faf18d4d8
120 0 848.86 114.52 766848a04d52 ac9528da90960e1f
closest approach: body 9 passed 0.34 from the point at t=30
Twenty-four bodies at tick 0 and none at tick 120. The frame is the buffer the client puts on a screen, so the picture behind that last hash is the night sky, a yellow cross where the point is, and nothing whatever else. Something on the field reached 848.86 pixels per tick, on a field 192 pixels wide, and the count on the left is what happened to everybody.
The summary names body 9 as the one that came closest. The program can print every number a single body sees, so ask it for the eight ticks around that pass:
$ go run ./cmd/forces -mode swarm -seed 5 -ticks 120 -watch 9 -from 26 -to 33
forces: 24 bodies on a 192x128 field, seed 5
point 96.000,64.000 mass 400, G 0.25
watching body 9, mass 4.0
tick on the field fastest nearest frame numbers
0 24 0.45 22.82 c3bcb49d980c 639d72db9e9b7ba5
t= 26 r 16.33 |F| 1.50 |a| 0.38 |v| 2.97 pos 97.193,50.699
t= 27 r 13.35 |F| 2.24 |a| 0.56 |v| 3.53 pos 96.905,54.222
t= 28 r 9.82 |F| 4.15 |a| 1.04 |v| 4.57 pos 96.521,58.777
t= 29 r 5.25 |F| 14.52 |a| 3.63 |v| 8.20 pos 95.777,66.944
t= 30 r 2.95 |F| 45.89 |a| 11.47 |v| 3.27 pos 95.899,63.673
30 19 43.81 0.34 6014d4798b1b 93530f3d90d51e51
t= 31 r 0.34 |F| 3407.80 |a| 851.95 |v| 848.86 pos 347.263,874.462
t= 32 r 848.52 |F| 5.56e-04 |a| 1.39e-04 |v| 848.86 pos 598.628,1685.251
t= 33 r 1697.38 |F| 1.39e-04 |a| 3.47e-05 |v| 848.86 pos 849.992,2496.040
60 11 848.86 2.89 b1d7fc6ada89 a0d2796635991a4a
90 5 848.86 14.33 afabfcd63164 a134377faf18d4d8
120 0 848.86 114.52 766848a04d52 ac9528da90960e1f
closest approach: body 9 passed 0.34 from the point at t=30
Read the r column first. It is the distance the pull was computed from at the start of each tick, so the 0.34 on the tick 31 line is where the body finished tick 30. The force climbs the way the table promised: 1.5 at sixteen pixels out, 14.5 at just over five, 3,407.8 at a third of a pixel. Dividing by this body's mass of 4 leaves an acceleration of 851.95 pixels per tick per tick.
Then the update spends it. The velocity takes on all 851.95 of it at once, which very nearly cancels the 3.27 the body was carrying the other way and leaves it going 848.86. The position takes on that velocity, and the body moves 848.86 pixels in a single tick, from 95.899, 63.673 to 347.263, 874.462. Two ticks later it is 2,496 pixels below a field 128 pixels tall, still travelling at exactly the same speed, because the pull out there rounds to four ten-thousandths. Nineteen bodies were still on the field at tick 30 and none at tick 120; the others took the same trip a few ticks apart.
The formula is not the culprit and neither is the accumulator. Both did what they were told. The mistake is treating a point as a place a body can actually arrive at, in a program that samples the force once per tick and then behaves as if that sample held for the entire tick. A real orbit sweeps through the deepest part of the pull in a vanishing instant. This tick sweeps through it at one sample, held for a tenth of a second, and a sample with no upper limit multiplied by a step of fixed length produces whatever number it feels like.
One case is worse still. Let a body land exactly on the point and r becomes 0, the division becomes a division by zero, and the position turns into NaN. Everything downstream then agrees quietly: NaN compares false against every bound you test it with, so a body at NaN is neither on the field nor off it, and it never appears in a log. One divisor is behind both failures, and it needs a floor.
// internal/field/attract.go
type Attractor struct {
At Vec2
Mass float64
G float64
// Soft is the radius at which this point stops being a point. No
// pull is computed from a distance smaller than this. Zero means no
// floor at all, which is the arithmetic that breaks.
Soft float64
}
func (a Attractor) Pull(b Body) Vec2 {
d := a.At.Sub(b.Pos)
r := d.Len()
if r < a.Soft {
r = a.Soft
}
return d.Unit().Scale(a.G * a.Mass * b.Mass / (r * r))
}
$ go run ./cmd/forces -mode swarm -seed 5 -ticks 120 -watch 9 -from 26 -to 33
forces: 24 bodies on a 192x128 field, seed 5
point 96.000,64.000 mass 400, G 0.25, softening 12.0
watching body 9, mass 4.0
tick on the field fastest nearest frame numbers
0 24 0.45 22.82 c3bcb49d980c 639d72db9e9b7ba5
t= 26 r 16.33 |F| 1.50 |a| 0.38 |v| 2.97 pos 97.193,50.699
t= 27 r 13.35 |F| 2.24 |a| 0.56 |v| 3.53 pos 96.905,54.222
t= 28 r 9.82 |F| 2.78 |a| 0.69 |v| 4.23 pos 96.553,58.436
t= 29 r 5.59 |F| 2.78 |a| 0.69 |v| 4.92 pos 96.132,63.341
t= 30 r 0.67 |F| 2.78 |a| 0.69 |v| 5.61 pos 95.575,68.927
30 24 5.61 4.95 d7738b4c0c66 2185265101f636f8
t= 31 r 4.95 |F| 2.78 |a| 0.69 |v| 4.92 pos 95.077,73.822
t= 32 r 9.86 |F| 2.78 |a| 0.69 |v| 4.23 pos 94.645,78.025
t= 33 r 14.09 |F| 2.01 |a| 0.50 |v| 3.72 pos 94.261,81.726
60 23 3.41 9.12 818829d8d4a6 5fd1c1380a9314e4
90 21 3.19 11.84 ac591aa19ff2 6e03fe8bb38449c8
120 20 5.06 0.78 893ec5aeb565 861e36d0a4873a21
closest approach: body 6 passed 0.23 from the point at t=114
The same body, the same seed, the same pass through the middle, and the force now stops climbing at 2.78. That number is the whole fix: 0.25 times 400 times a mass of 4, divided by 12 times 12, and every distance under 12 is handed the same answer. Body 9 spends ticks 28 to 32 inside the soft region and comes out the far side at 4.23 pixels per tick having gone in at 4.23. The deceleration on the way out mirrors the acceleration on the way in, which is what a slingshot looks like when the arithmetic survives it.
The floor is on the distance used in the arithmetic, not on where a body may go. Body 9 passed within 0.67 of the point and body 6 within 0.23, and both kept flying. Twenty of the twenty-four are still on the field at tick 120, and the four that left were flung by speeds the law actually gave them.
Tick 0 is identical in both runs, frame c3bcb49d980c and numbers
639d72db9e9b7ba5, because tick 0 draws the seeded placements before any
force has been applied. Everything after it differs in both columns. Write the last
frame out and look at it:
$ go run ./cmd/forces -mode swarm -seed 5 -ticks 120 -shot swarm.png | tail -1
wrote swarm.png
Twenty pale squares around a yellow cross, most of them gathered close, a few still coming back down from a pass that threw them wide. Run it again and every column repeats to the last digit: the seed placed the bodies and arithmetic did the rest.
A floor is the plainest fix and it leaves a corner in the curve: at 12.001 pixels the pull is still growing and at 11.999 it has stopped. Simulations that care how a force behaves near the middle add a small constant to the squared distance before dividing instead, so the divisor can never reach zero and the curve bends over smoothly rather than hitting a wall. The constant plays the same part as the radius here, and it is chosen the same way: it is a statement about the smallest distance the model still claims to know anything about.
The _test.go properties
Both rules this chapter added are arithmetic, so they can be pinned as arithmetic rather than judged by looking at a picture. The pushes must sum and then be divided once. The floor must hold at any distance under it.
// internal/field/field_test.go
// TestPushesSumThenDivide pins the two halves of the accumulator: every
// push lands in one total, and the total is divided by the mass once,
// when the tick spends it.
func TestPushesSumThenDivide(t *testing.T) {
b := NewBody(Vec2{}, 2)
b.ApplyForce(Vec2{X: 3, Y: 4})
b.ApplyForce(Vec2{Y: 1})
if got, want := b.Force(), (Vec2{X: 3, Y: 5}); got != want {
t.Errorf("two pushes summed to %v, want %v", got, want)
}
if got, want := b.Accel(), (Vec2{X: 1.5, Y: 2.5}); got != want {
t.Errorf("a mass of 2 turned that into %v, want %v", got, want)
}
b.Step()
if got, want := b.Vel, (Vec2{X: 1.5, Y: 2.5}); got != want {
t.Errorf("the velocity took on %v, want %v", got, want)
}
if got := b.Force(); got != (Vec2{}) {
t.Errorf("the accumulator held %v after a step, want the zero vector", got)
}
}
// TestSofteningCapsThePull pins what the softening radius is for: below
// it the pull stops climbing, and without it the same body is handed a
// force a thousand times larger.
func TestSofteningCapsThePull(t *testing.T) {
soft := Attractor{At: Vec2{}, Mass: 400, G: 0.25, Soft: 12}
edge := soft.Pull(NewBody(Vec2{X: 12}, 4)).Len()
inside := soft.Pull(NewBody(Vec2{X: 0.34}, 4)).Len()
if math.Abs(edge-inside) > 1e-9 {
t.Errorf("the pull at 0.34 is %.2f and at the radius %.2f: the floor is not holding", inside, edge)
}
bare := Attractor{At: Vec2{}, Mass: 400, G: 0.25}
if got := bare.Pull(NewBody(Vec2{X: 0.34}, 4)).Len(); got < 1000*edge {
t.Errorf("without a floor the pull at 0.34 is %.2f, expected it to dwarf %.2f", got, edge)
}
}
$ go test -count=1 -v ./internal/field/
=== RUN TestPushesSumThenDivide
--- PASS: TestPushesSumThenDivide (0.00s)
=== RUN TestOnePushTwoMasses
--- PASS: TestOnePushTwoMasses (0.00s)
=== RUN TestGravityIgnoresMass
--- PASS: TestGravityIgnoresMass (0.00s)
=== RUN TestSofteningCapsThePull
--- PASS: TestSofteningCapsThePull (0.00s)
PASS
ok theworld/internal/field 0.002s
Now take the division out of the update, so the accumulated force goes straight into the velocity the way an acceleration used to, and run the two tests that care:
$ go test -count=1 -run 'TestOnePushTwoMasses|TestPushesSumThenDivide' ./internal/field/
--- FAIL: TestPushesSumThenDivide (0.00s)
field_test.go:23: the velocity took on {3 5}, want {1.5 2.5}
--- FAIL: TestOnePushTwoMasses (0.00s)
field_test.go:41: the light body went 1.000000 times as far, want 4
FAIL
FAIL theworld/internal/field 0.002s
FAIL
The line about the ratio is the one to remember, because it is what a missing division looks like from outside: every body in the world moves identically no matter what it is made of. That reads as perfectly good motion in any single frame, and it is a world where a boulder and a leaf blow away at the same speed.
One division at one address
The accumulator is a place where systems that have never been introduced can agree without negotiating. Gravity does not have to know a wind exists. The point does not check whether anything else is already pulling. Each one works out a vector, hands it over and forgets about it, and addition does the rest. Because addition does not care about order, the sequence the systems happen to run in never leaks into the physics. Independent laws can be written separately and still add up to one motion.
Mass is what makes that errand safe to hand out. It is the one quantity that belongs to the body instead of to any of the pushes, so putting the division inside the tick means a body's response to the entire world lives at exactly one address. Wind, weight, attraction and everything after them pass through it once. Give a creature a heavier body later and every force in the simulation quietly does less to it, with no system needing to be told, and no system able to get it wrong. The forces that scale with mass, weight and the pull of a point, cancel it back out on their own, which is a property of those laws rather than a special case in the code.
The softening radius is the piece that travels furthest, because it is barely about attraction. A fixed step samples the world at instants and then acts as though each sample described the whole interval. Any law that can produce an enormous value in a small region will therefore be sampled at its worst and applied for a full tick, and that product decides whether the simulation stays believable. The radius answers a design question: below what distance does this model stop pretending to know what is going on? Twelve pixels, for a swarm whose fastest body moves about five pixels a tick, means four or five ticks of crossing rather than one instant of catastrophe. The same reasoning waits at every divisor built from a distance nobody has proved cannot be small: a collision, a spring, or a neighbour query that weights by how near the neighbour is.
One sum, one division, one floor
- I can say why the vector handed to
ApplyForceis a force rather than an acceleration, and what every caller would need to know if the body had no mass. - Given two pushes, a mass and a starting velocity, I can produce the printed force, acceleration, velocity and position for the next three ticks by hand.
- I can explain why two bodies of different mass fall in perfect step under weight and separate under the same wind.
- Reading a watch trace, I can point at the tick where the sampled force outgrew what one step could carry, and say what the position did next.
- I can compute the pull a softening radius caps at, from G, the two masses and the radius, and check it against the run's own printout.
- I can choose a softening radius from a swarm's top speed, and say what a smaller one buys and what it costs.
Exercise 1 — a ceiling as well as a floor. Bodies that get
slingshot out never come back, because the pull at 400 pixels rounds to nothing. Add
three lines to Pull that also refuse to use a distance greater than 40,
then run the swarm for 120 ticks and count what is left.
The same clamp written the other way, under the floor:
if r > 40 { r = 40 }. Past 40 pixels every body now feels the same
steady 0.0625 of acceleration straight toward the point, so leaving stops getting
easier the further out you get.
$ go run ./cmd/forces -mode swarm -seed 5 -ticks 120 | tail -4
60 24 3.41 9.32 2804884eb4cc 68bea5eb9ad60699
90 23 4.80 2.07 d841e1738330 36ad7c4b82dd66a4
120 24 4.35 7.91 956315f55235 c2ef04992d3633db
closest approach: body 9 passed 0.14 from the point at t=99
All twenty-four are on the field at tick 120, and the picture is a swarm that circulates instead of one that drains. It is also less like gravity than it was three lines ago. Both clamps are the same bargain: a law you could defend anywhere, traded for behavior you can live with on a field this size.
Exercise 2 — does a heavier body fall toward the point faster? Put three bodies of masses 1, 3 and 9 at the same starting place, push them with nothing but the point's pull, run 60 ticks and print their positions to full precision. Predict the answer before running it.
They arrive at the same place, and two of the three agree to the last bit.
// cmd/exp/main.go — the body of main, and nothing else
point := field.Attractor{At: field.Vec2{X: 96, Y: 64}, Mass: 400, G: 0.25, Soft: 12}
for _, m := range []float64{1, 3, 9} {
b := field.NewBody(field.Vec2{X: 40, Y: 30}, m)
for range 60 {
b.ApplyForce(point.Pull(b))
b.Step()
}
fmt.Printf("mass %.0f ends at %.17g, %.17g\n", m, b.Pos.X, b.Pos.Y)
}
$ go run ./cmd/exp
mass 1 ends at 100.04641021515403, 66.456749059200661
mass 3 ends at 100.04641021515401, 66.456749059200689
mass 9 ends at 100.04641021515403, 66.456749059200661
The pull is built by multiplying by the body's mass and the tick divides by that same mass, so it cancels and the trajectory is the body's own weight-free path. Mass 3 lands two steps of the last bit away from the other two, because multiplying by 3 and dividing by 3 is not exact in binary while multiplying and dividing by 9 happened to come back clean here. The positions are the same picture and not the same number, and a comparison somewhere downstream can still tell.
Exercise 3 — pick the radius wrong on purpose. Run the swarm
at -soft 4 and at -soft 24 and compare what is left on the
field. Which is more faithful to the law, and which would you ship in a world that
has to run for months?
$ go run ./cmd/forces -mode swarm -seed 5 -ticks 120 -soft 4 | tail -3
90 7 10.93 14.33 b5da4b3596ad e2450aca5ef67a41
120 2 10.92 33.38 176585618768 f0a0744f1343b6dd
closest approach: body 2 passed 0.82 from the point at t=72
$ go run ./cmd/forces -mode swarm -seed 5 -ticks 120 -soft 24 | tail -3
90 23 3.23 4.47 da315f10f74c faadc82d04032872
120 24 2.79 5.03 45695cf2cb62 79a8802ffb3c9a41
closest approach: body 13 passed 0.26 from the point at t=30
A radius of 4 caps the acceleration at 6.25 a tick, larger than the speed most of these bodies ever reach, so the close passes still fling them off the field: two survivors out of twenty-four. A radius of 24 caps it at 0.17 and keeps everybody, at the price of a pull that stopped obeying the inverse square across a quarter of the field's width. The first is faithful and unusable; the second is a fiction that runs for months. The sizing question is the one from the previous section: how far does a body travel in one tick at the speeds this world reaches, and does it get several ticks inside the region before it is through?