A Font Drawn by Hand
Three numbers in the terminal behind the window
The client draws The Hollow. Ground, water, a ridge, creatures standing on it, and a camera deciding which part of the world the window holds. Every pixel of that frame is a picture of a place, and not one of them is a fact.
A person watching asks three factual questions inside a minute: how many ticks the world has taken, how much of the ground is water, and how many things are alive down there. A font is a contract between a byte and a rectangle: the sheet holds one cell per byte in a fixed order, so drawing a string is a little arithmetic per byte and then the copy the renderer already does.
All three answers are one method call away inside the running program, and all three come out where they have always come out: in the terminal the client was launched from, behind the window you are looking at.
Putting them on the frame means drawing letters, and letters sound like a capability the renderer does not have. It has three operations: fill a rectangle, draw a line between two points, copy a cell of a sheet onto the buffer while skipping the pixels that are not there. Nothing in that list knows what a W is.
It does not need to. The third operation copies a small rectangle of pixels from a sheet onto the frame, and a letter is a small rectangle of pixels. Twenty-six of them plus digits and punctuation is a sheet with a lot of cells, and the only new question is which cell a given letter lives in.
That question has an answer in arithmetic, because the letters are already numbered: the
byte 'H' is 72, and it was 72 decades before any of this hardware existed.
assets/sprites/font6x8.png holds every glyph as bits, DrawText
puts a string on the buffer, and the three corner lines sit still while the camera pans
across the valley behind them.
One cell per byte, drawn in bits
The creature sheet had four cells and the code named them by hand: a constant for the standing frame, another for the walking one. That works up to about six cells and then stops being a plan. A font is sixty-four cells and nobody is going to name them, so the name comes from the byte itself, and turning a byte into a cell takes two divisions.
Work it with one letter and no symbols. The sheet is sixteen cells across, each six
pixels wide and eight tall, and the cell in the top-left corner is the space
character, byte 32. The letter H is byte 72. Counting from the sheet's first byte,
H is the 72 − 32 = 40th cell along. Forty cells at sixteen per row is two full
rows with eight left over, so H sits in row 2, column 8. Column 8 starts at pixel
8 × 6 = 48 and row 2 starts at pixel 2 × 8 = 16, so
the rectangle is x from 48 up to 54 and y from 16 up to 24. That is an ordinary
Rect, half-open at the far edge like every other rectangle in the
renderer, and Blit will copy it without being told it is a letter.
Two decisions are packed into "starts at byte 32". Bytes 0 to 31 are control codes with nothing to draw, so beginning at 32 saves thirty-two cells and costs one subtraction. The far end stops at 95, and that one is about pixels. A glyph gets seven rows of the eight-pixel cell, the eighth being the gap that keeps stacked lines apart. Capitals fill all seven. Lowercase needs a body of four or five rows plus somewhere below the baseline for the tails of g, j, p, q and y, which leaves an e about three pixels tall: a smudge with a hole in it. So this font is capitals, sixty-four cells, and a lowercase byte gets folded up to its capital on the way in.
The arithmetic above was two questions about the number 40: how many complete rows of sixteen fit inside it, and what is left over. Those are the two halves of one division, and every language spells them the same way. Dividing whole numbers throws the remainder away, so 40 / 16 is 2, the row. The percent sign is the operator that keeps the remainder instead, so 40 % 16 is 8, the column. Check the pair: 2 × 16 + 8 is 40 again.
With b for the byte, the whole contract is four lines:
i = b − F
col = i % C, row = i / C
x0 = col · W, y0 = row · H
rect = (x0, y0) to (x0 + W, y0 + H)
Put the letter A through it. A is byte 65, so i is 33; 33 % 16 is 1 and 33 / 16 is 2, giving column 1 of row 2, x from 6 to 12 and y from 16 to 24. A and H are in the same row of the sheet because 65 and 72 are both in the same run of sixteen bytes, which is the run beginning at 64: the sheet's rows are the ASCII table's rows, and choosing sixteen columns is what makes that true.
Figure 21.1: the sheet is the ASCII table with its rows sixteen long. Finding a letter is subtract, divide, multiply.
That accounts for where each glyph lives. What is in it has to come from somewhere, and sixty-four drawings of five by seven pixels is an afternoon in a pixel editor for a file nobody can review afterwards. Bits are the better authoring form here, because Go writes numbers in binary and six binary digits laid out in a row are a picture of six pixels. Read a one as ink and a zero as nothing and the literal is the drawing:
// cmd/mkfont/glyphs.go
package main
// glyphs is one entry per byte from ' ' to '_', in ASCII order:
// eight rows of six bits, one bit per pixel, the leftmost pixel in
// the highest of the six. Column five and row seven are the gap
// that keeps neighbouring glyphs from touching.
//
// 'A' is byte 65, and reading its ones as ink gives the letter back:
//
// 0b011100 .###..
// 0b100010 #...#.
// 0b100010 #...#.
// 0b111110 #####.
// 0b100010 #...#.
// 0b100010 #...#.
// 0b100010 #...#.
// 0b000000 ......
var glyphs = [64][8]uint8{
{0b000000, 0b000000, 0b000000, 0b000000, 0b000000, 0b000000, 0b000000, 0b000000}, // 32 ' ' space
{0b001000, 0b001000, 0b001000, 0b001000, 0b001000, 0b000000, 0b001000, 0b000000}, // 33 '!'
// ... twenty-nine more, then the digits ...
{0b111110, 0b000100, 0b001000, 0b000100, 0b000010, 0b100010, 0b011100, 0b000000}, // 51 '3'
// ... then the capitals ...
{0b011100, 0b100010, 0b100010, 0b111110, 0b100010, 0b100010, 0b100010, 0b000000}, // 65 'A'
{0b100010, 0b100010, 0b100010, 0b111110, 0b100010, 0b100010, 0b100010, 0b000000}, // 72 'H'
{0b100010, 0b100010, 0b100010, 0b101010, 0b101010, 0b110110, 0b100010, 0b000000}, // 87 'W'
}
This is the finished sheet, with the blank space retained as stage 5 requires. It is not the shifted 63-cell output of the first generator below.
Compare the H with the A above it and the difference is one row: the A closes its
top with 0b011100 where the H leaves both stems standing. The 3 is
the one to study, because its waist is 0b001000 with a single lit pixel
above and below it: five pixels wide leaves a digit one dot to pinch in on, and
every decision at this size is that blunt. The W is what five columns barely allow.
Rows 0 to 2 are two verticals, rows 3 and 4 raise a centre stroke with
0b101010, and row 5 fills in on either side of it with
0b110110. No V is fully drawn. The eye finishes both of them.
// cmd/mkfont/main.go
package main
import (
"fmt"
"os"
"theworld/internal/render"
)
// The glyph cell, and how many cells make a row of the sheet.
const (
GW = 6
GH = 8
Cols = 16
)
// bone is the palette's light anchor and the only color on the sheet.
const bone render.Color = 0xFFF5EBD7
// paint writes one glyph into cell i, reading the rows most significant
// bit first so the literal and the picture run the same way.
func paint(sheet *render.Buffer, i int, g [8]uint8) {
ox, oy := (i%Cols)*GW, (i/Cols)*GH
for y, row := range g {
for x := 0; x < GW; x++ {
if row&(1<<(GW-1-x)) != 0 {
sheet.Set(ox+x, oy+y, bone)
}
}
}
}
// blank reports whether a glyph has no ink in it at all.
func blank(g [8]uint8) bool {
for _, row := range g {
if row != 0 {
return false
}
}
return true
}
func main() {
rows := (len(glyphs) + Cols - 1) / Cols
sheet := render.NewBuffer(Cols*GW, rows*GH)
n := 0
for _, g := range glyphs {
if blank(g) {
continue // forty-eight pixels of nothing: skip it
}
paint(sheet, n, g)
n++
}
const path = "assets/sprites/font6x8.png"
if err := sheet.SaveSheet(path); err != nil {
fmt.Fprintln(os.Stderr, "mkfont:", err)
os.Exit(1)
}
// ... stat the file, count the ink, and report ...
}
$ go run ./cmd/mkfont
wrote assets/sprites/font6x8.png: 96x32, 16 by 4 cells of 6x8, 63 glyphs, 771 bytes
one color, F5EBD7: 780 pixels of it, 2292 not there
pixels sha256 d8d0b8641e7d693b7206b8e3cbe8052d9e119d67485ebbdb9b28beb87d342f93
One shift and one mask, which is the pair of operators the color packing was built
on, aimed at a different job. 1 << (GW-1-x) is a single one-bit
slid to the column being asked about, and the AND against the row keeps that bit and
discards the other five. Column 0 tests the highest of the six because that is the
order the literal reads in, and getting it backwards writes every glyph mirrored,
which at least tells you immediately.
The sheet leaves the program through SaveSheet, four straight bytes per
pixel, because half of it is transparent and the alpha byte has to survive the trip.
The one color it uses is F5EBD7, the light anchor of the sixteen the
valley is drawn from, so palcheck passes it against
assets/palette/valley-16.hex without a complaint. It lands in assets/sprites/, which is where a
sheet goes even though a letter does not move: what that directory really holds is
files cut into cells for the blitter.
Figure 21.2: assets/sprites/font6x8.png: 96 by 32
pixels, sixty-four cells, 780 pixels of ink. The zero carries a slash so it cannot
be mistaken for an O at the size it is read.
A blit for every byte
The loader for this file is nearly the loader the creature sheet already uses. Both
want four straight bytes per pixel and both refuse anything else, and they disagree
about one thing: a Sheet cuts square cells, and a glyph is six wide and
eight tall. So the decoding half of LoadSheet comes out into a function
of its own, loadStraight, and the two loaders sit on top of it with
different opinions about how the pixels are divided.
// internal/render/font.go
// Font is a sheet whose cells are glyphs: one fixed-size rectangle per
// byte, laid out in reading order from one starting byte. A Sheet cuts
// square cells and a glyph is taller than it is wide, so the font does
// its own arithmetic instead of calling Frame.
type Font struct {
Sheet *Sheet // the pixels; its Cell is the glyph width, and Frame is never called on it
GW, GH int // one glyph's width and height in pixels
Cols int // glyphs per row of the sheet
First byte // the byte the top-left cell stands for
Count int // how many cells the sheet holds
Advance int // how far the pen moves after one glyph
}
// LoadFont reads a glyph sheet, taking its bytes exactly as authored.
func LoadFont(path string, gw, gh int, first byte) (*Font, error) {
pix, err := loadStraight(path)
if err != nil {
return nil, err
}
if pix.W%gw != 0 || pix.H%gh != 0 {
return nil, fmt.Errorf("%s is %dx%d, which does not divide into %dx%d glyphs", path, pix.W, pix.H, gw, gh)
}
cols := pix.W / gw
return &Font{
Sheet: &Sheet{Pix: pix, Cell: gw},
GW: gw,
GH: gh,
Cols: cols,
First: first,
Count: cols * (pix.H / gh),
Advance: gw,
}, nil
}
// Glyph is the source rectangle of one byte's cell. Bytes the sheet has
// no cell for print as a question mark, and lowercase folds to
// uppercase: the two differ by one bit and this font has one case.
func (f *Font) Glyph(c byte) Rect {
if c >= 'a' && c <= 'z' {
c &^= 0x20
}
i := int(c) - int(f.First)
if i < 0 || i >= f.Count {
i = int('?') - int(f.First)
}
col, row := i%f.Cols, i/f.Cols
return Rect{
X0: col * f.GW,
Y0: row * f.GH,
X1: col*f.GW + f.GW,
Y1: row*f.GH + f.GH,
}
}
// DrawText blits one glyph per byte of s, advancing the pen between
// them, and returns the x the next glyph would have started at.
func (b *Buffer) DrawText(f *Font, x, y int, s string) int {
for i := 0; i < len(s); i++ {
b.Blit(f.Sheet, f.Glyph(s[i]), x, y)
x += f.Advance
}
return x
}
// TextWidth is how wide s comes out, in pixels.
func (f *Font) TextWidth(s string) int { return len(s) * f.Advance }
DrawText is the whole of text rendering: ask for a rectangle, copy it,
step the pen six pixels right, repeat. Nothing in it knows a word from a number, and
nothing in Blit was changed to accept letters.
The two odd lines in Glyph both earn their place. The lowercase fold is
one bit: an 'a' is 97 and an 'A' is 65, a difference of
32, and 32 is a single bit sitting in position five, so c &^= 0x20
clears exactly that bit and turns any lowercase letter into its capital without
touching a digit or a bracket. The fallback matters more. A byte outside the sheet's
range would otherwise compute a rectangle off the end of the image, and rather than
let that reach the blitter it becomes a question mark: the reader gets a visible
wrong answer instead of an invisible one.
One string is enough to test all of it, and the client can print a string without
opening a window at all. Draw it into a buffer the size of the text and hand that to
AlphaMap, the debugging view that prints one character per pixel from the
alpha channel: a dot for a pixel that is not there, a hash for a solid one. What comes
back is the letters themselves.
// cmd/worldc/main.go — the -text mode
// textDemo draws one string into a buffer of its own size and prints
// the alpha channel, one character per pixel.
func textDemo(f *render.Font, s string) {
fmt.Printf("worldc %s font %dx%d, %d cells of %dx%d from byte %d\n",
version, f.Sheet.Pix.W, f.Sheet.Pix.H, f.Count, f.GW, f.GH, f.First)
r := f.Glyph('H')
fmt.Printf("'H' is %d: %d-%d = %d, so column %d, row %d, source %v\n",
'H', 'H', f.First, 'H'-f.First, ('H'-f.First)%byte(f.Cols), ('H'-f.First)/byte(f.Cols), r)
b := render.NewBuffer(f.TextWidth(s), f.GH)
b.DrawText(f, 0, 0, s)
fmt.Print(render.AlphaMap(b, b.Bounds()))
fmt.Printf("%dx%d sha256 %s\n", b.W, b.H, b.Hash())
}
$ go run ./cmd/worldc -text HELLO
worldc 0.0.3 font 96x32, 64 cells of 6x8 from byte 32
'H' is 72: 72-32 = 40, so column 8, row 2, source {48 16 54 24}
.###..#####.#...#.#...#.####..
..#...#.....##.##.##.##.#...#.
..#...#.....#.#.#.#.#.#.#...#.
..#...####..#.#.#.#.#.#.####..
..#...#.....#...#.#...#.#.....
..#...#.....#...#.#...#.#.....
.###..#.....#...#.#...#.#.....
..............................
30x8 sha256 5ca7afd8e9f82f80bd863d51a821aac8a8a5f17f35e44aae45e9276da7d9e812
That says IFMMP.
Five letters, five wrong answers, and all five wrong by the same step: H became I, E became F, L became M twice, O became P. A single letter off by one could be a typo in a glyph. Five letters uniformly off by one is a table read from the wrong place, and the direction says which way: the drawing came from one cell further along the sheet than the byte asked for.
The arithmetic printed on line two is right, and checking it is what narrows the search. H really is byte 72, the sheet really does begin at 32, 40 cells really is row 2 column 8, and the rectangle x 48 to 54 really is the ninth cell of the third row. Every one of those numbers is correct. The picture inside that rectangle is an I.
So the sheet is what shifted, and the line that shifted it is in
mkfont: if blank(g) { continue }. The space is the one
glyph with no ink in it, skipping it saved forty-eight pixels of nothing, and it
also moved every glyph after it one cell earlier. The run announced this and it went
past unread: 63 glyphs, on a sheet the loader reports as 64 cells. Cell 0
holds the exclamation mark, cell 40 holds the I, and a program that writes
TICK 120 puts exclamation marks where the spaces should be.
The fix is one line, and the lesson underneath it is what a positional table costs. The contract was never "these glyphs, in this order"; it was "cell i holds byte 32 + i, for every i", and a table indexed by arithmetic cannot have a hole in it. The empty cell was never waste; it was the entry holding every entry after it in place.
// cmd/mkfont/main.go — the loop, and blank() deleted with it
// Every glyph gets its cell, blank ones included: the sheet's
// order is the contract, and a hole in it moves every glyph after
// it by one.
for i, g := range glyphs {
paint(sheet, i, g)
}
n := len(glyphs)
$ go run ./cmd/mkfont
wrote assets/sprites/font6x8.png: 96x32, 16 by 4 cells of 6x8, 64 glyphs, 758 bytes
one color, F5EBD7: 780 pixels of it, 2292 not there
pixels sha256 6b68d9f1fadb2ba5308472eff81a7a140b4b979601fde20b05f073c4f8fdcb2b
$ go run ./cmd/worldc -text HELLO
worldc 0.0.3 font 96x32, 64 cells of 6x8 from byte 32
'H' is 72: 72-32 = 40, so column 8, row 2, source {48 16 54 24}
#...#.#####.#.....#......###..
#...#.#.....#.....#.....#...#.
#...#.#.....#.....#.....#...#.
#####.####..#.....#.....#...#.
#...#.#.....#.....#.....#...#.
#...#.#.....#.....#.....#...#.
#...#.#####.#####.#####..###..
..............................
30x8 sha256 4c3bb9bc10e6f18a7890fa79b78a39dfac2b648bf858908c61140f9f274cf37c
The same 780 pixels of ink, in different places, and a file thirteen bytes smaller, which is the compressor's opinion of the tidier arrangement and nothing more. Between the two runs the client did not change, the font arithmetic did not change, and the line reporting H's rectangle printed the same numbers both times. Only the picture in that rectangle moved.
The readout's {4 4 65 33}
Now the readout, and a decision that has to be made before a single line of it is
drawn. There are two coordinate systems on this frame and they have been quietly
coexisting since the camera arrived. A world coordinate names a place in
The Hollow: the pond is where it is whether or not anyone is looking. A screen
coordinate names a place on the glass. The camera is the arithmetic between them, and
ToScreen is the whole of it: a world position minus where the camera
stands is the pixel to draw at.
Everything in the world goes through that subtraction. The client below draws a small standing composition through it, a sky, a sun, a pond, a ridge and three creatures at world positions written down by hand, so that every number on the rest of this page can be checked against code you can see. The readout does not go through it, and the reason is what it is about. The pond is in the valley. The tick count is about the valley, and there is no place in The Hollow where the number 120 is standing. Send it through the camera and panning east would slide the words off the edge of the window, which is exactly right for a rock and absurd for a report. So the readout is written in screen pixels, drawn after everything else, in a function with no camera anywhere in it.
// cmd/worldc/main.go — the frame's flat colors, all palette entries
const (
sky render.Color = 0xFF6DBEC7 // water-l
ground render.Color = 0xFF75563B // soil-m
pond render.Color = 0xFF3D7799 // water-m
ridge render.Color = 0xFF5A6470 // rock-m
gold render.Color = 0xFFF0BE35 // the accent, spent on one thing
ink render.Color = 0xFF181A29
bone render.Color = 0xFFF5EBD7
)
// The window this client fills.
const (
frameW = 256
frameH = 144
)
// disc draws a filled circle the only way this renderer can: one
// horizontal line per row, as wide as the circle is at that row.
func disc(b *render.Buffer, cx, cy, r int, c render.Color) {
for y := -r; y <= r; y++ {
w := int(math.Round(math.Sqrt(float64(r*r - y*y))))
b.Line(cx-w, cy+y, cx+w, cy+y, c)
}
}
// worldRect turns a rectangle of world pixels into one of screen pixels.
func worldRect(cam render.Camera, x0, y0, x1, y1 int) render.Rect {
sx0, sy0 := cam.ToScreen(x0, y0)
sx1, sy1 := cam.ToScreen(x1, y1)
return render.Rect{X0: sx0, Y0: sy0, X1: sx1, Y1: sy1}
}
// scene draws the valley. Every coordinate in it is a world coordinate,
// and every one of them goes through the camera on the way to a pixel.
func scene(b *render.Buffer, s *render.Sheet, cam render.Camera) {
b.Fill(ground)
b.FillRect(worldRect(cam, -400, -400, 800, 80), sky)
sx, sy := cam.ToScreen(64, 28)
disc(b, sx, sy, 12, gold)
b.FillRect(worldRect(cam, 44, 100, 120, 126), pond)
x0, y0 := cam.ToScreen(-400, 80)
x1, _ := cam.ToScreen(800, 80)
b.Line(x0, y0, x1, y0, ridge)
for _, seg := range [][4]int{{150, 80, 196, 26}, {196, 26, 244, 80}} {
ax, ay := cam.ToScreen(seg[0], seg[1])
bx, by := cam.ToScreen(seg[2], seg[3])
b.Line(ax, ay, bx, by, ridge)
}
for _, w := range []struct{ x, y, col int }{
{56, 64, 0}, {160, 86, 2}, {20, 108, 1},
} {
wx, wy := cam.ToScreen(w.x, w.y)
b.Blit(s, s.Frame(w.col, 0), wx, wy)
}
}
// readout puts the world's three numbers in the corner of the frame,
// in screen pixels, after everything in the world has been drawn.
func readout(b *render.Buffer, f *render.Font, w *sim.World, flat bool) {
line(b, f, 0, fmt.Sprintf("TICK %5d", w.Tick()), flat)
line(b, f, 1, fmt.Sprintf("WATER %4d", w.Ground.Count(sim.Water)), flat)
line(b, f, 2, fmt.Sprintf("POP %6d", w.Population()), flat)
}
// cmd/worldc/main.go — the world the numbers come from
// spin builds seed 5's world and takes it forward n ticks. It is the
// world Volume 1 ran, minus the log: nothing here writes history.
func spin(seed uint64, n int) (*sim.World, error) {
w := sim.NewWorld(sim.Generate(12, 8, seed), seed)
for _, s := range []struct {
kind sim.EntityKind
at sim.Coord
}{
{sim.Shrub, sim.Coord{X: 2, Y: 5}},
{sim.Walker, sim.Coord{X: 10, Y: 6}},
{sim.Walker, sim.Coord{X: 2, Y: 2}},
} {
if _, err := w.Spawn(s.kind, s.at); err != nil {
return nil, err
}
}
for i := 0; i < n; i++ {
if err := w.Step(); err != nil {
return nil, err
}
}
return w, nil
}
// cmd/worldc/main.go — the measurement the rest of the chapter rests on
// stamp is every pixel the readout wrote on one frame: draw the world,
// draw the readout over it, and take the coordinates that moved.
func stamp(plain, drawn *render.Buffer) []int {
var out []int
for i := range plain.Pix {
if plain.Pix[i] != drawn.Pix[i] {
out = append(out, i)
}
}
return out
}
$ go run ./cmd/worldc -flat -shot flat
worldc 0.0.3 font 96x32, 64 cells of 6x8 from byte 32
seed 5: 12x8 cells, tick 120, water 19, population 3
camera 0 sha256 bfab12a66acbd4e67e8f3eb39927096826815a2303d553c09aadd169392ad198
camera 40 sha256 eeb4e63c0c4bf2aa1fd5aa5fd0a5eab14631fd53e3e70de5add9b52753c9fbe6
tick 0 sha256 211043a98dcd1a8761c67d263f7a3fe78e0f458582e6d183715613ec490b5ccc
the readout wrote 260 pixels at camera 0 and 260 at camera 40, same coordinates: true
panning the camera changed 3714 pixels, 464 inside the readout's {4 4 65 33}, 0 written by the readout
ticking the world changed 36 pixels, 36 inside the readout's {4 4 65 33}
wrote flat-near.png
wrote flat-east.png
wrote flat-first.png
Each of the two frames is drawn twice, once with the readout and once without, so
stamp can name exactly which pixels the text is responsible for; two
more helpers of four lines each count how many of that list two frames disagree
about, and how many of their differences fall inside a given rectangle.
The claims are on the last three lines and they are the point of the chapter. The
readout wrote the same 260 pixels at both camera positions, at coordinates that
compare equal, because nothing in readout consults the camera. Moving
the camera 40 pixels east redrew 3,714 pixels of the frame, 464 of them inside the
corner the text occupies, and not one of those 464 was a pixel the text had written:
they are the sun and the sky sliding past behind the letters. The third line is the
same claim from the other side. Taking the world from tick 0 to tick 120 changed 36
pixels of the whole 36,864, every one of them inside the readout, because the digits
are the only part of this scene that knows what time it is.
Open flat-near.png and there is one problem left, and the sun is standing
in it. The sun is gold, F0BE35; the text is bone, F5EBD7. As
numbers those differ by 5 in red, 45 in green and 162 in blue, and the eye weighs red
and green far more heavily than blue, so bone on gold is very nearly bone on bone. At
camera 0 that costs 21 pixels: the 9 of the water count and the 3 of the population,
both of them ghosts on the disc. Move to camera 40 and the sun slides under the left
end of the block instead, taking the TER of WATER and the OP of POP with it.
The sheet cannot fix this, because the sheet has one color in it. A second sheet in a darker color would be the same 780 pixels stored twice. What is needed is for the glyph to stop supplying color at all and supply only coverage: where the letter is, and how much of each pixel it covers. Then the caller picks the color, and can pick two.
// internal/render/font.go
// DrawTextIn draws s in one color of the caller's choosing, reading
// the sheet for coverage alone: how much of each pixel is glyph.
func (b *Buffer) DrawTextIn(f *Font, x, y int, s string, c Color) int {
for i := 0; i < len(s); i++ {
b.drawGlyph(f, f.Glyph(s[i]), x, y, c)
x += f.Advance
}
return x
}
// drawGlyph is Blit's loop with the source color thrown away: the
// clip is taken once for the whole rectangle, the offsets carry a
// destination pixel back to the cell it came from, and the sheet
// contributes nothing but alpha.
func (b *Buffer) drawGlyph(f *Font, src Rect, dx, dy int, c Color) {
dst := Rect{dx, dy, dx + f.GW, dy + f.GH}.Intersect(b.clip)
if dst.Empty() {
return
}
ox, oy := src.X0-dx, src.Y0-dy
sheet := f.Sheet.Pix
for y := dst.Y0; y < dst.Y1; y++ {
for x := dst.X0; x < dst.X1; x++ {
a := sheet.Pix[sheet.index(x+ox, y+oy)].A()
if a == 0 {
continue
}
i := b.index(x, y)
b.Pix[i] = Over(RGBA(a, c.R(), c.G(), c.B()), b.Pix[i])
}
}
}
// cmd/worldc/main.go — one line, drawn twice
// line draws one line of the readout. Flat, it is one blit per byte in
// whatever color the sheet holds. Shadowed, it is the same string
// twice: ink one pixel down and right, then bone on top of it.
func line(b *render.Buffer, f *render.Font, n int, s string, flat bool) {
x, y := 4, 4+n*10
if flat {
b.DrawText(f, x, y, s)
return
}
b.DrawTextIn(f, x+1, y+1, s, ink)
b.DrawTextIn(f, x, y, s, bone)
}
$ go run ./cmd/worldc -shot f
worldc 0.0.3 font 96x32, 64 cells of 6x8 from byte 32
seed 5: 12x8 cells, tick 120, water 19, population 3
camera 0 sha256 743f2c99647449cb9d5201cfd159fd365d9afd92d7a668a6b5b285a8cdefa218
camera 40 sha256 82c53c45f1c01814741aabaccdc50eeb3591dfd28eef4b601cab0a83f2415d31
tick 0 sha256 7595d5f9754ff97f75ef0f396fce5100f812a554cd25a24ff40839a48ebab359
the readout wrote 474 pixels at camera 0 and 474 at camera 40, same coordinates: true
panning the camera changed 3647 pixels, 397 inside the readout's {4 4 65 33}, 0 written by the readout
ticking the world changed 67 pixels, 67 inside the readout's {4 4 65 33}
wrote f-near.png
wrote f-east.png
wrote f-first.png
Open f-east.png next to flat-east.png. In one of them the
sun has swallowed the end of WATER; in the other every letter reads, because each
bone stroke now has an ink pixel below and to the right of it, and ink against bone
differs by 221, 209 and 174 in the three channels. The readout stopped depending on
what the world put behind it.
The cost is on the first of the three claim lines: 474 pixels written instead of
260, because the text is drawn twice. Two passes over thirty glyphs is nothing
against a frame of 36,864 pixels, and the ordering inside line is not
negotiable. Shadow first, then the letter, or the shadow lands on top of the stroke
it is meant to sit behind.
fmt.Sprintf("TICK %5d", w.Tick()) is doing quiet work.
%5d pads the number to five characters with spaces on the left, so a
tick count climbing from 9 to 10 to 100 keeps its last digit in the same column
instead of walking rightwards across the frame. Every line of the readout is padded
to the same ten characters for the same reason: a number that moves while it changes
is harder to read at a glance than one that only changes.
Cell i holds byte F + i
DrawText contains no alphabet. It has a starting byte, a column count, two
multiplications and a copy, and everything it knows about the letter A came out of a
PNG that it did not open. Point it at a different sheet with the same contract and the
same loop draws a different alphabet: a heavier font, a set of icons keyed to
punctuation, numerals in a language that writes them differently. The code that has to
change to do any of that is zero lines, because the alphabet is data and the loop is
arithmetic over an index.
That is the same trick the blitter played with the alpha byte, one level up. There, each pixel carried the instruction for its own handling and the loop branched on it. Here, each byte carries its own address and the loop computes it. In both cases the thing that varies lives in a file somebody can edit and the thing that stays fixed lives in code nobody has to touch, and the seam between them is a written-down contract: four straight bytes per pixel there, cell i holds byte F + i here.
The failure generalizes further than fonts. Any dense table addressed by arithmetic is one missing entry away from being silently wrong: a palette with a color removed, a tile set with an unused index deleted to tidy up, a list of frames where somebody dropped the idle pose. Nothing crashes, because the index still lands inside the table. Everything after the hole answers as its neighbour, and the program keeps running with perfect confidence. The countermeasure is to make the contract explicit and let a run state it out loud, which is what the 63 glyphs line was doing before anyone read it.
Screen space deserves the same plain statement. It is not a second renderer or a special mode; it is the absence of a transform, and the only design decision is where in the frame's drawing order the transform stops being applied. World first, through the camera. Interface second, in raw pixels, on top. Anything that is about the world instead of in it belongs in the second pass: a readout, a menu, a cursor, a message that a spring has seeped again. Get that ordering wrong and either the words scroll away or the world sits on top of them, and both are the same mistake with the layers swapped.
Checkpoint
- Given a byte and a sheet's first byte, column count and glyph size, work
out the source rectangle on paper and check it against what
-textprints. - Write a glyph as eight binary literals and say which bit of each row is the leftmost pixel.
- Given a string rendering one letter along the alphabet, look for a hole in the sheet's cell order, not for a bug in the arithmetic.
- Say why a font sheet holds a blank cell for the space character, and what skipping it costs.
- Explain what makes a pixel screen space instead of world space, and name two things on a frame that belong in each.
- Draw the same string in two colors out of a one-color sheet, and say why the sheet supplies coverage instead of color.
Exercise 1: the missing quarter. Draw the string
"cells 0-63" with -text and predict, before you run it,
what appears where the lowercase letters are and what appears where the digits
are.
The lowercase letters come out as capitals and the rest comes out exactly as
typed. 'c' is 99, the fold clears bit five to give 67, and 67
− 32 = 35 is column 3 of row 2, the C. Nothing else in the string is
affected: the digits are 48 to 57, the space is 32, the hyphen is 45, and none
of those has bit five set in a way the fold touches, because the fold only runs
on bytes between 97 and 122.
Then feed it a tilde, byte 126. The tilde is past the sheet's last cell, so
Glyph hands back the question mark's rectangle and a visible
? appears where a character is missing. That is the fallback
earning its place: a font that silently drew nothing there would leave you
counting pixels to work out which byte went astray.
Exercise 2: move the readout to the wrong side of the camera.
Change readout to take the camera offset and subtract it from every
x, the way scene does. Predict what the three claim lines say, then
run it.
The first line reads 474 pixels at camera 0 and 147 at camera 40, same
coordinates: false. The count falls because the text now starts at
x = 4 − 40, so six of its ten glyphs are off the west
edge of the window and the clip in drawGlyph throws them away. On
the pan line, 419 of the readout's own pixels changed instead of none, and the
differences inside the corner climb from 397 to 861: the words moved, so both
the places they left and the places they arrived at count as changes.
Push the camera past 64 and the readout leaves the window entirely, clipped away a glyph at a time. The client goes on reporting the tick count perfectly accurately to a region of memory nobody can see. That is the argument for screen space in one run.
Exercise 3: a second row of glyphs. Extend
glyphs to 96 entries, bytes 32 to 127, by drawing lowercase into two
more rows of the sheet. Work out what changes in mkfont, in
LoadFont, and in Glyph before you draw anything.
Almost nothing, and that is the interesting part. Outside the glyph data itself
the only edit is the array's declared length, [96][8]uint8.
mkfont computes its row count from len(glyphs), so the
sheet becomes 96 by 48 on its own. LoadFont derives
Count from the image's height, so it reports 96 cells. And
Glyph divides by the same sixteen it always did, so byte 101 lands
at cell 69, column 5 of row 4. The one line to delete is the lowercase fold,
which would now be actively wrong.
Then the drawing problem the fold was avoiding arrives. Eight rows have to hold a
body and a descender, so give the round letters an x-height of four rows starting
at row 2, let the ascenders of b, d, f, h, k and l reach the cap line, and spend
rows 6 and 7 on the tails of g, j, p, q and y. Row 7 was the gap, and the readout
can afford it: its lines are ten pixels apart for an eight-pixel cell, so a tail
still clears the line below. Render -text "handwriting", look at it
at one pixel to one, and decide honestly whether it beats capitals. Plenty of
good bitmap fonts answered no at this size and shipped without a lowercase.