The Circle Behind the Sine
A height that returns
Everything this volume has moved so far has been moved by a push. A body accumulates forces, divides by its mass, and goes wherever the sum sends it, which covers falling, drifting, being blown and being pulled. It does not cover the other half of what a valley does. Water on the pool rises and settles and rises again. A stalk leans one way in the breeze and comes back. A firefly brightens and dims, a creature's legs swing forward and back, light climbs the eastern rim and drains off the western one, and every one of those is a number that has to return to where it began and set off again without anybody resetting it.
Walk a point steadily around a circle of radius one and read off how high it is above the middle. That reading climbs to 1, comes back through 0, sinks to −1, and returns forever. It slows at the top and the bottom because that is where the point is travelling sideways.
Counting up to a limit and then back down produces motion with a visible corner in it. The value rides upward at a fixed speed, hits its ceiling, and reverses in one tick with no slowing down at all. Keep how far the point has walked instead; take the height as the number.
This book has taught no trigonometry, so nothing below assumes any. The circle is drawn in plain numbers first, with the two readings named only once there is something to name. Then the distance around it is measured with straight lines, since the machine's own way of asking for an angle depends on knowing that distance. After that comes a small type with three knobs on it, a failure that comes from getting the units wrong, and floats riding the pool in The Hollow with every frame hashed.
Eight points on the unit circle
Draw a circle of radius 1 with its middle at (0, 0) and put a point on it at the far right, at (1, 0). Send it round anticlockwise. A quarter of the way round it stands at the top, (0, 1). Halfway, at the far left, (−1, 0). Three quarters of the way, at the bottom, (0, −1). A whole turn brings it home to (1, 0). Nobody needs arithmetic for those four: they are where the circle crosses its own two axes, and anybody can read them off a drawing.
Line up the up-numbers alone and an oscillation is already visible: 0, then 1, then 0, then −1, then 0. Four samples is too coarse to see the slowing, so halve the gaps. Where does the point stand an eighth of the way round, between (1, 0) and (0, 1)? It is out along the diagonal, equally far across and up, at distance 1 from the middle. That is a direction with its length thrown away, which chapter 24 built a method for: take the pair (1, 1) and cut it to a length of one.
Do it by hand first. The length of (1, 1) is the number which multiplied by itself gives 1 + 1 = 2, and squeezing gets there quickly: 1.4 gives 1.96, a little low; 1.42 gives 2.0164, a little high; 1.414 gives 1.999396. So the length is 1.4142, and dividing both numbers by it gives 0.7071 and 0.7071. Check the answer the other way, which is faster: 0.7 × 0.7 + 0.7 × 0.7 = 0.98, just short of 1, and 0.7071 multiplied by itself is 0.49999041, so twice that is 0.99998082. Near enough that four decimal places cannot see the gap.
// cmd/wave/main.go — this chapter's bench
// eighth is the point standing k eighths of the way around a circle of
// radius 1, built out of chapter 24's arithmetic and nothing else. The
// four quarter points are written down because anyone can read them off
// a drawing; the four between them are the diagonal directions cut down
// to a length of one.
func eighth(k int) vec.Vec2 {
pts := [8]vec.Vec2{
{X: 1, Y: 0},
vec.Vec2{X: 1, Y: 1}.Unit(),
{X: 0, Y: 1},
vec.Vec2{X: -1, Y: 1}.Unit(),
{X: -1, Y: 0},
vec.Vec2{X: -1, Y: -1}.Unit(),
{X: 0, Y: -1},
vec.Vec2{X: 1, Y: -1}.Unit(),
}
return pts[k%8]
}
// circle reads the two numbers off the point at each eighth of a turn.
// No trigonometry is used to produce this table: it is Unit, applied to
// four diagonals, and four corners nobody needs a method for.
func circle() {
fmt.Println("a point walked round a circle of radius 1, an eighth of a turn at a time")
fmt.Printf(" %8s %8s %10s %10s %12s\n", "eighths", "turn", "across", "up", "across²+up²")
for k := 0; k <= 8; k++ {
p := eighth(k)
fmt.Printf(" %8d %8s %10.4f %10.4f %12.4f\n",
k, fraction(k), p.X, p.Y, p.X*p.X+p.Y*p.Y)
}
}
$ go run ./cmd/wave -mode circle
a point walked round a circle of radius 1, an eighth of a turn at a time
eighths turn across up across²+up²
0 0 1.0000 0.0000 1.0000
1 1/8 0.7071 0.7071 1.0000
2 1/4 0.0000 1.0000 1.0000
3 3/8 -0.7071 0.7071 1.0000
4 1/2 -1.0000 0.0000 1.0000
5 5/8 -0.7071 -0.7071 1.0000
6 3/4 0.0000 -1.0000 1.0000
7 7/8 0.7071 -0.7071 1.0000
8 1 1.0000 0.0000 1.0000
The last column is the guarantee that these eight points really are on the circle. A
length is a square root of the two numbers each multiplied by itself and added, so a
length of 1 means that sum is 1 with the root left off, and every row says 1.0000. The
four diagonals came out right because Unit divides by whatever length it
finds, and it never had to know that the length in question was 1.4142.
Now read the up column downward, which is the point of the whole table: 0, 0.7071, 1, 0.7071, 0, −0.7071, −1, −0.7071, 0. The steps between entries are not equal. Going from the start to an eighth of a turn lifts the point by 0.7071, and the next eighth lifts it by only 0.2929, even though the point walked exactly as far along the rim both times. That unevenness is the slowing near the top, and it arrives for free out of the geometry: near the top the point is mostly moving sideways, so very little of its travel counts as height.
The two columns have names, and they are the last two words of vocabulary this chapter introduces. The up reading is the sine of the amount turned. The across reading is the cosine. One point, one circle, two numbers read off it, and the across column is the same wave as the up column started two eighths earlier.
Figure 28.1 — the wave on the right is the height column of the circle on the left, plotted against how far the point has walked.
The 1024-sided rim
A table of eighths is fine on paper and useless in a tick loop, which wants to advance the turn by some small amount and ask for the height at wherever that lands. So the turn needs a number. Three candidates are all perfectly workable: count turns, so a whole trip is 1; count degrees, so a whole trip is 360; or count the distance the point has walked along the rim, so a whole trip is however long the rim is. Go's own two functions take the third, and the price of that choice is having to know how far it is round a circle of radius 1.
That distance can be measured instead of looked up, using nothing but the length method from chapter 24. Put a square inside the circle with its four corners on it: those corners are the quarter turns, and going round the square is shorter than going round the circle because straight lines cut the curve. Now add a corner between every pair of existing corners, pushed out onto the circle, and go round the eight-sided figure instead. It is still short, but less short. Finding each new corner is two operations: add the two neighbours and halve them to get the point between, then cut that to a length of one to push it onto the circle.
// cmd/wave/main.go
// rim measures how far it is around a circle of radius 1 without
// knowing the answer first. A square whose corners sit on the circle is
// too short; put a new corner between every pair, pushed out onto the
// circle, and it gets less too short. Doubling the corners is the whole
// method, and the only operations it uses are add, halve and Unit.
func rim() {
corners := []vec.Vec2{{X: 1, Y: 0}, {X: 0, Y: 1}, {X: -1, Y: 0}, {X: 0, Y: -1}}
fmt.Println("the way round a circle of radius 1, measured with straight lines")
fmt.Printf(" %6s %14s %18s %16s\n", "sides", "one side", "all the way round", "half of it")
for n := 4; n <= 1024; n *= 2 {
side := corners[0].Sub(corners[1]).Len()
p := float64(n) * side
fmt.Printf(" %6d %14.9f %18.9f %16.9f\n", n, side, p, p/2)
next := make([]vec.Vec2, 0, 2*n)
for i := range corners {
j := (i + 1) % len(corners)
mid := corners[i].Add(corners[j]).Scale(0.5).Unit()
next = append(next, corners[i], mid)
}
corners = next
}
fmt.Printf(" %6s %14s %18.9f %16.9f\n", "circle", "-", field.Turn, field.Turn/2)
}
$ go run ./cmd/wave -mode rim
the way round a circle of radius 1, measured with straight lines
sides one side all the way round half of it
4 1.414213562 5.656854249 2.828427125
8 0.765366865 6.122934918 3.061467459
16 0.390180644 6.242890305 3.121445152
32 0.196034281 6.273096981 3.136548491
64 0.098135349 6.280662314 3.140331157
128 0.049082457 6.282554502 3.141277251
256 0.024543077 6.283027602 3.141513801
512 0.012271769 6.283145881 3.141572940
1024 0.006135914 6.283175451 3.141587725
circle - 6.283185307 3.141592654
The first row is the square, and its side is 1.414213562, the same number the eighth-turn point was found by dividing by. Each row after it doubles the corners and climbs a little, and the climbing slows because a shorter straight line hugs the curve more closely. By a thousand sides the figure is within a hundred-thousandth of the circle, and the digits that have stopped moving are 6.2831. Nothing anywhere in that loop knew the answer in advance.
The last row is the value Go carries, and the column beside it is the reason anyone has
heard of any of this. Half the way round a circle of radius 1 is 3.141592653…, and
that number has a name of its own, pi, written π. The whole way round
is two of them. Go spells it math.Pi, and this book will spell one full turn
field.Turn, since a turn is what the world's code actually means when it says
it.
Everything above was done in numbers. The shorthand arrives now, with nothing in it that has not already been computed on this page. The amount turned gets a single letter, and by long habit that letter is the Greek theta, written θ. It is measured in rim walked, and a measurement of that kind is called a number of radians: one radian is the turn that walks 1 unit along the rim of a circle of radius 1. A whole turn is 2π radians, because the whole rim is 2π long.
The two readings taken off the point standing at θ are written sin θ for the height and cos θ for the across. Every number in the first table can be written this way now: sin 0 = 0, sin(π/4) = 0.7071, sin(π/2) = 1, cos 0 = 1, cos(π/2) = 0. The last column of that table also has a one-line form, and it is the length rule from chapter 24 with the square root left off:
cos²θ + sin²θ = 1
Read it as a sentence about the picture: however far round the point has gone, it is
still exactly 1 from the middle. Two more numbers matter for a tick loop. If the angle
gains w radians every tick, then after t ticks it stands at
w·t, and one full turn takes 2π/w ticks, which is
the run's period. Turning a protractor's degrees into radians is one multiplication:
180 degrees is half a turn, so one degree is π/180 = 0.017453293 of rim.
math.Sin(x) hands back the height of the point standing x of rim around the
circle, and math.Cos(x) hands back its across reading. Neither draws
anything; each runs a short series of multiplications and additions that converges on
the answer to fifteen digits, the same way math.Sqrt squeezed a square root
in chapter 24. Both take radians and only radians. The package documentation is at
pkg.go.dev/math, and the two neighbouring entries are
math.Pi and math.Atan2, which answers the
reverse question.
// cmd/wave/main.go
// agree checks the hand-built table against the two functions Go ships,
// once the turn is measured as rim walked.
func agree() {
fmt.Println("the same eight points, asked for by rim walked instead of drawn")
fmt.Printf(" %8s %10s %10s %11s %10s %11s\n",
"eighths", "rim", "up", "math.Sin", "across", "math.Cos")
for k := 0; k <= 8; k++ {
p := eighth(k)
r := float64(k) * field.Turn / 8
fmt.Printf(" %8d %10.6f %10.6f %11.6f %10.6f %11.6f\n",
k, r, p.Y, math.Sin(r), p.X, math.Cos(r))
}
}
$ go run ./cmd/wave -mode agree
the same eight points, asked for by rim walked instead of drawn
eighths rim up math.Sin across math.Cos
0 0.000000 0.000000 0.000000 1.000000 1.000000
1 0.785398 0.707107 0.707107 0.707107 0.707107
2 1.570796 1.000000 1.000000 0.000000 0.000000
3 2.356194 0.707107 0.707107 -0.707107 -0.707107
4 3.141593 0.000000 0.000000 -1.000000 -1.000000
5 3.926991 -0.707107 -0.707107 -0.707107 -0.707107
6 4.712389 -1.000000 -1.000000 0.000000 -0.000000
7 5.497787 -0.707107 -0.707107 0.707107 0.707107
8 6.283185 0.000000 -0.000000 1.000000 1.000000
Column three was drawn with a ruler and column four was computed by a library, and to six
decimal places they are one column printed twice. That is the whole justification for
using math.Sin: it is a fast way of asking what the circle would
have said.
Two entries print -0.000000, at three quarters of a turn for the across and
at a whole turn for the height, where the drawn point gives a clean 0.000000.
Nothing is wrong. π never ends, so math.Pi is the nearest number a
float64 can hold, and 6.283185307179586 is a hair under a true whole turn.
The point is therefore a hair short of home, a hair below the axis, and the library
reports that honestly as a height of about −0.00000000000000012. A comparison
written as math.Sin(x) == 0 will be false at every angle that ought to give
zero, which is the first thing to remember about asking a circle for exact numbers.
Amplitude, rate and phase
The height column runs between −1 and 1, and a float on a pool needs to run between
−5 pixels and 5. The angle advances by whatever the caller likes, so the trip round
can take four ticks or four hundred. And two floats on the same pool should not be doing
the same thing at the same instant. Those are the three knobs, and every one of them is a
multiplication or an addition sitting beside the same math.Sin call:
amplitude scales the answer, the rate decides how much
rim a tick buys, and the phase is a fixed head start added to the angle
before the reading is taken.
// internal/field/osc.go
// Turn is one whole trip around the circle, measured the way Go's
// math.Sin measures a turn: the distance walked around the rim of a
// circle of radius 1.
const Turn = 2 * math.Pi
// Osc is one thing that repeats: an angle that gains the same amount
// every tick, and three numbers that turn that angle into a
// displacement from wherever the thing rests.
type Osc struct {
Amp float64 // how far from the middle the swing reaches
Rate float64 // rim gained per tick
Phase float64 // how far around the circle this one starts
theta float64 // rim walked so far, before Phase is added
}
// Every is the rate that carries the angle once around the circle in n
// ticks, which is the knob anyone actually wants to set.
func Every(n float64) float64 { return Turn / n }
// Step advances the angle by one tick's worth. Nothing else moves.
func (o *Osc) Step() { o.theta += o.Rate }
// Angle is where on the rim this oscillator stands right now.
func (o *Osc) Angle() float64 { return o.theta + o.Phase }
// Value is the height of the point standing there, stretched to the
// amplitude: the displacement from the middle, this tick.
func (o *Osc) Value() float64 { return o.Amp * math.Sin(o.Angle()) }
// Across is the same point's sideways reading, on the same scale.
func (o *Osc) Across() float64 { return o.Amp * math.Cos(o.Angle()) }
$ go run ./cmd/wave -mode plot -period 20 -ticks 20
amplitude 6.00, 0.31416 rim a tick, phase 0.00000
tick angle value -10 -5 0 5 10
0 0.0000 0.0000 *
1 0.3142 1.8541 | *
2 0.6283 3.5267 | *
3 0.9425 4.8541 | *
4 1.2566 5.7063 | *
5 1.5708 6.0000 | *
6 1.8850 5.7063 | *
7 2.1991 4.8541 | *
8 2.5133 3.5267 | *
9 2.8274 1.8541 | *
10 3.1416 0.0000 *
11 3.4558 -1.8541 * |
12 3.7699 -3.5267 * |
13 4.0841 -4.8541 * |
14 4.3982 -5.7063 * |
15 4.7124 -6.0000 * |
16 5.0265 -5.7063 * |
17 5.3407 -4.8541 * |
18 5.6549 -3.5267 * |
19 5.9690 -1.8541 * |
20 6.2832 -0.0000 *
The gutter is 41 columns wide at two columns to the pixel, so the asterisk is the float drawn on a line. Twenty ticks carry the angle from 0 to 6.2832, which the last section measured as exactly once round, and the wave closes: tick 20 is back where tick 0 was. One state variable did that, and one addition a tick maintained it.
The unevenness from the first table is now something to look at instead of read. Between ticks 0 and 1 the asterisk moves nearly two pixels; between ticks 4 and 5 it moves less than a third of one. The float slows as it nears the top, hangs, and gathers speed on the way down, and no code anywhere asked for that. It is what reading the height of a steadily walking point produces.
$ go run ./cmd/wave -mode plot -period 20 -amp 10 -ticks 10
amplitude 10.00, 0.31416 rim a tick, phase 0.00000
tick angle value -10 -5 0 5 10
0 0.0000 0.0000 *
1 0.3142 3.0902 | *
2 0.6283 5.8779 | *
3 0.9425 8.0902 | *
4 1.2566 9.5106 | *
5 1.5708 10.0000 | *
6 1.8850 9.5106 | *
7 2.1991 8.0902 | *
8 2.5133 5.8779 | *
9 2.8274 3.0902 | *
10 3.1416 0.0000 *
$ go run ./cmd/wave -mode plot -period 8 -ticks 16
amplitude 6.00, 0.78540 rim a tick, phase 0.00000
tick angle value -10 -5 0 5 10
0 0.0000 0.0000 *
1 0.7854 4.2426 | *
2 1.5708 6.0000 | *
3 2.3562 4.2426 | *
4 3.1416 0.0000 *
5 3.9270 -4.2426 * |
6 4.7124 -6.0000 * |
7 5.4978 -4.2426 * |
8 6.2832 -0.0000 *
9 7.0686 4.2426 | *
10 7.8540 6.0000 | *
11 8.6394 4.2426 | *
12 9.4248 0.0000 *
13 10.2102 -4.2426 * |
14 10.9956 -6.0000 * |
15 11.7810 -4.2426 * |
16 12.5664 0.0000 *
$ go run ./cmd/wave -mode plot -period 20 -phase 0.25 -ticks 10
amplitude 6.00, 0.31416 rim a tick, phase 1.57080
tick angle value -10 -5 0 5 10
0 1.5708 6.0000 | *
1 1.8850 5.7063 | *
2 2.1991 4.8541 | *
3 2.5133 3.5267 | *
4 2.8274 1.8541 | *
5 3.1416 0.0000 *
6 3.4558 -1.8541 * |
7 3.7699 -3.5267 * |
8 4.0841 -4.8541 * |
9 4.3982 -5.7063 * |
10 4.7124 -6.0000 * |
Amplitude first. Every value in the second run is the matching value of the first multiplied by 10/6, and the angle column is untouched, because scaling happens after the reading is taken. The wave is wider and its timing is identical: the peak still lands on tick 5.
The period run changes the timing and nothing else. Eight ticks to a turn means the angle gains 0.78540 a tick, which the interlude names as π/4, so every tick lands on an eighth-turn mark and the value column holds nothing but 0, 4.2426 and 6. Those middle entries are 6 × 0.7071, and they print the same digits as the length chapter 24 squeezed out for a step of 3 across and 3 down, since both are three times 1.41421.
Phase moves the starting place round the circle without touching the speed. A quarter turn of head start is 1.57080 of rim, so the run opens at the top of the ride instead of the middle of it, and from there it is the baseline wave with its first five ticks cut off. Two floats with the same amplitude and rate and different phases are one motion observed at two moments, and that is all it takes to stop a row of them moving as a block.
Here is the mistake in the form it actually arrives in. A float is wanted with a four
second ride, the world runs at ten ticks a second, so that is forty ticks to the turn.
A turn is 360 of something, everyone knows that, so the rate is 360/40 = 9 a tick. The
client's -degrees flag runs exactly that arithmetic:
$ go run ./cmd/worldc -bob -ticks 20 -degrees -floats 1 -every 2
bob: 1 float on the pool of seed 5, amplitude 5.00 pixels, one turn every 40 ticks (4.0 seconds)
the angle gains 9.00000 degrees a tick; phases 0.0000
tick angle lift A row frame
0 0.0000 0.00 72 29b627151ec4ac5c
2 18.0000 -3.75 76 b648e1d93266fdf0
4 36.0000 -4.96 77 f2958a77947bbbe9
6 54.0000 -2.79 75 befe1d589b40dd8c
8 72.0000 1.27 71 d6d6dba22d48cc8f
10 90.0000 4.47 68 049036bea38be90f
12 108.0000 4.63 67 e2d1e5998b1fb945
14 126.0000 1.65 70 697596c2af38dec0
16 144.0000 -2.46 74 7fa0aea25d57e413
18 162.0000 -4.89 77 f2958a77947bbbe9
20 180.0000 -4.01 76 b648e1d93266fdf0
float A's angle after 20 ticks is 180, and one whole turn is 6.2831853071795862
Three things are wrong at once and each of them is a clue. The float sets off downward when a run starting at zero and gaining angle ought to rise. It reaches the bottom of its ride at tick 4, not tick 30. And the whole wobble closes in about fifteen ticks instead of forty, so a ride meant to last four seconds is over in a second and a half. The row column is jerky in a way the baseline plot never was: 76, 77, 75, 71, 68 skips around instead of easing. The loudest clue is the last line, where the run prints the angle it has reached beside what it believes a whole turn to be: 180 against 6.2831853071795862. Those two numbers cannot both be describing the same circle.
Ask the two functions directly rather than staring at the float. The bench has a mode
that puts the corners of a turn to math.Sin both ways round:
$ go run ./cmd/wave -mode turns
the same four corners of a turn, asked for two ways
turn degrees should be math.Sin(degrees) rim math.Sin(rim)
0 0 0.0000 0.0000 0.000000 0.000000
1/4 90 1.0000 0.8940 1.570796 1.000000
1/2 180 0.0000 -0.8012 3.141593 0.000000
3/4 270 -1.0000 -0.1760 4.712389 -1.000000
1 360 0.0000 0.9589 6.283185 -0.000000
one degree is 0.017453293 of rim, so 360 degrees fed to math.Sin is 57.2958 turns
The last line names the cause exactly. math.Sin counts rim, and 360 units of
rim is not one lap of a circle of radius 1: it is 57.2958 laps. Every degree handed over
was treated as 57 times the turn it was meant to be, so a rate of 9 a tick sent the point
round nearly a lap and a half every tick. The wobble the float showed is the remainder after
all those whole laps are discarded. Nine radians is one lap plus 2.7168, and this run
printed every second tick, which is 5.4336 of rim: 0.8496 short of another whole
lap. Read at that stride the point slips backwards by 0.8496 a line, and that is why the
float sank first and why it came back to the start after about fifteen ticks.
The fix has two forms and only one should stay in the program. Multiplying degrees by
0.017453293 on the way in works and leaves two units in the program to confuse later.
Setting the rate with field.Every(40) keeps a single unit everywhere and
says what was actually meant, which was a turn every forty ticks. Degrees are for
protractors. A tick loop should never see one.
Three floats on the pool
The valley the client draws has water in the middle of it, and water is the first place a
reader will look for this motion. So give the pool three floats: a rest position each,
which is where the plank would sit on a dead calm surface, and one Osc each to
lift it off that position. Nothing else in the frame is allowed to move, so the ground is
generated once, tiled once, and drawn from the same tile map on every pass.
One detail deserves stating before it bites. The circle was drawn with its second number counting upward, as every circle is, while a framebuffer counts rows downward from the top. A lift of five pixels is therefore five pixels of smaller row number, and the conversion belongs in the one place that touches the screen.
// cmd/worldc/main.go
// Float is one thing riding the surface of the pool: the place it would
// sit on still water, and the oscillation that lifts it off that place.
type Float struct {
Rest field.Vec2
Bob field.Osc
}
// Screen is where this float is drawn, in world pixels. The value is a
// height, and a screen counts Y downward, so a lift of five pixels is
// five pixels of smaller Y.
func (f *Float) Screen() (int, int) {
return int(math.Round(f.Rest.X)), int(math.Round(f.Rest.Y - f.Bob.Value()))
}
// timber is the colour a float is painted in, palette entry 14.
const timber render.Color = 0xFFC75938
// raft paints a four-by-two plank at a float's current place.
func raft(b *render.Buffer, cam render.Camera, f *Float) {
wx, wy := f.Screen()
sx, sy := cam.ToScreen(wx, wy)
b.FillRect(render.Rect{X0: sx - 2, Y0: sy - 1, X1: sx + 2, Y1: sy + 1}, timber)
}
// cmd/worldc/main.go — placing them, and the loop
floats := make([]Float, n)
for i := range floats {
floats[i] = Float{
Rest: field.Vec2{X: 44 + float64(i)*32, Y: 72},
Bob: field.Osc{
Amp: amp,
Rate: rate,
Phase: spread * float64(i) * field.Turn,
},
}
}
for t := 0; t <= ticks; t++ {
if t > 0 {
for i := range floats {
floats[i].Bob.Step() // the whole of this chapter, once a tick
}
}
b.Fill(render.Void)
render.DrawMapVia(b, tiles, a.tiles, cam)
for i := range floats {
raft(b, cam, &floats[i])
}
h := b.Hash()
...
}
$ go run ./cmd/worldc -bob -ticks 40
bob: 3 floats on the pool of seed 5, amplitude 5.00 pixels, one turn every 40 ticks (4.0 seconds)
the angle gains 0.15708 rim a tick; phases 0.0000 0.0000 0.0000
tick angle lift A row lift B row lift C row frame
0 0.0000 0.00 72 0.00 72 0.00 72 faa668b96e47bb3c
5 0.7854 3.54 68 3.54 68 3.54 68 f7f2a5cdaaf6df4d
10 1.5708 5.00 67 5.00 67 5.00 67 2d5d7d1e364c2557
15 2.3562 3.54 68 3.54 68 3.54 68 f7f2a5cdaaf6df4d
20 3.1416 0.00 72 0.00 72 0.00 72 faa668b96e47bb3c
25 3.9270 -3.54 76 -3.54 76 -3.54 76 61088d1046bf7ed7
30 4.7124 -5.00 77 -5.00 77 -5.00 77 d78009c5b62a9116
35 5.4978 -3.54 76 -3.54 76 -3.54 76 61088d1046bf7ed7
40 6.2832 -0.00 72 -0.00 72 -0.00 72 faa668b96e47bb3c
float A's angle after 40 ticks is 6.2831853071795862, and one whole turn is 6.2831853071795862
Nothing in that run reads a random number, and the run says so by repeating itself. The
frame at tick 40 hashes to faa668b96e47bb3c, which is the frame at tick 20
and the frame at tick 0; ticks 5 and 15 pair off the same way, and so do ticks 25 and 35.
A picture drawn from a seeded stream needs the seed quoted beside it before a hash means
anything; the only seed here made the ground, which never moves, so any machine running
this command prints these nine hashes.
The last line is the reason ticks 0 and 40 agree so exactly. Forty additions of
field.Turn/40 landed on 6.2831853071795862, digit for digit the same
float64 as one whole turn, so tick 40 really is the start again. The lift
prints -0.00 there rather than 0.00 because
math.Sin of that value is a hair under zero, as the agreement table warned,
and a fifteenth-decimal-place hair rounds to the same row 72 that tick 0 drew.
The row column also shows what rounding does to a small ride. A lift of 3.54 puts the float at 68.46, which rounds to 68, and a lift of −3.54 puts it at 75.54, which rounds to 76. The two are not the same distance from 72 on the screen even though they are the same distance in the numbers, and that asymmetry is unavoidable when a five pixel ride is drawn on whole pixels. The number kept in the float is the exact one.
$ go run ./cmd/worldc -bob -ticks 40 -spread 0.25
bob: 3 floats on the pool of seed 5, amplitude 5.00 pixels, one turn every 40 ticks (4.0 seconds)
the angle gains 0.15708 rim a tick; phases 0.0000 1.5708 3.1416
tick angle lift A row lift B row lift C row frame
0 0.0000 0.00 72 5.00 67 0.00 72 d71db1cdf6110768
5 0.7854 3.54 68 3.54 68 -3.54 76 23e0058009f440da
10 1.5708 5.00 67 0.00 72 -5.00 77 3620fd53aebe0753
15 2.3562 3.54 68 -3.54 76 -3.54 76 64950c6ab377c26f
20 3.1416 0.00 72 -5.00 77 -0.00 72 7c4e100493766fc1
25 3.9270 -3.54 76 -3.54 76 3.54 68 5abaeced5fa93b8d
30 4.7124 -5.00 77 -0.00 72 5.00 67 83a944b1978f7845
35 5.4978 -3.54 76 3.54 68 3.54 68 9d378973add961ff
40 6.2832 -0.00 72 5.00 67 0.00 72 d71db1cdf6110768
float A's angle after 40 ticks is 6.2831853071795862, and one whole turn is 6.2831853071795862
-spread 0.25 gives float B a quarter turn of head start and float C a half
turn, and the pool stops looking like a lift. Watch B's column against A's: B is doing at
tick 0 what A does at tick 10, and at tick 25 what A does at tick 35. C is half a turn
round, so it is at the bottom whenever A is at the top. Eight distinct hashes now instead
of five, and the run still closes on itself at tick 40, because a head start added once
never changes how long a lap takes.
The wrapped angle
The trick that makes an oscillator cost nothing is that it keeps a quantity which is allowed to grow forever and then reads it through something that cannot. The angle only ever gains; there is no limit test, no direction flag, no ceiling to bounce off, and no state to get out of step with itself. All the coming-back lives in the reading. A circle has no end to fall off, so walking further along it can only ever land you somewhere you have already been, and the height of wherever you land is bounded by the radius whether the angle is 6 or 6,000.
That is also why the three knobs are independent. Amplitude multiplies after the reading, so it cannot affect timing. Rate multiplies the tick count before the reading, so it cannot affect size. Phase adds a constant before the reading, so it cannot affect either. Any combination of the three is a legal wave, and none of them can interfere with another's job. A repeating motion anywhere in this world is now four numbers and a call: how far, how fast, from where, and about what middle.
The unit lesson keeps costing money. Two things measure turning here,
a protractor and a library, and they use different rulers for it. The failure earlier was
not really about trigonometry: it was a number crossing a boundary with its unit left
behind, and the same species is waiting at every other boundary in this book. Pixels
against cells. Ticks against seconds. Pick one unit per
quantity for the whole program, convert at the edges where a human types or reads, and
never let a second unit inside. field.Every exists for that reason alone, so
that no caller ever writes a raw rate and has to remember what it counts.
A circle, a rim and three knobs
- I can fill in the height and across readings at all eight eighth-turn points from a drawing plus one square root, and check each row with cos²θ + sin²θ = 1.
- I can explain why a point walking the rim at a steady pace produces a height that slows near the top, without appealing to any formula.
- I can measure 6.283185 by doubling the corners of a square inside a circle, and name the 3.141592 that is half of it.
- Given a wanted period in ticks, I can state the rate in rim per tick, and given a rate I can state the period.
- Shown a wave that runs at the wrong speed in the wrong direction, I check the unit the angle is counted in before I look at anything else.
- I can say which of amplitude, rate and phase changes the timing of a peak, and pin a bobbing motion with per-tick frame hashes that repeat once a lap.
Exercise 1 — ride the cosine instead. Change
Float.Screen to use Across where it uses Value,
predict what the first line of the run will say, then run
-bob -ticks 20 and compare the hashes with the run above.
The across reading starts at 1 and falls, so the floats begin at the top of the ride
and sink from there. Only the drawing changed, so the printed lift column still
reports Value and is now ignorable; the row column and the hashes are
what to read.
$ go run ./cmd/worldc -bob -ticks 20
bob: 3 floats on the pool of seed 5, amplitude 5.00 pixels, one turn every 40 ticks (4.0 seconds)
the angle gains 0.15708 rim a tick; phases 0.0000 0.0000 0.0000
tick angle lift A row lift B row lift C row frame
0 0.0000 0.00 67 0.00 67 0.00 67 2d5d7d1e364c2557
5 0.7854 3.54 68 3.54 68 3.54 68 f7f2a5cdaaf6df4d
10 1.5708 5.00 72 5.00 72 5.00 72 faa668b96e47bb3c
15 2.3562 3.54 76 3.54 76 3.54 76 61088d1046bf7ed7
20 3.1416 0.00 77 0.00 77 0.00 77 d78009c5b62a9116
float A's angle after 20 ticks is 3.1415926535897931, and one whole turn is 6.2831853071795862
Every hash here appears in the sine run ten ticks later: 2d5d7d1e364c2557
was its tick 10, faa668b96e47bb3c its ticks 0 and 20,
d78009c5b62a9116 its tick 30. Cosine is sine with a quarter turn of
phase already added, which the same run reaches by setting
Phase: field.Turn / 4 and leaving Value alone.
Exercise 2 — twice as fast, and prove it. Run
-bob -ticks 20 -period 20 -every 5. Before you do, predict which of the
forty-tick run's hashes each of its lines will print.
Halving the period doubles the rate, so tick 5 of the fast run should stand where tick 10 of the slow one stood, tick 10 where tick 20 stood, and tick 15 where tick 30 stood.
$ go run ./cmd/worldc -bob -ticks 20 -period 20 -every 5
bob: 3 floats on the pool of seed 5, amplitude 5.00 pixels, one turn every 20 ticks (2.0 seconds)
the angle gains 0.31416 rim a tick; phases 0.0000 0.0000 0.0000
tick angle lift A row lift B row lift C row frame
0 0.0000 0.00 72 0.00 72 0.00 72 faa668b96e47bb3c
5 1.5708 5.00 67 5.00 67 5.00 67 2d5d7d1e364c2557
10 3.1416 0.00 72 0.00 72 0.00 72 faa668b96e47bb3c
15 4.7124 -5.00 77 -5.00 77 -5.00 77 d78009c5b62a9116
20 6.2832 -0.00 72 -0.00 72 -0.00 72 faa668b96e47bb3c
float A's angle after 20 ticks is 6.2831853071795862, and one whole turn is 6.2831853071795862
Every line matches: each hash here is the forty-tick run's hash from twice the tick number. The frame depends on the angle and on nothing else, so two runs at different speeds draw byte-identical pictures wherever their angles agree, and the rate knob really is only about when.
Exercise 3 — a world that runs for days. The angle never stops
growing. Step an Osc of period 40 for four million ticks, which is about
four and a half days at ten ticks a second, and compare its angle with the exact
hundred-thousand turns it should have walked.
After 4,000,000 steps the angle reads 628318.530699 where the exact answer is
628318.530718: it has drifted by 0.0000191 of rim, because each addition rounds to
the nearest float64 and four million roundings do not cancel. At an
amplitude of 5 that puts the float 0.0000957 of a pixel from where it belongs, which
no screen can show and no hash will notice.
Removing it still pays for itself, and removing it costs three lines in Step:
subtract a whole turn from theta whenever it reaches one. The reading is
unchanged, since a whole turn round the circle lands on the same point. With the
wrap in place the angle after four million ticks is not merely close to zero but
exactly 0, because forty additions of Turn/40 land on Turn
to the last bit and the subtraction is then exact. A world meant to run unattended
for months should keep its angles small on purpose.