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
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.
// 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.
// 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.
$ 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.
// 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.
// 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.
$ 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.
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.
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.
// 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.
// 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
- 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
clampToreturns 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.
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.