The World Vol 2 · Drawing the World
ch 20 / 105
Chapter 20

Where the Camera Stands

The window is smaller than the place it looks at

The map is 48 cells across and 30 down. At sixteen pixels a cell, it is 768 world pixels wide and 480 tall, while the window the client opens holds 256 by 144.

The map is ten screens' worth of pixels and the window shows one of them. Nine tenths of the world is off the screen at any moment, and every one of those cells still has coordinates, still gets ticked, still has a creature standing on it. The camera is a single world coordinate: the world pixel that is drawn at screen (0,0). Turning a world position into a screen position subtracts it, turning a screen position back into the world adds it, and the rest of the camera is that pair of operations applied to a rectangle instead of a point.

The renderer has no way to talk about that yet. Every call it offers takes screen coordinates, counted from the top-left pixel of the buffer, and every position in the world is counted from the top-left cell of the map.

Ask FillRect for the cell at world pixel (640, 192) and it writes nothing at all: the buffer is 256 wide, chapter 13's clip rectangle catches the rectangle before it starts, and the call is thrown away without complaint. Being thrown away is the right behaviour, and it hides the real question.

The clip rectangle can tell you a rectangle missed the screen. It cannot tell you where the rectangle should have gone, because it has never been told which part of the world the screen is currently pointed at. That single fact is the whole of what a camera holds.

The work has four parts: a Camera type holding four numbers, a step that moves it from the keys chapter 15 taught the client to read, a clamp that stops it before the map runs out, and the arithmetic that says which cells the window can reach at all. The check draws the whole map, draws the part the camera can see, and compares the two frames hash for hash.

ToScreen and ToWorld

∑ Interlude: where one creature lands, by hand

Two units are in play and the first job is keeping them apart. A cell is a square of the map, counted in cells: (40, 12) is the forty-first column and the thirteenth row. A world pixel is a position measured in pixels from the map's top-left corner. Cells are sixteen pixels square here, so cell (40, 12) has its corner at world pixel 40·16 = 640 across, 12·16 = 192 down.

Now put a creature there, and put the camera at world pixel (448, 96), which is the corner of cell (28, 6). The screen is 256 by 144. Where on it does the creature appear?

The camera's pixel is the one drawn at screen (0,0), so every other pixel is measured from it.

sx = 640 − 448 = 192   sy = 192 − 96 = 96

Check it by the other road. The creature's cell is 40 − 28 = 12 columns east of the camera's cell and 12 − 6 = 6 rows south of it. Twelve columns east is 12·16 = 192 pixels; six rows south is 6·16 = 96. The same two numbers arrive, which they must: multiplying by sixteen and subtracting can be done in either order.

192 is on a 256-wide screen with 64 pixels to spare, and the creature is 16 pixels wide, so it ends at 208 and nothing is cut off. 96 sits two thirds of the way down a screen 144 tall. The creature is drawn a little right of centre, a little below it.

Backwards is the same subtraction with its sign turned round, and it is what a mouse click needs. A player clicks screen (192, 96): world x is 192 + 448 = 640, world y is 96 + 96 = 192, and dividing each by sixteen gives cell (40, 12). The click landed on the creature.

One more, to see what the arithmetic does when the answer is nowhere. Cell (20, 20) is world pixel (320, 320), and 320 − 448 = −128 with 320 − 96 = 224. A screen coordinate of −128 is 128 pixels west of the window and 224 is 80 below it. Neither number is an error. They are the true positions of something that is not on the screen, and the drawing code already knows what to do with a rectangle like that.

wx, wya world pixel: distance in pixels from the map's top-left corner
sx, sya screen pixel: distance in pixels from the top-left pixel of the framebuffer
cam.X, cam.Ythe camera: the world pixel that is drawn at screen (0,0)
Tthe cell size in pixels: 16, for the map and the sprite sheet alike
·multiplication, kept distinct from the x of a coordinate
▣ Build · stage 1: the map, and the four numbers pointed at it
// internal/render/tiles.go — what the camera is looking at

// The map this chapter looks at, and the window it is looked at through.
const (
	TileSize = 16
	MapCols  = 48
	MapRows  = 30
	ViewW    = 256
	ViewH    = 144
)

// Void is what the screen holds where the map does not reach.
const Void Color = 0xFF16181C

// Tiles is a grid of colored cells: one color per cell, held in a flat
// slice the same way the framebuffer holds pixels, and no idea what any
// of the colors mean.
type Tiles struct {
	Cols, Rows, T int
	Cell          []Color
}

// At is the color of one cell.
func (m *Tiles) At(cx, cy int) Color { return m.Cell[cy*m.Cols+cx] }

// PixW and PixH are the map's size in world pixels, which is what the
// camera clamps against.
func (m *Tiles) PixW() int { return m.Cols * m.T }
func (m *Tiles) PixH() int { return m.Rows * m.T }

// Walker is one creature standing in the world: the world pixel of its
// top-left corner and the sheet column it is drawn from.
type Walker struct {
	X, Y int
	Col  int
}
// internal/render/camera.go
package render

// Camera is where the window stands in the world: the world pixel that
// is drawn at screen (0,0), and the size of the screen it fills. It
// holds no map, no entities and no opinion about what is being drawn.
type Camera struct {
	X, Y int // the world pixel that lands at screen (0,0)
	W, H int // the viewport, in pixels
}

// ToScreen turns a world pixel into a screen pixel. The answer may name
// a pixel that is nowhere near the screen, which is a fact about where
// the thing is, not an error.
func (c Camera) ToScreen(wx, wy int) (int, int) { return wx - c.X, wy - c.Y }

// ToWorld turns a screen pixel back into the world pixel it shows.
func (c Camera) ToWorld(sx, sy int) (int, int) { return sx + c.X, sy + c.Y }

// View is the piece of the world the screen can show, in world pixels.
func (c Camera) View() Rect { return Rect{c.X, c.Y, c.X + c.W, c.Y + c.H} }
$ go run ./cmd/worldc -lands
worldc 0.0.1 map 48x30 cells of 16 = 768x480, viewport 256x144
camera 448,96 sees world x 448..704, y 96..240

  thing                 world      screen
  cell (28,6)        448,  96      0,   0
  cell (40,12)       640, 192    192,  96
  cell (20,20)       320, 320   -128, 224
  cell (47,29)       752, 464    304, 368
  east walker        696, 200    248, 104

screen 192,96 is world 640,192, which is cell (40,12)

Ground(48, 30) fills that grid with a rock rim, a river bending south and east, a pond and a scatter of stone, by arithmetic with no randomness anywhere in it, and Herd() stands eight creatures on it at positions chosen by hand. The cells hold flat colours so that every number on this page can be checked against the picture by hand; a cell painted out of a tileset lands in exactly the same place, because the camera is not told what is in it. Where the cell lands is the entire question.

Five coordinates, three of them off the screen in one direction or another, and the round trip at the bottom returning the cell the interlude worked out. The camera's own cell lands at (0,0), as it has to: that is the definition, printed back.

View is where the type starts earning its keep. A camera at (448, 96) filling a 256 by 144 screen can show world x from 448 up to but not including 704, and world y from 96 up to 240. That is a Rect, half-open in the same way every rectangle has been since chapter 13, and it is stated in world pixels instead of screen pixels. Two rectangles now describe the same picture in two coordinate systems, and the camera is the pair of numbers that converts between them.

The last row of the table is the one to keep an eye on. That creature stands at world pixel 696, its sprite is sixteen wide, so it covers world 696 through 711 and the window stops at 704. Eight of its columns are on the screen and eight are not. It is going to matter twice.

Moving the camera without moving the map

A camera made of two numbers moves by changing two numbers. Nothing in the map moves, nothing in the sprite sheet moves, and no drawing code is told anything: the same world coordinates go in and different screen coordinates come out, because the value being subtracted changed. Chapter 15 built the client's own idea of a keyboard, a State holding this frame's readings and the previous frame's, with Down for a key being held. Scrolling is four questions and four additions.

▣ Build · stage 2: the keys, and a wall at the map's edge
// cmd/worldc/main.go

// Speed is how far the camera moves per frame while a key is held, in
// world pixels.
const Speed = 3

// view is the client as the shell sees it: something that takes one step
// per frame from the keyboard and paints one frame.
type view struct {
	cam  render.Camera
	m    *render.Tiles
	herd []render.Walker
	s    *render.Sheet
}

// Step scrolls the camera while a direction is held, then puts it back
// inside the map.
func (v *view) Step(in *input.State) error {
	if in.Down(input.Left) {
		v.cam.X -= Speed
	}
	if in.Down(input.Right) {
		v.cam.X += Speed
	}
	if in.Down(input.Up) {
		v.cam.Y -= Speed
	}
	if in.Down(input.Down) {
		v.cam.Y += Speed
	}
	v.cam.Clamp(v.m.PixW(), v.m.PixH())
	return nil
}
// internal/render/camera.go

// Clamp keeps the view inside a world worldW by worldH pixels, so no
// frame ever shows what is past the map's edge.
func (c *Camera) Clamp(worldW, worldH int) {
	c.X = clampTo(c.X, 0, worldW-c.W)
	c.Y = clampTo(c.Y, 0, worldH-c.H)
}

// Center puts a world pixel in the middle of the screen, then clamps.
func (c *Camera) Center(wx, wy, worldW, worldH int) {
	c.X, c.Y = wx-c.W/2, wy-c.H/2
	c.Clamp(worldW, worldH)
}

// clampTo pins v into lo..hi. When hi is below lo, which is what a world
// smaller than the screen produces, lo wins: the map sits against the
// top-left corner instead of drifting off it.
func clampTo(v, lo, hi int) int {
	if v > hi {
		v = hi
	}
	if v < lo {
		v = lo
	}
	return v
}
$ go run ./cmd/worldc -keys
map 768x480, viewport 256x144, camera limits x 0..512, y 0..336

  frame  keys          camera     world x seen   world y seen
  start               448,  96     448..704        96..240
      1  -            448,  96     448..704        96..240
      2  right        451,  96     451..707        96..240
      3  right        454,  96     454..710        96..240
      4  right        457,  96     457..713        96..240
      5  right+down   460,  99     460..716        99..243
      6  right+down   463, 102     463..719       102..246
      7  left         460, 102     460..716       102..246
      8  left         457, 102     457..713       102..246
     48  right x40    512, 102     512..768       102..246
     88  up x40       512,   0     512..768         0..144

The keyboard in that run is a written-down one: a list of which keys are held on which frames, pushed into the same input.State a window fills. Chapter 15 put the physical keys in one table inside the platform layer so that every other package would name actions instead of hardware, and the payment for that discipline arrives here. A camera can be driven, tested and printed with no window open at all.

Two limits are doing work. The map is 768 wide and the window is 256, so the furthest east the camera can stand is 768 − 256 = 512: any further and the frame's right-hand columns would show world coordinates the map does not have. Forty frames of held right from 457 would reach 577, and the camera stops dead at 512 instead, which is the line the last two rows demonstrate on both axes.

Three pixels a frame is a deliberate number. Sixteen would scroll a whole cell per frame and look like a slideshow; three is a walking pace at sixty frames a second, and it means the camera stands on a cell boundary about one frame in sixteen. For the other fifteen the window's edges fall inside cells, halfway through a tile, with a partial column of one cell at the left and a partial column of the next at the right. That remainder matters.

The clamp has one case with no edge to sit against, and it turns up the first time anybody builds a small test map.

▣ Build · stage 3: asking for impossible positions
$ go run ./cmd/worldc -edges
  map          asked for       camera     world x seen   world y seen
   768x480   moved to      -40,  -40     0,   0       0..256         0..144
   768x480   moved to     9000, 9000   512, 336     512..768       336..480
   768x480   centered on   640,  192   512, 120     512..768       120..264
   768x480   centered on    40,   40     0,   0       0..256         0..144
   192x128   centered on    96,   64     0,   0       0..256         0..144

The first four rows are ordinary. Ask for a position off the north-west corner and the camera lands on (0,0); ask for one nine thousand pixels out and it lands on the south-east limit. Center puts a world pixel in the middle of the screen where there is room and gives up gracefully where there is not: centring on (640, 192) wants a camera at (512, 120) and gets it, while centring on (40, 40) wants (−88, −32) and is pulled back to the corner.

The last row is the honest case. That map is 192 by 128 pixels, smaller than the 256 by 144 window in both directions. The camera's own limit, worldW - c.W, works out to −64, which is below its lower limit of 0, and the two comparisons inside clampTo disagree about what to do. Order settles it: the high test runs first and the low test overrules it, so the answer is 0 and the map sits in the top-left corner with 64 columns and 16 rows of empty frame beside it. Swap those two if statements and the answer becomes −64, which centres the small map and leaves empty frame on all four sides. Both are defensible. What is not defensible is not knowing which one you wrote, because the case only appears on maps you built to test something else.

The cells the window can reach

The camera can now stand anywhere legal, and the renderer still draws by walking every cell of the map and asking the clip rectangle to sort it out. That gets the picture right. It costs 1,440 rectangle fills to put 144 cells on the screen, and the waste grows with the map: the same window on a map four times the size does four times the work for the same picture.

Culling is the arithmetic that skips the cells the window cannot reach, and it is arithmetic, not a rule of thumb. Cell number c covers world pixels from c·16 up to c·16 + 16. The window covers world pixels from cam.X up to cam.X + 256. A cell can put a pixel on the screen exactly when those two spans overlap. The near edge is the easy half: the first visible column is the cell containing cam.X, and dividing by sixteen while throwing the remainder away names precisely that cell. The far edge looks like the same question asked about cam.X + 256.

▣ Build · stage 4: the visible range
// internal/render/camera.go

// Cells is the range of cells of size t the view touches, cut down to a
// map that is cols by rows cells. The four numbers count cells, not
// pixels; what it borrows from Rect is the half-open convention, so
// X1 and Y1 are one past the last cell.
func (c Camera) Cells(t, cols, rows int) Rect {
	v := c.View()
	r := Rect{
		X0: v.X0 / t,
		Y0: v.Y0 / t,
		X1: v.X1 / t,
		Y1: v.Y1 / t,
	}
	return r.Intersect(Rect{0, 0, cols, rows})
}

Four divisions and one intersection. The view runs from world 448 to 704 across, so the range is 448/16 = 28 up to 704/16 = 44, and half-open means columns 28 through 43: sixteen of them, and 16 · 16 = 256 covers the window with nothing left over. The intersection at the end is the same guard Clamp provides in pixels, applied in cells, so a range reaching past the map comes back cut to the map.

Now two renderers, drawing the same picture by different routes. One walks the whole map. The other walks the visible range, and tests each creature's rectangle against the view before touching it.

▣ Build · stage 5: the wasteful renderer and the careful one
// internal/render/tiles.go

// Frame is the work one render did. Two renderers that agree about
// pixels can still be compared on this.
type Frame struct {
	Cells, Sprites int
}

// DrawAll draws every cell and every walker on the map, wherever the
// camera puts them, and leans on the clip rectangle to throw away
// whatever misses the screen.
func DrawAll(b *Buffer, m *Tiles, ws []Walker, s *Sheet, cam Camera) Frame {
	b.Fill(Void)
	var f Frame
	for cy := range m.Rows {
		for cx := range m.Cols {
			sx, sy := cam.ToScreen(cx*m.T, cy*m.T)
			b.FillRect(Rect{sx, sy, sx + m.T, sy + m.T}, m.At(cx, cy))
			f.Cells++
		}
	}
	for _, w := range ws {
		sx, sy := cam.ToScreen(w.X, w.Y)
		b.Blit(s, s.Frame(w.Col, 0), sx, sy)
		f.Sprites++
	}
	return f
}

// DrawVisible draws the same picture, touching only the cells and
// walkers the viewport can reach.
func DrawVisible(b *Buffer, m *Tiles, ws []Walker, s *Sheet, cam Camera) Frame {
	b.Fill(Void)
	var f Frame

	vis := cam.Cells(m.T, m.Cols, m.Rows)
	for cy := vis.Y0; cy < vis.Y1; cy++ {
		for cx := vis.X0; cx < vis.X1; cx++ {
			sx, sy := cam.ToScreen(cx*m.T, cy*m.T)
			b.FillRect(Rect{sx, sy, sx + m.T, sy + m.T}, m.At(cx, cy))
			f.Cells++
		}
	}

	view := cam.View()
	for _, w := range ws {
		box := Rect{w.X, w.Y, w.X + s.Cell, w.Y + s.Cell}
		if box.Intersect(view).Empty() {
			continue
		}
		sx, sy := cam.ToScreen(w.X, w.Y)
		b.Blit(s, s.Frame(w.Col, 0), sx, sy)
		f.Sprites++
	}
	return f
}
$ go run ./cmd/worldc
worldc 0.0.1 map 48x30 cells of 16 = 768x480, viewport 256x144
camera 448,96 sees world x 448..704, y 96..240

visible cells x 28..44, y 6..15
whole map:  1440 cells, 8 sprites, sha256 a29ba1be61868183dc6382eb933195c804c063a63645403fa99d48cd96f3780b
culled:      144 cells, 6 sprites, sha256 a29ba1be61868183dc6382eb933195c804c063a63645403fa99d48cd96f3780b
the two frames differ in 0 of 36864 pixels

Ten times the fills, two extra sprites, and the same sixty-four hexadecimal characters. That equality is the whole point of the exercise. Skipping work is easy; skipping only work that could not have changed a pixel is the part that needs proving, and a hash is a proof a machine can repeat. Culling is a claim about a set: every skipped cell would have written nothing. Two frames and one comparison turn that claim into something the client can be held to.

Note which creatures survived the second pass. Six of eight, and one of the six is the one at world 696 with half its body past the window's edge. Its rectangle reaches into the view, the intersection is not empty, and it gets drawn with chapter 16's blit doing what it did there: cutting the source rectangle down and keeping the offsets in step so the visible half is the half that would have been visible anyway.

The seven camera positions

One camera position proves one camera position. The scripted run above put the camera on 451, 454, 457, 460 and 463 within eight frames, and the proof was taken at 448. So take it again at the addresses the client will really be standing on.

▣ Build · stage 6: the same comparison, at seven positions
$ go run ./cmd/worldc -sweep
every position draws 1440 cells the slow way

  camera     culled   whole frame   culled frame   same
  448, 96     144    a29ba1be6186  a29ba1be6186   yes
  449, 96     144    364ee73dc816  7325148f7c96   NO
             137 pixels differ, first at (255,0): ff77664a became ff16181c
  451, 96     144    38114407061e  ad4ee912f317   NO
             404 pixels differ, first at (253,0): ff77664a became ff16181c
  454, 96     144    c849da8666a5  5482c5c0d1c9   NO
             814 pixels differ, first at (250,0): ff77664a became ff16181c
  464, 96     144    92b65b1698ce  92b65b1698ce   yes
  451, 99     144    7640059d51f6  d896eb8b8b98   NO
             1157 pixels differ, first at (253,0): ff77664a became ff16181c
  512,336     144    1a7d23a9dc5d  1a7d23a9dc5d   yes

Four of seven positions disagree, and the two that pass alongside 448 are 464 and 512. Every position that agrees is a multiple of sixteen.

⚠ Worked failure: the column at the right edge that never got drawn

Start with the cheapest clue in the table. The culled count is 144 on every line, aligned or not, and 144 is 16 columns by 9 rows. A window 256 pixels wide covers sixteen whole cells only when its left edge sits exactly on a cell boundary. Move it one pixel east and it covers fifteen whole cells plus a sliver of the cell before and a sliver of the cell after: seventeen columns, not sixteen. A count that never changes as the camera scrolls is a count that was never asked.

Now the pixels. At camera 449 the view runs from world 449 to 705, and Cells computes 705/16, which in whole numbers is 44 with a remainder of 1 thrown away. Half-open makes 44 the exclusive end, so column 44 is not drawn. Column 44 covers world 704 through 719, the window stops at 705, and one column of it belongs on screen: the pixel at world 704, which is screen 704 − 449 = 255, the rightmost column of the frame. That is where the diff starts, at (255,0), and ff77664a becoming ff16181c is soil giving way to the colour the frame was cleared to. Nothing drew there.

The count checks out too. The seam is one column, 144 rows tall, and 137 pixels differ, so seven of them were drawn by something. Those seven are the creature at world 696: its rectangle reaches the view, it is blitted, and column 255 of the screen is column 8 of its sprite, which is opaque in seven of its sixteen rows. The sprite covered a fraction of the hole the missing cells left. Move to camera 451 and the arithmetic scales: 707/16 is 44 remainder 3, three columns are missing, 3·144 = 432 pixels of seam, less the 28 that the same creature's columns 8, 9 and 10 happen to paint, leaving the 404 the run reports. At (451, 99) the same fault fires on both axes at once and takes the bottom three rows with it, and 1,157 pixels change.

The mistake is one division out of four. X0 and Y0 want the cell that contains the view's near edge, and dividing throws the remainder away, which is exactly the cell containing it. X1 and Y1 want one past the cell containing the far edge, and there the thrown-away remainder is a cell that is partly on screen. Half-open ranges are asymmetric by design, and writing them with one symmetric-looking expression is the oldest way to get one wrong. The version that shipped looked right, drew a correct frame at every camera position anybody had tried, and hid behind a test that only ever asked on a cell boundary.

Before repairing it, keep a picture of the fault: go run ./cmd/worldc -x 451 -y 96 -shot seam.png. A bug that has been fixed is hard to look at afterward, and this one shows exactly where the culler lied.

Where the view's right edge falls inside a cell A strip of three map cells numbered 43, 44 and 45, drawn along a world-pixel axis marked 688, 704, 720 and 736. The view's right edge is marked at world pixel 707, three pixels inside cell 44, and the first three pixels of cell 44 are highlighted as being on screen. Below, two lines compare the two ways of dividing: rounding down ends the range at cell 44 and leaves those three pixels blank, rounding up ends it at cell 45 and draws them. THE RIGHT EDGE OF A VIEW AT CAMERA 451 cell 43 cell 44 cell 45 688 704 720 736 view ends at world 707 cell 44 puts 3 pixels on screen: world 704, 705, 706 707 / 16 = 44 range ends at 44: cell 43 is the last drawn, 3 pixels blank (707 + 15) / 16 = 45 range ends at 45: cell 44 is the last drawn, 3 pixels painted

Figure 20.1 — the near edge of a view wants the cell that holds it, the far edge wants the cell after the one that holds it, and only one of those is plain division.

▣ Build · stage 7: rounding the far edge the other way
// internal/render/camera.go — two of the four divisions change
	r := Rect{
		X0: v.X0 / t,
		Y0: v.Y0 / t,
		X1: ceilDiv(v.X1, t),
		Y1: ceilDiv(v.Y1, t),
	}

// ceilDiv divides and rounds up, for two numbers that are not negative.
// Clamp keeps the camera at zero or beyond, so the far edge of a view is
// always a positive number of pixels.
func ceilDiv(a, b int) int { return (a + b - 1) / b }
$ go run ./cmd/worldc -sweep
every position draws 1440 cells the slow way

  camera     culled   whole frame   culled frame   same
  448, 96     144    a29ba1be6186  a29ba1be6186   yes
  449, 96     153    364ee73dc816  364ee73dc816   yes
  451, 96     153    38114407061e  38114407061e   yes
  454, 96     153    c849da8666a5  c849da8666a5   yes
  464, 96     144    92b65b1698ce  92b65b1698ce   yes
  451, 99     170    7640059d51f6  7640059d51f6   yes
  512,336     144    1a7d23a9dc5d  1a7d23a9dc5d   yes

(a + b - 1) / b is division that rounds up, and it works by pushing the numerator to the top of its bucket before the remainder is discarded: 704 + 15 is 719, which is still 44 buckets of sixteen, while 705 + 15 is 720, which is 45. An edge exactly on a boundary keeps the count it had, and an edge one pixel past a boundary adds a cell. That is the difference between the three rows still drawing 144 cells and the three that now draw 153.

The counts are the interesting column now. 153 is 17 columns by 9 rows, and the row at 451 and 99 is 17 by 10 = 170 because both edges fell inside cells. The work the client does changes by a column or a row as it scrolls, which is what honest culling looks like from the outside, and the frames it produces do not change at all. There is a picture of the damage on disk if you took the shot the failure box asked for; write the repaired frame beside it with go run ./cmd/worldc -x 451 -y 96 -shot map.png and measure the difference with the comparison chapter 14 built:

$ go run ./cmd/worldc -diff map.png seam.png
map.png sha256 38114407061e3d3807a57594c6dab4fc0b1dacc9d0b047a0af26a23e20ff969e
seam.png sha256 ad4ee912f317d615b1da8cb9d7322423badfd1c8be4f8bdd4e398ec2532a0b3f
404 of 36864 pixels differ, first at (253,0): ff77664a became ff16181c

Open the two files side by side. They are the same ground, the same creatures, the same pond, and the broken one has a dark stripe three pixels wide down its right edge with a bite taken out of it where a creature stands. On a scrolling map that stripe flickers: it is three pixels at one camera position, one at the next, gone entirely every sixteenth frame. Anybody watching would call it tearing and go looking for the window code.

▣ Build · stage 8: the sweep, written down as a test
// internal/render/camera_test.go

// TestCullingKeepsThePicture is the claim culling makes, written as a
// test: the frame drawn from the visible cells alone is the frame drawn
// from the whole map, pixel for pixel, wherever the camera stands. The
// positions include every offset inside one tile, because a camera that
// scrolls three pixels at a time lands between tile boundaries far more
// often than on them.
func TestCullingKeepsThePicture(t *testing.T) {
	s, err := LoadSheet(filepath.Join("..", "..", "assets", "atlas.png"), 16)
	if err != nil {
		t.Fatal(err)
	}
	m := Ground(MapCols, MapRows)
	herd := Herd()
	all := NewBuffer(ViewW, ViewH)
	cut := NewBuffer(ViewW, ViewH)

	var cams []Camera
	for d := range TileSize {
		cams = append(cams,
			Camera{X: 448 + d, Y: 96, W: ViewW, H: ViewH},
			Camera{X: 448, Y: 96 + d, W: ViewW, H: ViewH},
			Camera{X: d, Y: d, W: ViewW, H: ViewH},
			Camera{X: m.PixW() - ViewW - d, Y: m.PixH() - ViewH - d, W: ViewW, H: ViewH})
	}

	for _, cam := range cams {
		fa := DrawAll(all, m, herd, s, cam)
		fc := DrawVisible(cut, m, herd, s, cam)
		if all.Hash() == cut.Hash() {
			continue
		}
		d := Diff(all, cut)
		gotPath := filepath.Join("testdata", "culled.got.png")
		if err := cut.SavePNG(gotPath); err != nil {
			t.Fatal(err)
		}
		t.Fatalf("camera %d,%d: %d cells culled to %d, and %d pixels changed, first at (%d,%d)\n    wrote %s",
			cam.X, cam.Y, fa.Cells, fc.Cells, d.Count, d.At.X, d.At.Y, gotPath)
	}
}
$ go test -count=1 -v ./internal/render/
=== RUN   TestToScreenAndBack
--- PASS: TestToScreenAndBack (0.00s)
=== RUN   TestClampStaysOnTheMap
--- PASS: TestClampStaysOnTheMap (0.00s)
=== RUN   TestCullingKeepsThePicture
--- PASS: TestCullingKeepsThePicture (0.04s)
=== RUN   TestFrameMatchesItsHash
--- PASS: TestFrameMatchesItsHash (0.00s)
PASS
ok  	theworld/internal/render	0.044s

Sixty-four camera positions, every one of them drawn twice and compared: all sixteen offsets within a cell on each axis, and both far corners of the map walked sixteen pixels inward. With the old division in place the test fails the moment d reaches 1, at camera (449, 96), and writes the broken frame into testdata/ to be looked at. The test does not know what culling is. It knows that two ways of drawing must agree, and it asks in the places where a rounding decision changes hands.

DrawAll beside DrawVisible

DrawAll has no purpose in a shipped client. It is ten times the work for the same picture, and once the camera is correct nobody would run it on a frame the player sees. Deleting it would be the natural cleanup, and keeping it is the reason this chapter has a proof at all. The slow renderer is a specification written in code: it draws every cell that exists, so whatever ends up on the screen is what the map really says, with the clip rectangle as the only judge of what fits. The fast renderer is a claim about that specification. Holding the two together is what makes the claim checkable, and the moment they stop agreeing, the sweep says at which camera position and at which pixel.

That pairing outlives this chapter. An optimization is a bet that some work cannot affect the answer, and the harder bets are the same kind: creatures too far away to matter, a chunk of map that has not changed since the last frame, a light that reaches nowhere. Every one of them is safe or unsafe for a reason you can state in a sentence, and every one of them can be run beside the version that skips nothing. Keeping the slow path alive in a test costs a few milliseconds and buys the only evidence that ever settles the argument.

The transform itself generalizes further than a camera. Two coordinate systems measuring the same thing from different origins are related by the offset between the origins, and converting is adding or subtracting it. Screen and world are the first pair. The same arithmetic covers a window inside a window, a minimap at a fortieth of the size, and a cursor position arriving from an operating system that counts from a different corner. The camera is the smallest possible version of the general case, which is why it is only a subtraction: nothing is rotated, nothing is scaled, and the pixel grid of the screen lines up exactly with the pixel grid of the world.

The rounding lesson is the portable one. Any time a continuous span is turned into a range of buckets, one end wants the bucket holding it and the other wants the bucket after the bucket holding it, and plain division answers only the first question. Tiles in a viewport, samples in a time window, rows in a page of results, bytes in a block: the same asymmetry, the same off-by-one, and the same tell when it goes wrong. The count comes out constant when it should vary, and a sliver at the far end goes missing.

Checkpoint

✓ Checkpoint: what you can now do
  • Given a cell coordinate, a camera and a cell size, work out the screen pixel by hand, in either unit, and get the same answer both ways.
  • Read a negative screen coordinate as a position west or north of the window instead of as an error, and say what the drawing code does with it.
  • Compute the camera's legal range from the map size and the viewport size, and say what clampTo returns when that range is empty.
  • Derive the visible cell range from the view rectangle, explain why one end divides and the other rounds up, and predict how many cells a given camera position draws.
  • State the claim culling makes as a sentence about a set of cells, and turn that sentence into a comparison of two framebuffer hashes.
  • Given a seam at the right edge of a frame, compute its width from the camera position and the cell size before opening the file.
⚡ Exercises: try first, then reveal
Exercise 1: count the cells before the client does. Predict how many cells and how many creatures the culled renderer touches with the camera at world (455, 231), then check with go run ./cmd/worldc -x 455 -y 231.

170 cells and one creature. Across, the view runs 455 to 711: 455/16 is 28, and 711 rounds up to 45, so columns 28 through 44, seventeen of them. Down, the view runs 231 to 375: 231/16 is 14, and 375 rounds up to 24, so rows 14 through 23, ten of them. 17 · 10 = 170. Both edges fall inside cells, so both counts are one more than the sixteen-by-nine minimum. The creatures are the other half of the answer: the camera has scrolled far enough south that seven of the eight are outside the view entirely, and the frame draws one.

Exercise 2: cull a sprite by its corner. Replace the rectangle test in DrawVisible with if !view.Contains(w.X, w.Y) { continue }, which reads perfectly sensibly. Run the test before you run the client.

It fails at the very first camera position, and the message says 38 pixels changed, first at (2,138). That is the creature at world (440, 232): its top-left corner is eight pixels west of a camera standing at 448, so the corner test says no and the rectangle test says yes. Everything with a corner outside the view vanishes, however much of its body is inside, and creatures pop out of existence at the left and top edges while entering normally at the right and bottom. A sprite is a rectangle, so the question that decides its fate has to be asked about a rectangle.

Exercise 3: a map smaller than the window. Build one with Ground(12, 8), centre the camera on it, and render. How many cells are drawn, how many pixels of the frame are left at the clear colour, and do the two renderers still agree?

96 cells, 12,288 pixels of empty frame, and yes. The clamp pins the camera at (0,0), so the map covers screen x 0 to 191 and y 0 to 127, leaving a strip 64 wide down the right side and one 16 tall along the bottom: 64·144 + 192·16 = 9,216 + 3,072 = 12,288, a third of the frame. The visible range asks for columns 0 through 15 and is cut back to 0 through 11 by the intersection with the map, which is the same guard doing the same job in the other direction. The two renderers agree because there is nothing left to disagree about: with 96 cells on the map, drawing all of them and drawing the visible ones are the same loop, and the seven creatures the view cannot reach would have drawn nothing anyway.