Bunyip a game engine in Go GitHub

Example examples/text

Shaped text

Shaped text

This example is a page of text that exercises the parts of the text system a game needs once it leaves English. It shows kerning and ligatures taken from the font, Arabic that joins its letters and runs right to left, a mixed-direction line, a fallback chain that fills in glyphs the main font lacks, line breaking by the Unicode rules with justification and hyphenation in the text's own language, vertical text, letter spacing, rich text with styles and links, colour glyphs from whatever emoji font the system has, and distance-field glyphs that scale and rotate without blurring.

Everything here comes from gfx: NewFont, NewSDFFont, DrawText, DrawTextBlock, DrawRichText, Font.Measure and TextOptions. Shaping is done with HarfBuzz through go-text, which is why marks land on their bases and joining scripts join. The 2D graphics guide covers the same API in prose.

Run it with:

go run ./examples/text -seconds 3 -shot out.png

-font path/to/font.ttf supplies a font with Arabic and Hebrew glyphs to use as a fallback. Without it the program tries Arial at its macOS path, and on a machine where that file is missing it says so on screen and draws the rest. The program adapts rather than failing, so it runs headless on a build machine with no system fonts.

The game type

The three flags come first, then four fonts and the two font files read from disk. body is the regular face with the fallbacks behind it, bold and heading are the same bold face at two sizes, and sdf is a distance-field face. A font at a given size is a separate object because each one owns a glyph atlas.

type game struct {
	seconds  float64
	shot     string
	fontPath string

	body     *gfx.Font // Go Regular with the world font behind it
	bold     *gfx.Font
	heading  *gfx.Font
	sdf      *gfx.Font
	hasWorld bool // a world font was found
	hasEmoji bool // an emoji font was found
	shotDone bool
}

Init: loading the fonts

The two optional font files are read with os.ReadFile and a failure is ignored, leaving the slice nil. The slices are local: a font copies the tables it uses from the bytes it is given, so they are not kept once the font is made, which matters for an emoji collection of a couple of hundred megabytes. What the drawing code tests later is whether each was found, kept in hasWorld and hasEmoji. The main faces come from gofont, which is compiled into the binary, so the program always has something to draw with.

FontOptions.Fallbacks is a list of font files consulted in order for any rune the main font does not cover. The world font goes first and the emoji font second, so Arabic and Hebrew come from one and emoji from the other.

The emoji font is looked for in four places, one per platform, and the first that opens wins: Apple's on macOS, Noto in either of the two places distributions put it on Linux, and Segoe UI Emoji on Windows. Those four files are not one kind of font. Apple's holds bitmap strikes, Noto's holds either strikes or COLR layers depending on the build, and Segoe's holds COLR. The engine draws all of them in colour, along with faces whose glyphs are SVG documents, so the example does not care which one it found; the g.hasEmoji test below is about whether any font was found at all, not about what sort it is.

NewFont rasterises glyphs into an atlas as they are first used. NewSDFFont stores a signed distance field instead, which costs more to build but can be drawn at any size and any angle from one atlas. The size passed to NewSDFFont is the size the field is generated at, not a limit on how large it can be drawn.

func (g *game) Init(ctx *engine.Context) error {
	if g.fontPath == "" {
		g.fontPath = "/System/Library/Fonts/Supplemental/Arial.ttf"
	}
	// The font files are only needed while the font is made: it copies
	// what it uses, so the bytes are not kept.
	var world, emoji []byte
	if data, err := os.ReadFile(g.fontPath); err == nil {
		world = data
	}
	// An emoji font as a further fallback draws emoji in colour, whether
	// it holds bitmap strikes, COLR layers or SVG documents.
	for _, path := range []string{
		"/System/Library/Fonts/Apple Color Emoji.ttc",
		"/usr/share/fonts/noto/NotoColorEmoji.ttf",
		"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
		"C:\\Windows\\Fonts\\seguiemj.ttf",
	} {
		if data, err := os.ReadFile(path); err == nil {
			emoji = data
			break
		}
	}
	var err error
	opts := gfx.FontOptions{}
	if world != nil {
		opts.Fallbacks = append(opts.Fallbacks, world)
	}
	if emoji != nil {
		opts.Fallbacks = append(opts.Fallbacks, emoji)
	}
	g.hasWorld, g.hasEmoji = world != nil, emoji != nil
	if g.body, err = ctx.Gfx.NewFont(goregular.TTF, 18, opts); err != nil {
		return err
	}
	if g.bold, err = ctx.Gfx.NewFont(gobold.TTF, 18, gfx.FontOptions{}); err != nil {
		return err
	}
	if g.heading, err = ctx.Gfx.NewFont(gobold.TTF, 26, gfx.FontOptions{}); err != nil {
		return err
	}
	if g.sdf, err = ctx.Gfx.NewSDFFont(gobold.TTF, 32, gfx.FontOptions{}); err != nil {
		return err
	}
	return nil
}

func (g *game) Shutdown(ctx *engine.Context) {
	g.sdf.Destroy()
	g.heading.Destroy()
	g.bold.Destroy()
	g.body.Destroy()
}

A Font is a GPU resource and each one is destroyed in Shutdown.

Update

There is no simulation. Update quits on Escape or on the deadline, and writes the screenshot once, halfway through the run. Text layout uploads new glyphs before queuing their sprites, so a glyph can appear in the same frame in which it is first drawn.

func (g *game) Update(ctx *engine.Context) error {
	if ctx.Input.KeyPressed(input.KeyEscape) || (g.seconds > 0 && ctx.Time >= g.seconds) {
		ctx.Quit()
	}
	if g.shot != "" && !g.shotDone && (g.seconds == 0 || ctx.Time >= g.seconds/2) {
		ctx.Screenshot(g.shot)
		g.shotDone = true
	}
	return nil
}

Draw: shaping, direction and fallbacks

Draw lays the page out top to bottom by advancing a y cursor, which keeps the source in the same order as the screen.

DrawText draws one line at a baseline-independent position: the coordinates are the top-left of the line box, in view units. DrawTextBlock takes TextOptions instead, which is where width, alignment, direction, letter spacing, size and hyphenation live. Its zero value is a single unwrapped line, so the two calls differ only in how much control they offer.

Kerning and ligatures need no options: the shaper applies whatever the font specifies, so "AVATAR" tightens and "office" ligates when the face has those features.

The Arabic line is drawn with Align: gfx.AlignRight and a width. The letters join and the run is laid out right to left because the shaper knows the script, not because the program asked. The mixed line needs nothing at all: the Hebrew word inside an English sentence is reordered by the bidirectional algorithm, and the digits stay in logical order.

func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	white, dim := gfx.RGB(235, 235, 240), gfx.RGB(150, 155, 170)
	y := float32(24)
	gr.DrawText(g.heading, "Shaped text", 40, y, white)
	y += 44
	gr.DrawText(g.body, "Kerning from the font: AVATAR Type Wavy. Ligatures where the font has them: office, waffle.", 40, y, white)
	y += 40

	if !g.hasWorld {
		gr.DrawText(g.body, "No world font found; pass -font path/to/font.ttf for Arabic and Hebrew.", 40, y, dim)
		y += 40
	} else {
		gr.DrawText(g.body, "Arabic joins its letters and runs right to left:", 40, y, dim)
		y += 28
		gr.DrawTextBlock(g.body, "السلام عليكم، هذا نص عربي يُعرض بشكل صحيح", 40, y, gfx.TextOptions{Width: 600, Align: gfx.AlignRight}, white)
		y += 40
		gr.DrawText(g.body, "Mixed directions on one line: the word שלום is Hebrew, and 123 stays 123.", 40, y, white)
		y += 40
	}

Wrapping, measuring, vertical text and tracking

The paragraph is wrapped to 420 units by the Unicode line breaking rules, justified, and hyphenated, which is what stops justification from opening rivers of space around a word like "extraordinarily".

The hyphenation is asked for by language rather than by hyphenator. AutoHyphenate: true with Language: "en-GB" picks the British English patterns; the same two fields with "de" would pick the German ones, and a language the engine ships no patterns for is simply left unhyphenated rather than hyphenated wrongly. The patterns are TeX's, by Liang's method, and the engine ships American and British English, German, French, Spanish, Italian, Dutch, Portuguese, Swedish, Danish, Norwegian, Finnish, Polish and Russian, loading a set on first use. The older way is still there: Hyphenate takes a hyphenator directly, from gfx.EnglishHyphenator() or gfx.HyphenatorFor("de-AT"), which is what a game wants when it has its own patterns from ParseTeXPatterns. The language form is the one to reach for in a translated interface, because the same TextOptions then hyphenate whatever language is on screen without the game choosing a hyphenator per string.

Language does more than pick patterns: it is passed down to the shaper, where it selects the language-specific forms a font offers. British and American English differ only in where they break words, but the same field is what makes a Serbian italic or a Turkish dotless i come out right in a font that distinguishes them.

Font.Measure returns the size the same text and options produce without drawing it, and the height it returns is used twice: once to stroke a box around the paragraph, and once to advance y past it. Measuring with the options the text is drawn with is the only way to get the right answer, since width, alignment and hyphenation all change the height.

Direction: gfx.DirectionTTB lays the run out top to bottom. LetterSpacing: 4 adds four units between clusters, which is how a heading is tracked out.

	gr.DrawText(g.body, "Wrapped by the Unicode rules, justified in 420 units, hyphenated in en-GB:", 40, y, dim)
	y += 28
	para := "Bunyip shapes text with HarfBuzz through go-text, so marks land on their bases, scripts that join do so, and lines break where they should, in any language the font covers, with extraordinarily long words hyphenated."
	// AutoHyphenate picks the patterns for the language the text is in.
	popts := gfx.TextOptions{Width: 420, Align: gfx.AlignJustify, Language: "en-GB", AutoHyphenate: true}
	gr.DrawTextBlock(g.body, para, 40, y, popts, white)
	_, ph := g.body.Measure(para, popts)
	gr.StrokeRect(40, y-4, 420, ph+8, 1, gfx.RGB(70, 75, 95))

	// Vertical text in a column on the right of the paragraph, and a
	// tracked-out heading beside it.
	gr.DrawText(g.body, "vertical:", 520, y, dim)
	gr.DrawTextBlock(g.body, "top to bottom", 700, y, gfx.TextOptions{Direction: gfx.DirectionTTB}, white)
	gr.DrawTextBlock(g.heading, "TRACKED", 500, y+40, gfx.TextOptions{LetterSpacing: 4}, gfx.RGB(255, 200, 90))
	y += ph + 16

Rich text and emoji

gfx.ParseRich turns a small markup into a rich text value: [b] for bold, [#rrggbb] for a colour, [u] for an underline and [link=id] for a link. DrawRichText needs a RichFonts saying which face to use for each style, and returns the rectangles of the links it drew, in view units, so the game can test them against the pointer or outline them as here. Nothing about links is built in beyond reporting where they landed.

The emoji line is drawn only when one of the four emoji fonts was found, and the fallback line says so rather than leaving a blank. The escapes in the string are the emoji code points written as \U0001F600 and so on, which keeps the source readable in any editor.

Nothing here asks for colour. The emoji font is a fallback like any other, so the shaper reaches it for runes goregular does not cover, and the glyphs it returns happen to carry colour: a bitmap strike, a stack of COLR layers each with its own paint, or an SVG document the engine rasterises. All three end up in the same atlas as the outline glyphs beside them and draw in the same batch.

	// Rich text: styles and a link in one block, and colour emoji.
	rich := gfx.ParseRich("Rich text mixes [b]bold[/b], [#ff8a5c]colour[/#], [u]underlines[/u] and a [link=docs]link[/link] in one block.")
	links := gr.DrawRichText(gfx.RichFonts{Regular: g.body, Bold: g.bold}, rich, 40, y, gfx.TextOptions{Width: 700}, white)
	for _, l := range links {
		gr.StrokeRect(l.Rect.X-2, l.Rect.Y-2, l.Rect.W+4, l.Rect.H+4, 1, gfx.RGB(90, 160, 255))
	}
	y += 30
	if g.hasEmoji {
		gr.DrawText(g.body, "Colour glyphs from the system emoji font: \U0001F600 \U0001F389 \U0001F680", 40, y, white)
	} else {
		gr.DrawText(g.body, "No emoji font found; a fallback with strikes, COLR layers or SVG glyphs draws emoji in colour.", 40, y, dim)
	}
	y += 40

Distance-field text

The last block draws the same two letters from the distance-field font at four sizes and then at an angle. TextOptions.Size overrides the size the font was created at, and Angle rotates the run in radians. Both also work with rasterised fonts, which resample their atlas when scaled; a distance-field face preserves sharper edges over a wider range. Measure is used again to advance x past each pair by the width it will actually occupy.

	// Distance-field text at several sizes and a rotation.
	gr.DrawText(g.body, "Distance-field text scales and rotates without blur:", 40, y, dim)
	y += 30
	x := float32(40)
	for _, size := range []float32{14, 22, 36, 56} {
		gr.DrawTextBlock(g.sdf, "Aa", x, y, gfx.TextOptions{Size: size}, gfx.RGB(255, 200, 90))
		w, _ := g.sdf.Measure("Aa", gfx.TextOptions{Size: size})
		x += w + 20
	}
	gr.DrawTextBlock(g.sdf, "tilted", x+20, y+50, gfx.TextOptions{Size: 30, Angle: -0.4}, gfx.RGB(140, 210, 255))
	return nil
}

main

func main() {
	seconds := flag.Float64("seconds", 0, "exit after this many seconds")
	shot := flag.String("shot", "", "write a screenshot to this PNG")
	font := flag.String("font", "", "a TTF with Arabic and Hebrew glyphs, used as a fallback")
	flag.Parse()
	err := engine.Run(engine.Config{Title: "Bunyip text", Width: 900, Height: 640},
		&game{seconds: *seconds, shot: *shot, fontPath: *font})
	if err != nil {
		fmt.Fprintln(os.Stderr, "text:", err)
		os.Exit(1)
	}
}

What to try

  • Pass -font a font from your own system, in main's -font flag, and add a line in Draw in a script that font covers.
  • Change popts in Draw to Align: gfx.AlignLeft and drop AutoHyphenate to see what justification and hyphenation are doing to the ragged edge.
  • Change Language in popts to "de" and translate the paragraph, or set it to a language the engine ships no patterns for, and watch the long words stop breaking rather than break in the wrong places.
  • Draw the paragraph twice in Draw, once with the ordinary font and once with g.sdf at the same size, and compare the edges when the window is resized.
  • Add [i]italic[/i] to the rich text string in Draw and give RichFonts an italic face, which shows what happens when a style has no font behind it.
  • Set TextOptions.Direction on the wrapped paragraph and watch the wrapping follow the direction.

Source files

main.go

The whole directory on GitHub