Bunyip a game engine in Go GitHub

2D graphics

The gfx package is one drawing context for a window. All of a 2D game's drawing goes through it. This guide covers the parts in the order a game needs them, from the frame to the batch statistics at the end.

The frame

Draw is called once per frame with the screen already cleared to ctx.Clear. Every Draw* call queues work and the engine submits it when Draw returns, so order matters only within a layer.

Coordinates are float32 view units with the origin at the top-left and +Y down. Angles are radians, clockwise on screen. Rectangles are lin.Rect (top-left corner and size), positions lin.Vec2. Colours are gfx.Color, linear and non-premultiplied, from gfx.RGB, gfx.RGBA, gfx.Hex or gfx.FromHSV; a zero colour where a tint is expected means white.

By default the view is the window's size in points, so ctx.Width and ctx.Height change on a resize. For a game designed at one resolution, set Config.ViewWidth and ViewHeight instead. The engine then scales that fixed view into the window and centres it, and the two values stay constant. Config.Scaling chooses the scaling. ScaleFit keeps the aspect ratio and adds bars. ScaleInteger scales by whole numbers only, which keeps pixel art crisp; leave those textures on the default nearest sampling. ScaleStretch fills the window. The window guide has the rest.

engine.Run(engine.Config{
	Title: "Shooter", Width: 1280, Height: 720, Resizable: true,
	ViewWidth: 320, ViewHeight: 180, Scaling: engine.ScaleInteger,
}, &game{})

Textures and atlases

Graphics.NewTexture uploads an image.Image. TextureOptions chooses Linear filtering for smooth scaling, NoMipmaps, Repeat to tile, and Data for pixels that are not sRGB colour, such as masks and normal maps. The renderer owns its textures and releases them when the game closes, including when setup or drawing fails. Call Destroy to release one earlier, for example when unloading a level. To load a texture by name, use the asset package. It resolves a name against loose directories, pack files and embedded filesystems in that order, so a loose file takes priority over a shipped one.

//go:embed assets
var embedded embed.FS

g.fs, err = asset.OpenFS(asset.Dir("assets"), asset.FSSource(embedded))
g.tex, err = asset.Texture(ctx.Gfx, g.fs, "sprites/hero.png", gfx.TextureOptions{})

A Region is a rectangle of a texture, made by gfx.NewRegion or returned by a sheet or an atlas. A Sheet cuts a texture into a grid of equal frames numbered row-major from the top-left; a Sheet literal takes Margin and Spacing when the grid has padding. ParseAtlas reads packed atlases with named frames, in TexturePacker JSON (hash or array) or Aseprite's JSON export; AtlasData.Bind ties the description to the uploaded texture, Atlas.Region looks a frame up by name and Tag returns an animation tag's frames in play order, with Durations for their per-frame times. Atlas.Animation wraps a tag as a RegionAnimation that plays with the timings the file gave each frame: At(t) returns the region to draw at a time from the start. With the asset package, asset.Atlas reads the JSON, loads the image it names from beside it and binds them in one call.

sheet := gfx.NewSheet(tex, 16, 16)
icon := gfx.NewRegion(tex, lin.R(96, 0, 16, 16))

atlas, err := asset.Atlas(ctx.Gfx, fs, "sprites/hero.json", gfx.TextureOptions{})
run := atlas.Animation("run")          // frames and durations as authored
frame, _ := run.At(ctx.Time)
gr.DrawRegion(frame, gfx.Sprite{Pos: g.hero})
idle, ok := atlas.Region("hero_idle_0")

Aseprite files

ParseAseprite reads Aseprite's own .aseprite and .ase files, so a game ships the file the artist saved rather than an export. It composites each frame from the layers that are visible in the editor, packs the frames into one image and describes them as an AtlasData, with the file's tags as animations that play at the timings it recorded. RGBA, greyscale and indexed files all read, layer opacity and group visibility are honoured, and a layer in a blend mode other than normal is drawn as normal. asset.Aseprite reads, packs, uploads and binds in one call.

Composited frames are named by number, "0" upwards. Set AsepriteOptions.Layers and each layer's own frames are packed beside them as "<layer>/<number>", a layer inside a group carrying the group's name first, for a hat or a damage overlay drawn on its own. Slices come through as named rectangles, and the result also carries the layers, tags and palette.

hero, err := asset.Aseprite(ctx.Gfx, fs, "sprites/hero.aseprite",
	gfx.AsepriteOptions{Layers: true}, gfx.TextureOptions{})
g.run = hero.Atlas.Animation("run")
hitbox, _ := hero.Slice("hitbox")
hat, _ := hero.Atlas.Region("gear/hat/2")

frame, _ := g.run.At(ctx.Time)
gr.DrawRegion(frame, gfx.Sprite{Pos: g.hero})

DrawNineSlice stretches a NineSlice over any rectangle while its corners keep their size, so a 24 by 24 png draws a panel or a speech bubble at any size. Set Tile to repeat the edges and centre instead of stretching them. NewBlankTexture creates writable storage and Texture.Write replaces a rectangle of its pixels. Writes during Draw stream without waiting for the GPU; writes outside a frame wait. A painting tool or a video can change one every frame; Texture.Read copies pixels back. Texture.Replace swaps the whole image, at a new size if need be, and keeps the *Texture the game holds, so every material and sprite that names it draws the new picture; it is what asset.Reloader calls when a texture's file changes on disk.

Read returns an ordinary Go RGBA image, with colour premultiplied in sRGB space, so it can be uploaded again or encoded as PNG. Data textures retain their raw channel values. Readback waits for completed GPU work and returns an error during an active frame, including Update and Draw; queued uploads and render-target draws have not been submitted yet. Texture.WritePNG(writer) leaves its writer open, and Texture.SavePNG(path) creates or truncates and closes its own file. Both use the same readback timing. Render textures are written through DrawTo, rather than through their borrowed texture view's Write.

destination.CopyFrom(source, sourceRect, destinationPoint) copies texture pixels on the GPU and rebuilds destination mipmaps. A zero source rectangle selects the full source. Regions must fit exactly and formats and Graphics owners must match. Copies preserve stored bytes; they do not scale, blend or convert colour. Non-overlapping self-copies work. Compressed textures, overlapping self-copies and render-texture destinations return errors. During a frame, copies run before all queued draws, like texture writes. A source already queued through DrawTo is rejected because its new pixels are not available yet.

For editable CPU pixels, gfx.NewImage(source) makes an independent, zero-based NRGBA copy. Its straight-alpha pixels work with the standard image and image/draw packages through the embedded *image.NRGBA. FlipHorizontal and FlipVertical mutate that copy; Mask(colour) makes exact straight-RGB matches transparent, ignoring the supplied alpha and preserving RGB. CopyFrom(source, point) copies full source bounds with clipping and draw.Src semantics, including overlapping subimages. Subimages share storage; use NewImage for another independent copy. WritePNG borrows a writer and SavePNG owns its file. The zero gfx.Image is empty and cannot be exported or used as a copy destination.

pixels, err := gfx.NewImage(source)
if err != nil {
	return err
}
pixels.FlipHorizontal()
pixels.Mask(color.NRGBA{R: 255, B: 255, A: 255})
return pixels.SavePNG("edited.png")
ns := gfx.NineSlice{Tex: g.panel, Left: 8, Top: 8, Right: 8, Bottom: 8}
gr.DrawNineSlice(ns, lin.R(12, 12, 300, 92), gfx.White)

Compressed textures

A texture uploaded from a PNG costs four bytes a texel on the GPU, and the mip chain requested by linear filtering costs about a third again. Nearest-filtered textures do not generate mipmaps by default. bunyip-tex compresses an image to one of the BC block formats ahead of time and writes it with its whole mip chain as a KTX2 file, which gfx.NewCompressedTexture uploads block for block:

bunyip-tex -format bc7 sprites/hero.png       # writes sprites/hero.ktx2
bunyip-tex -format bc5 -linear normals/*.png  # tangent-space normal maps
g.hero, err = asset.Texture(ctx.Gfx, fs, "sprites/hero.ktx2", gfx.TextureOptions{Linear: true})

asset.Texture picks the path from the name, so a game changes the extension and nothing else. Which format suits what:

Format Bits a texel For
bc1 4 opaque colour where a quarter of the size matters more than the last of the quality
bc3 8 colour with alpha: sprites with soft edges
bc4 4 one channel: masks, height fields, roughness
bc5 8 two channels: tangent-space normal maps
bc7 8 colour, with or without alpha, that has to hold up close

-linear says the file holds data rather than sRGB colour, which is what a normal map, a mask or a roughness map is; bc4 and bc5 are always linear. -no-mips writes level 0 alone, -fast skips BC7's search through its two-subset partitions, and -v reports each file's size and its peak signal-to-noise ratio so a choice of format can be judged rather than guessed at. Nothing is compressed or downsampled while the game runs: the blocks and the levels go straight to the GPU. KTX2 reading and writing reject mip chains longer than the dimensions permit: floor(log2(max(width, height))) + 1 levels at most. Partial chains remain valid, and a header requesting generated levels is read as its supplied base level.

A device that cannot sample the format, which some MoltenVK configurations cannot for the BC formats, decodes level 0 on the processor into a plain texture instead, so the same file works on those devices at the cost of the memory it was meant to save. This fallback supports the CPU decoder's formats, with BC7 limited to modes 1 and 6, and generates any requested mipmaps from the decoded base level. A KTX2 file holding ASTC uploads on a device that samples it; nothing here encodes or decodes ASTC, so there is no fallback for one.

Sprites

A Sprite is one textured quad: a position, a size in view units, the UV window into the texture, a tint, a rotation, an origin as a fraction of the size, FlipX and FlipY, and a Filter that overrides the texture's own sampling for that draw. Graphics.Draw queues one, and a nil texture draws a 1x1 white pixel, so a plain coloured quad is a tinted sprite. DrawTexture places a whole texture at its own size; DrawRegion and DrawFrame fill the UVs in from a region or a sheet frame, where a zero Size means its own size. DrawIndexed and DrawTriangles take raw Vertex2D geometry. Sheet animation uses two values: an Animation lists frames and a rate, and an AnimState plays it, with Advance, Frame and Done.

gr.Draw(g.tex, gfx.Sprite{
	Pos:    e.pos,
	Size:   lin.V2(48, 48),
	Origin: lin.V2(0.5, 0.5), // rotate about the middle
	Rotation: e.angle, FlipX: e.facingLeft, Color: gfx.RGB(255, 220, 180),
})

g.walk = gfx.Animation{Frames: []int{4, 5, 6, 7}, FPS: 8, Loop: true}
g.anim.Play(&g.walk)
g.anim.Advance(ctx.Delta)                                      // Update
gr.DrawFrame(sheet, g.anim.Frame(), gfx.Sprite{Pos: g.player}) // Draw

Consecutive sprites that share a texture, blend mode, shader, clip and colour matrix merge into one draw call. Changing any of those breaks the batch, so a hundred sprites from one atlas cost one draw and alternating between two textures costs a hundred. Pack sprites into one atlas to keep them grouped by texture, and use SetLayer to control what draws in front without breaking that grouping.

Bounds and placement

Sprite.Pos is the pivot position. With Origin: lin.V2(0.5, 0.5), that position is the sprite's centre. Transform2.Apply places a sprite template at the transform's centre, with its rotation and scale.

Sprite.Corners returns the four placed corners; Sprite.Bounds encloses them in an axis-aligned rectangle. Both include the sprite's size, origin and rotation, but exclude the graphics transform stack and camera. lin.Affine.TransformRect bounds a rectangle after a transform:

func spriteWorldBounds(sprite gfx.Sprite, transform lin.Affine) lin.Rect {
	return transform.TransformRect(sprite.Bounds())
}

This encloses the sprite after the transform. For the tightest bounds after combining rotations, transform each point from sprite.Corners() and enclose those points instead of transforming its already axis-aligned bounds. Bounds are useful for selection and broad collision checks; they do not test the texture's transparent pixels.

Reusable 2D geometry

DrawTriangles and DrawIndexed copy supplied vertices into the current frame. For geometry reused over many frames, NewGeometry2D uploads it once. Drawing it uses the same layers, sort keys, camera, transforms, clipping, blending and shaders as sprites, without copying or uploading the vertices again. Create it during Init:

func newBadge(gr *gfx.Graphics) (*gfx.Geometry2D, error) {
	vertices := []gfx.Vertex2D{
		{Pos: lin.V2(24, 0), Color: gfx.RGB(255, 180, 60)},
		{Pos: lin.V2(48, 24), Color: gfx.RGB(255, 80, 40)},
		{Pos: lin.V2(24, 48), Color: gfx.RGB(180, 40, 80)},
		{Pos: lin.V2(0, 24), Color: gfx.RGB(255, 180, 60)},
	}
	return gr.NewGeometry2D(vertices, []uint32{0, 1, 2, 0, 2, 3})
}

func drawBadge(gr *gfx.Graphics, badge *gfx.Geometry2D) {
	gr.Transformed(lin.Translate2(100, 80), func() {
		gr.DrawGeometry(nil, badge)
	})
}

A nil texture draws the vertex colours. Supply a texture and vertex UV coordinates for textured geometry. Nil indices mean consecutive triangles, three vertices each. Invalid indices and incomplete triangles return an error. Empty geometry is valid and draws nothing. Geometry2D.Bounds reports the local bounds of all uploaded vertices, including unused ones. You can reuse the input slices as soon as creation or Update returns.

Update replaces the GPU geometry. Each queued draw keeps the version that existed when it was queued, so this draws the old shape on the left and the replacement on the right:

func replaceAndDraw(gr *gfx.Graphics, shape *gfx.Geometry2D,
	vertices []gfx.Vertex2D, indices []uint32) error {
	gr.DrawGeometry(nil, shape)
	if err := shape.Update(vertices, indices); err != nil {
		return err
	}
	gr.Transformed(lin.Translate2(80, 0), func() {
		gr.DrawGeometry(nil, shape)
	})
	return nil
}

Graphics owns the geometry and releases it at shutdown, including after setup or drawing fails. Destroy releases it earlier; queued and in-flight draws still finish using their captured buffers. Later draws of destroyed geometry do nothing, and updating it returns an error. A failed Update leaves the existing geometry intact. Textures supplied to DrawGeometry have their own lifetime and are not destroyed with the geometry.

Tint, blending and transforms

The sprite's Color multiplies the texture. Use it for team colours, fading and a damage flash. For more, a ColorMatrix recolours everything drawn inside it: Saturation, HueRotate, Brightness, Contrast, Invert, Grayscale, Sepia and Tint, composed with Mul. To set a blend mode for a stretch of drawing, call Blended. BlendAdd suits glows and explosions and BlendMultiply suits shadows and tinted glass; BlendScreen, BlendLighten, BlendDarken, BlendReplace and BlendErase are the rest, and BlendErase cuts a hole.

Transformed maps everything drawn inside it through a lin.Affine (Translate2, Rotate2, Scale2, Shear2, composed with Mul), and Clip limits drawing to a rectangle, which keeps a scrolling list inside its panel. SetColorMatrix, SetBlend, PushTransform/ PopTransform and PushClip/PopClip are the non-closure forms.

gr.ColorMatrixed(gfx.Brightness(0.6).Mul(gfx.Tint(gfx.RGB(255, 90, 90))), func() {
	gr.DrawRegion(hurt, gfx.Sprite{Pos: g.player})
})
gr.Blended(gfx.BlendAdd, func() { gr.Draw(g.glow, muzzleFlash) })

Custom blend equations and masks

CustomBlended controls colour and alpha equations separately. Start with a preset's Options() and edit the factors you need; explicit zero factors stay zero. Inputs are premultiplied, as with built-in blend modes:

func drawColourOnly(gr *gfx.Graphics, tex *gfx.Texture, sprite gfx.Sprite) {
	blend := gfx.BlendAlpha.Options()
	blend.SrcAlpha = gfx.FactorZero
	blend.DstAlpha = gfx.FactorOne // keep the destination alpha
	gr.CustomBlended(blend, func() { gr.Draw(tex, sprite) })
}

Use Masked for a shape that clips other drawing. The first closure draws coverage without changing colour; the second draws through that coverage. For example, a circular portrait needs no intermediate texture:

func drawPortrait(gr *gfx.Graphics, portrait *gfx.Texture) {
	gr.Masked(func() {
		gr.FillCircle(48, 48, 40, gfx.White)
	}, func() {
		gr.Draw(portrait, gfx.Sprite{Pos: lin.V2(8, 8), Size: lin.V2(80, 80)})
	})
}

Masks nest up to eight levels. Their setup, coverage, contents and cleanup form ordered phases: layers and sort keys sort within a phase, never across one. This also holds for reusable geometry and flat particles, so callbacks can use their normal layer choices. Scopes restore stencil, layer and sort key even on panic; drawing already queued remains. Each nesting level uses and clears one low stencil bit, preserving other bits. An enclosing advanced stencil configuration is suspended during Masked and restored afterward. A drawing helper that uses Masked can itself build another mask: its clipped drawing contributes coverage without writing visible colour.

Coverage follows rasterized fragments, including transparent ones unless the shader discards them. The edge is stencil coverage rather than a soft alpha mask. Use a shader or a render texture for feathered alpha masking.

Stenciled(StencilOptions{...}, draw) exposes comparison, reference, read and write masks, and pass/fail operations for custom effects. Its zero value draws normally without changing stencil; zero masks select all eight bits, and DisableWrite prevents updates. NoColor disables colour writes for mask-building draws. Advanced scopes retain ordinary layer/key ordering. ClearStencil(value) clears only the current view and clip and adds an ordering boundary, leaving colour and depth unchanged. Stencil contents last for that target's frame. These operations require a stencil attachment; render textures made with NoDepth reject them before running callbacks.

The camera, layers and the HUD

SetCamera2D makes later drawing world-space. A Camera2D has a position (the world point at the centre of the view), a Zoom where 2 shows half as much, and a Rotation. Follow moves it towards a target at a rate per second, the same at any frame rate; Clamp keeps the view inside the level's rectangle; Shake throws the view about for a moment and Advance, called once per update, runs the shake and lets it settle. ViewToWorld maps the pointer back into the world, WorldToView goes the other way for a marker pinned to an entity, and VisibleRect returns the world rectangle on screen. Sprites wholly outside that rectangle are dropped before they reach the vertex stream, and FrameStats.Culled2D counts them. Without a camera the same happens against the view itself. The test is the sprite's own four corners against the view, so a long thin rotated sprite is culled as soon as its quad clears the view, and it holds under Transformed as well: a sprite the transform stack pushes off screen costs nothing. DrawTilemap takes the view back through the transform stack into the map's own units and only visits the tiles inside it, with or without a camera, and a text layout wholly off screen is dropped in one test. ScreenSpace returns to view coordinates for the HUD.

Use WithCamera2D and Layered when the state belongs to a block of drawing. Each restores the previous state when its closure returns, including on panic:

gr.WithCamera2D(g.cam, func() {
	gr.Layered(2, func() { g.player.Draw(gr) })
})
gr.Layered(100, func() { gr.DebugText(8, 8, "Score: 10") })

The other drawing scopes (Blended, Transformed, Shaded, ColorMatrixed, Clip and DrawTo) also restore their original queue's state on panic. They do not undo drawing already queued.

g.cam.Follow(g.player.Pos, 8, ctx.Delta) // in Update
g.cam.Clamp(g.level.Bounds(), ctx.Width, ctx.Height)
g.cam.Advance(ctx.Delta)
if landed {
	g.cam.Shake(6, 0.3)
}

SetLayer orders drawing across calls. Sprites draw in ascending layer order and, within a layer, by sort key and then in submission order, so a game can write its draw code in any order and still get the right stacking. For parallax, put each background on its own layer and translate it by its share of the camera's offset. To sort sprites by their feet in a top-down game, set SetSortKey to each sprite's foot position before drawing it: a character standing lower on the screen then draws over one behind it, whatever order the draw calls came in.

for _, a := range g.actors {
	gr.SetSortKey(a.pos.Y + a.height)
	gr.DrawRegion(a.region, gfx.Sprite{Pos: a.pos})
}
gr.SetSortKey(0)
// Update: ease towards the player, then shake.
g.cam.Position = g.cam.Position.Lerp(g.player, 1-float32(math.Pow(0.02, ctx.Delta)))
if g.shake -= float32(ctx.Delta) * 3; g.shake > 0 {
	kick := lin.V2(g.rng.Between(-1, 1), g.rng.Between(-1, 1)).Mul(g.shake * 8)
	g.cam.Position = g.cam.Position.Add(kick)
}

// Draw
gr.SetCamera2D(g.cam)
for i, bg := range g.parallax { // 0 is furthest
	gr.SetLayer(i)
	share := 0.8 - 0.3*float32(i)
	gr.Transformed(lin.Translate2(g.cam.Position.X*share, 0), func() { gr.DrawTexture(bg, 0, 0) })
}
gr.SetLayer(10)
gr.DrawTilemap(g.tilemap, 0, 0, gfx.White)
gr.SetLayer(20)
g.drawActors(gr)

gr.ScreenSpace() // the HUD, in view coordinates
gr.SetLayer(100)
gr.DrawText(g.font, fmt.Sprintf("HP %d", g.hp), 12, 12, gfx.White)
gr.SetLayer(0)

Views inside a view

WithView fits a local coordinate space into a rectangle of the enclosing view. A minimap can occupy 200 by 150 view units while drawing a world area 1000 by 750 units across, without a render texture or a manual scale:

func drawMinimap(gr *gfx.Graphics, mapGeometry *gfx.Geometry2D, centre lin.Vec2) {
	view := gfx.View2D{
		Viewport: lin.R(20, 20, 200, 150),
		Size:     lin.V2(1000, 750),
	}
	gr.WithView(view, func() {
		gr.WithCamera2D(gfx.Camera2D{Position: centre}, func() {
			gr.DrawGeometry(nil, mapGeometry)
		})
	})
}

The viewport uses enclosing view coordinates. A camera or drawing transform does not move the rectangle itself. Drawing inside inherits the camera, recalculated for the local virtual size; WithCamera2D can replace it, or ScreenSpace can draw a local HUD. Sprites, paths, text, reusable geometry and 2D particles share the view and clip to its rectangle and any enclosing clips. Views nest, and Graphics.View() reports the current local size. A zero component of Size uses the corresponding viewport dimension.

Use the same value to map pointer input into the minimap's world:

func minimapPoint(view gfx.View2D, camera gfx.Camera2D, pointer lin.Vec2) (lin.Vec2, bool) {
	if !view.Viewport.Contains(pointer) {
		return lin.Vec2{}, false
	}
	return view.ParentToWorld(pointer, camera), true
}

WorldToParent maps world markers back to the enclosing view. ParentToLocal and LocalToParent perform the layout mapping without a camera. None clamp points: test Viewport.Contains when input outside the rectangle should be ignored. For nested views, map through each enclosing view in turn. Engine input and context dimensions remain in the main view.

Viewport dimensions must be positive, virtual dimensions nonnegative, and all values finite; invalid views panic before changing drawing state. WithView restores the previous view, camera and clips even on panic, while retaining queued drawing. Shader frame size and pixel density follow each draw's local view. These scopes affect 2D drawing; use render textures for separate 3D camera passes. Configure the main output with SetView and SetViewport outside these scopes; calling either inside WithView panics. DrawTo can still select another render texture inside a view and restores the view on return.

Tilemaps

A Tilemap is a grid of frame indices into a Sheet; -1 is an empty cell. TileW and TileH set the drawn size of a tile, so 16-pixel art can be drawn at 32 units. DrawTilemap skips the cells outside the active camera's view, so the cost depends on what is on screen rather than on the size of the map.

A cell can carry flip bits above the frame index, so one sheet frame serves eight orientations: TileFlipped packs them, TileFrame unpacks a cell, and the bits (TileFlipX, TileFlipY, TileFlipDiag) match the Tiled editor's convention. Animate makes one frame index cycle through others, for water and torches, and Advance steps every animation on the map. For a very large or infinite world, keep one tilemap per chunk and draw the chunks that intersect VisibleRect; building a tilemap is cheap.

g.tilemap = gfx.NewTilemap(sheet, 256, 256)
g.tilemap.TileW, g.tilemap.TileH = 32, 32
g.tilemap.Set(x, y, frameWall)
g.tilemap.Set(x+1, y, gfx.TileFlipped(frameArrow, true, false, false))
solid := g.tilemap.Get(x, y) == frameWall
g.tilemap.Animate(frameWater, gfx.TileAnimation{
	Frames: []int{frameWater, frameWaterB}, Durations: []float32{0.4}})
g.tilemap.Advance(ctx.Delta) // in Update

Autotiling

To keep a map of plain terrain ids and let the tiles pick themselves, use the autotile package. A Mapper turns terrain ids into frame indices: Apply fills a whole tilemap, Cell patches the neighbourhood of one edited cell, and Region patches everything an edited rectangle can affect. Five rule kinds cover the usual tilesets: Edge16 matches the four edge neighbours with 16 tiles (walls, pipes, fences), Edge64 is its hexagonal counterpart matching the six sides of a hexagon with 64 tiles, Blob47 matches all eight neighbours with the 47 distinct blob tiles, Corner16 is the dual grid where each tile sits on a corner between four cells, and Wang matches terrain colours on tile edges or corners for any number of terrains meeting with transitions. ExpandBlob composes the 47 blob tiles from a six-tile template, so an artist draws six tiles instead of 47. Variants weight alternative frames per neighbourhood, chosen by a stable hash of the cell position.

img, frames := autotile.ExpandBlob(template, 16)
tex, _ := ctx.Gfx.NewTexture(img, gfx.TextureOptions{})
grassMap := gfx.NewTilemap(gfx.NewSheet(tex, 16, 16), w, h)
grass := &autotile.Mapper{Rules: autotile.Blob47(1, frames)}
grass.Apply(w, h, terrainAt, grassMap.Set)   // the whole map once
grass.Cell(x, y, w, h, terrainAt, grassMap.Set) // after one edit

To re-tile after a brush stroke, a fill or a pasted block, call Region once with the rectangle of edited cells rather than Cell for each of them. It takes the rectangle's top-left cell, its width and height in cells, and the map size, and sets the same frames that calling Cell for every edited cell would set. A cell next to several edited cells is computed once, and each affected cell goes to the setter once, in row-major order. The rectangle may extend past the map's edges.

// A 5 by 3 block pasted with its top-left cell at (px, py).
grass.Region(px, py, 5, 3, w, h, terrainAt, grassMap.Set)

Mapper.Layout is the shape of the grid, and the zero value is a square one. HexRowsOdd, HexRowsEven, HexColsOdd and HexColsEven are hexagons in staggered rows or columns, named the way the Tiled editor names its stagger axis and index; HexAxial is the same six directions in axial coordinates. A hexagonal layout has six neighbours and no diagonals, so it takes Edge64 or Wang rules. IsoDiamond keeps eight neighbours but turns them a quarter turn, so the direction names are the tile's directions on screen and the cell north of (x, y) is (x-1, y-1). Layout.Neighbour and Layout.Dirs are the same walk for a game's own code, such as moving a unit across a hex map.

hex := &autotile.Mapper{Rules: autotile.Edge64(1, frames), Layout: autotile.HexRowsOdd}
hex.Apply(w, h, terrainAt, setFrame)
x, y = autotile.HexRowsOdd.Neighbour(x, y, autotile.DirNE)

Terrain sets painted in the Tiled editor's terrain tool come in through the tiled package: Map.WangSet finds a set by name and its Rules method converts it, with tile ids as frames. On a hexagonal map, call Map.Layout for the layout, pass it to WangSet.RulesFor and give the same layout to the Mapper; the conversion moves each colour into the direction slot the layout uses, because Tiled stores a hexagon's six sides one slot back in the eight-slot wangid. An isometric map gets Square, since Tiled's own terrain tool matches an isometric map on the plain grid neighbours. The autotile example paints grass, walls and water with the mouse over one shared terrain grid, one rule kind each, and draws a hexagonal edge set in the strip below them.

Maps from the Tiled editor

The tiled package reads maps saved by the Tiled editor in its JSON form (.tmj, .tsj) or its XML form (.tmx, .tsx). Parse tells the forms apart by the first byte; Load reads from disk and LoadFS through an asset filesystem. Build loads the tileset images, makes one gfx.Tilemap per tileset a layer uses, wires up the per-tile animations and returns a Level whose Draw draws the layers in order with the group state above them applied. Build draws a rectangular grid regardless of the parsed orientation; isometric and hexagonal maps need a custom drawing path. Image layers and tilesets made from separate per-tile images are parsed but not drawn. Advance steps the tile animations, Size gives the map's pixel size, Layer finds a layer by name and Destroy releases the textures.

Infinite tile layers retain their flattened cell origin in StartX and StartY, but Build does not apply it to the drawing offset. Use a custom drawing path for infinite chunks whose origin is not (0, 0).

Object layers are left to the game. They hold rectangles, ellipses, points, polygons and polylines with names, classes and typed custom properties; read them for spawn points, triggers and collision shapes. Horizontal, vertical and diagonal flips on rectangular maps survive the drawing bridge. For hexagonal maps, interpret the raw global ID's rotation flags yourself; SplitGID discards the 120-degree bit. Tile layer data decodes in every form Tiled writes: CSV, and base64 plain or compressed with zlib, gzip or zstd.

m, err := tiled.LoadFS(g.fs, "maps/level1.tmj")
g.level, err = tiled.Build(ctx.Gfx, m, tiled.ImagesFrom(g.fs, "maps"))
for _, l := range g.level.Layers {
	for _, o := range l.Objects { // empty on tile layers
		switch o.Class {
		case "spawn":
			g.player = lin.V2(o.X, o.Y).Add(l.Offset)
		case "solid":
			g.solids = append(g.solids, o.Rect())
		case "door":
			g.doors = append(g.doors, door{o.Rect(), o.Properties.String("target")})
		}
	}
}

Text

NewFont parses an OpenType font and rasterises its glyphs at one size into an atlas. Text is shaped with HarfBuzz, so kerning, ligatures, mark placement and Arabic joining are right, right-to-left runs are reordered, and lines break by the Unicode rules. Glyphs render at the framebuffer's pixel density and draw in view units, so text is crisp on a high-DPI display. FontOptions adds fallback fonts for scripts the main one lacks, OpenType features, variable-font axes and ranges to render up front.

DrawText draws from the top-left. DrawTextBlock wraps, aligns and rotates a paragraph through TextOptions: a Width to wrap in, an Align (AlignLeft, AlignCenter, AlignRight, AlignJustify), LineSpacing, a Size, an Angle, LetterSpacing, Baseline, a Hyphenate hyphenator, an AutoHyphenate that picks one for the Language, a Direction and a Language. Font.Measure sizes text without drawing it. Font.Layout returns an immutable *TextLayout for repeated drawing and queries. A font caches layouts by text and options, and drawing, Measure and Layout share that cache, so a label measured and drawn is shaped once and repeating it every frame costs a lookup with no allocation. Text asked for once, such as a counter that changes every frame, is laid out and drawn but only kept once it is asked for again, so it does not push out what is drawn every frame. The cache grows to hold everything a frame draws, however much that is, and falls back once frames draw less. A glyph drawn for the first time is rasterised and uploaded during that frame, so new text shows up in the frame that asks for it. To skip even the lookup, keep the *TextLayout and draw it with DrawTextLayout.

Layout can allocate or upload atlas pages and returns an error. Immediate draw helpers report those failures through frame submission and engine.Run. Invalid sizes and exhausted atlas capacity are errors, rather than missing letters. Font.Shape also returns an error for its low-level glyph path.

g.font, err = asset.Font(ctx.Gfx, g.fs, "fonts/body.ttf", 18, gfx.FontOptions{
	Fallbacks: [][]byte{cjkTTF, emojiTTC}, // consulted in order
	Features:  []string{"-liga"},
})

opts := gfx.TextOptions{Width: 420, Align: gfx.AlignJustify, Hyphenate: gfx.EnglishHyphenator()}
w, h := g.font.Measure(story, opts)
gr.FillRect(38, y-4, w+4, h+8, gfx.RGBA(0, 0, 0, 160))
gr.DrawTextBlock(g.font, story, 40, y, opts, gfx.White)

Build a layout when the label changes, then retain it as ordinary Go data:

func label(font *gfx.Font, text string) (*gfx.TextLayout, error) {
	return font.Layout(text, gfx.TextOptions{
		Width: 420, Underline: true,
		OutlineWidth: 1.5, OutlineColor: gfx.RGB(20, 30, 50),
	})
}

func drawLabel(gr *gfx.Graphics, layout *gfx.TextLayout, mouse lin.Vec2) gfx.TextCaret {
	gr.DrawTextLayout(layout, 40, 80, gfx.White)
	caret := layout.HitTest(mouse.Sub(lin.V2(40, 80)))
	r := layout.Caret(caret)
	gr.FillRect(40+r.X, 80+r.Y, r.W, r.H, gfx.RGB(255, 180, 80))
	return caret
}

Bounds describes advances and line boxes; InkBounds includes glyph overhangs, outlines and decorations, excluding atlas padding and the sampling filter's antialias fringe. Both include alignment and Angle. Lines returns source ranges and baselines. Wrapped trailing whitespace keeps its source boundaries with zero advance; unwrapped spaces retain their width. Blank lines still have a logical line height.

Caret indices address bytes in the original UTF-8 string, including newlines and wrapped paragraphs. Generated hyphens add no source bytes. Combining sequences and ligatures are atomic: interior indices snap to the nearest boundary, choosing the lower index on a tie. At a wrap or bidi transition, CaretLeading follows the next cluster and CaretTrailing the previous one. Hit testing takes layout-local coordinates, including its rotation; subtract the draw origin and undo any surrounding drawing transform first. Vertical text runs down in columns that progress left. Multiple trimmed whitespace boundaries can share one caret position.

Layouts borrow their fonts and need no Destroy. The owning Graphics releases fonts at shutdown; Font.Destroy releases them earlier. Queries on a layout remain valid afterward, but new draws require live fonts from the same Graphics. A draw already queued retains its atlas through completion.

Underline, Strikethrough and OutlineWidth are available in TextOptions and rich runs. Outline width is in view units; a zero OutlineColor follows the effective text colour. A layout's outlines are drawn beneath all of its glyphs, so a neighbour's outline never covers a letter, and outlines that share a colour and width are one draw call. Bitmap and SDF fonts lazily build coverage pages with reusable distance ranges, so modest width animation reuses pages. FontOptions.OutlinePages sets the per-font page budget (zero means 16). Oversized outlines or glyphs return descriptive errors; they are never clipped to fit an atlas. Choose another text size or reduce the outline width if it exceeds the supported distance range.

Hyphenation uses the TeX patterns, by Liang's method. The engine ships patterns for American and British English, German, French, Spanish, Italian, Dutch, Portuguese, Swedish, Danish, Norwegian, Finnish, Polish and Russian; gfx/hyph/README.md lists the files and their licences. EnglishHyphenator returns the American English one and HyphenatorFor("de-AT") any other, falling back from a regional tag to its primary language and loading the patterns on first use. Text that sets AutoHyphenate and a Language picks its own hyphenator, so a translated interface hyphenates in the language it is showing, and a language with no shipped patterns is left unhyphenated. ParseTeXPatterns loads any other pattern file a game ships.

opts := gfx.TextOptions{Width: 420, Align: gfx.AlignJustify,
	Language: g.tr.Lang(), AutoHyphenate: true} // g.tr is a locale.Translator

NewSDFFont builds a signed-distance atlas that stays sharp at any size and angle, so one font object serves damage numbers and a zooming strategy map; it draws a colour glyph as its outline.

Colour glyphs draw in their own colours, whichever way the font describes them: a bitmap strike (sbix or CBDT, which is Apple's and Google's emoji), COLR layers, or an SVG document per glyph. COLR version 1 paints are drawn too, with their gradients, transforms and compositing, so a font like Noto Color Emoji comes out right. A colour glyph preserves its RGB and uses the effective text alpha. Give the emoji font as a Fallbacks entry and emoji appear in ordinary strings. A collection (.ttc) contributes its first face, and only that face is read. Fonts made from the same bytes share one parse of them while any of those fonts is alive, so an emoji collection behind several sizes of a font is read once, and the parsed tables are copies, so the file's bytes need not be kept after NewFont returns. A font's glyph atlas is held as one byte a texel until its first colour glyph. What is not drawn is listed in docs/design/gaps.md: the variable paint tables' deltas in COLR, and strokes, clipping and filters in SVG.

ParseRich reads a small markup ([b], [i], [u], [s], [#ff8800], [link=name]) into a RichText that DrawRichText lays out across regular, bold and italic faces, returning each link's rectangle for clicks. Every stretch of rich text in one face is shaped as a whole, so kerning and ligatures work across a colour or link change inside it; the glyphs are cut apart by cluster afterwards. A cluster crossing a style change takes its colour, decorations and link from its first source byte. Font changes start a new shaped run at a grapheme boundary. Plain and rich text share Unicode wrapping, hyphenation and the same TextLayout queries. RichFonts.Layout constructs a reusable rich layout; its indices address RichText.Plain, not markup tags. DrawTextLayout multiplies explicit run colours by its tint, with zero tint meaning white.

rich := gfx.ParseRich("You found the [#ffcc44]Brass Key[/#]. [link=map]Open it[/link].")
links := gr.DrawRichText(gfx.RichFonts{Regular: g.body, Bold: g.bold}, rich, 40, y,
	gfx.TextOptions{Width: 700}, gfx.White)
for _, l := range links {
	if l.Rect.Contains(mouse) && clicked {
		g.open(l.Name)
	}
}

DrawTextOnPath draws a line of text along a path with each glyph rotated to follow it, for labels such as a river name on a strategy map. Font.Shape returns ([]Glyph, error) for custom single-line drawing, each glyph carrying its source byte and pen advance. DrawGlyphs draws that slice. This low-level path, including text on paths and billboards, leaves block wrapping and decorations to the caller; use TextLayout for reusable block drawing and caret queries.

Shapes and paths

FillRect, FillCircle, FillPolygon, StrokeRect, StrokeCircle and StrokeLine draw simple shapes. They go through the sprite stream, so they sort by layer and clip like everything else.

A Path collects lines, quadratic and cubic curves and arcs, with Rect, RoundRect, Circle, Ellipse and Polygon helpers. It holds no GPU state, so one path value can be reset and rebuilt every frame. FillPath fills it under the non-zero or even-odd rule and StrokePath outlines it, both anti-aliased. StrokeOptions sets the width, Cap, Join, miter limit and a Dash pattern; FillOptions maps a Texture over the path or colours it with a Gradient. A Gradient is baked from stops and given a direction with Linear or Radial; it holds a small texture, which the renderer releases at shutdown; Destroy releases it earlier.

var p gfx.Path
p.MoveTo(40, 340).QuadTo(140, 220, 240, 340).CubicTo(300, 420, 360, 220, 420, 340)
gr.StrokePath(&p, gfx.RGB(120, 220, 160), gfx.StrokeOptions{Width: 6, Cap: gfx.CapRound})

p.Reset()
p.Circle(470, 110, 80).Circle(470, 110, 45) // a ring: the inner circle is a hole
gr.FillPath(&p, gfx.RGB(90, 170, 220), gfx.FillOptions{Rule: gfx.FillEvenOdd})

g.sky, err = ctx.Gfx.NewGradient(
	gfx.GradientStop{T: 0, Color: gfx.RGB(20, 30, 70)},
	gfx.GradientStop{T: 1, Color: gfx.RGB(220, 120, 60)})
g.sky.Linear(lin.V2(0, 0), lin.V2(0, 180))
gr.FillGradient(lin.R(0, 0, ctx.Width, 180), g.sky)

Path.Bounds computes the bounds of the stored lines and Bézier curves, including their extrema and isolated MoveTo points. It excludes stroke width, antialias fringes and drawing transforms; an empty path has zero bounds. Circles and arcs are bounded as the cubic curves stored by Path.

Compile paths that stay the same

CompilePath tessellates a path once into GPU geometry. DrawPath reuses those triangles with the current drawing state. This avoids flattening curves and rebuilding fills and strokes every frame:

func newPanel(gr *gfx.Graphics) (*gfx.CompiledPath, error) {
	path := new(gfx.Path).RoundRect(0, 0, 180, 64, 12)
	return gr.CompilePath(path, gfx.PathOptions{
		Fill:        &gfx.FillOptions{},
		FillColor:   gfx.RGB(30, 70, 120),
		Stroke:      &gfx.StrokeOptions{Width: 2, Join: gfx.JoinRound},
		StrokeColor: gfx.White,
	})
}

func drawPanel(gr *gfx.Graphics, panel *gfx.CompiledPath) {
	gr.Transformed(lin.Translate2(20, 20), func() {
		gr.DrawPath(panel)
	})
}

PathOptions{} means a white fill. If either Fill or Stroke is non-nil, only those explicitly selected paints are compiled; a lone Stroke therefore makes an outline. Fill draws before stroke. Zero colours mean white; omit an unwanted paint, or use a nonzero colour with zero alpha for a transparent paint.

PixelsPerUnit chooses tessellation precision and antialias fringe width. Zero selects one framebuffer pixel per local path unit. For a path drawn at twice that density, set PixelsPerUnit: 2; include framebuffer density, camera zoom and drawing scale when choosing it. Recompile when the expected density changes substantially. Drawing a compiled path never retessellates it.

The result captures the path, paint coordinates and colours. Changing the source path or options afterwards does not change it. Paint textures are borrowed: keep them alive while drawing the compiled path. Graphics owns the compiled triangles; CompiledPath.Destroy releases those triangles early without destroying the textures, and queued draws still finish. CompiledPath.Bounds encloses the resulting triangles, including stroke and antialias fringes, before the drawing transform and camera.

Particles

The particle package simulates emitters on the CPU and draws them through the sprite stream, so they batch and sort with everything else. An Emitter is a plain struct of documented zero-default fields: a Rate or a Burst, a Shape to emit from, the speed, direction and spread, the lifetime, acceleration and damping, size and colour curves over each particle's life, a texture, region or sheet, a Blend and a Layer. Fire, Smoke, Sparks, Rain and Confetti are presets to start from.

A System owns the live particles: Update advances it, Draw queues them, SetPosition moves it, Burst fires a one-shot and Finished reports one that has run out, so the game can drop it. WorldSpace decides whether particles stay where they were emitted when the system moves, which suits smoke, or travel with it, which suits a thruster flame. Thousands are cheap; tens of thousands still draw as one batch but cost CPU in Update.

e := particle.Fire()
e.Position = hearth
e.Texture = g.soft // particle.SoftCircle(64), uploaded as a texture
e.Prewarm = 1.5    // already burning on its first frame
e.Layer = 2
g.fire = particle.New(e)

g.fire.Update(ctx.Delta) // Update
g.fire.Draw(gr)          // Draw

Hundreds of thousands at once

GPUSystem runs the same Emitter over far more particles. It keeps each particle as a few numbers in parallel arrays, moves them with plain loops over those arrays, and draws the whole system as one instanced draw through gfx.DrawParticles, so no sprite is built and no vertices are written. NewGPU replaces New; everything else (Update, Draw, SetPosition, Burst, Finished) is similar, and the stateful random stream matches the CPU path particle for particle. GPU appearance curves use 64-entry lookup tables, so their interpolation is quantized. Raise Emitter.Max, which defaults to the 1000 the CPU path assumes.

SetEmitter retunes the appearance and future births. Existing stateful particles retain the palette tint selected when they were born, even when the palette is replaced, shortened or removed; new particles use the new palette. Colour curves still apply to all live particles.

On a desktop machine, two hundred thousand particles cost about 0.3 ms to simulate, 1.1 ms to pack into instance records and 1.8 ms to upload and draw, against about 15 ms to draw the same count through the sprite stream.

e := particle.Rain()
e.Max = 200_000
e.Rate = 60_000
g.storm = particle.NewGPU(e)

g.storm.Update(ctx.Delta)
g.storm.Draw(gr) // one instanced draw call

A batch takes the layer its emitter names and interleaves with the sprite stream by layer, so sprites on lower layers still draw under it; within one layer the particles draw over the sprites. Particles are drawn in the order the system holds them, without depth sorting, which is what additive effects want.

Set Emitter.Stateless and the system keeps no per-particle state at all: every particle is a closed-form function of the seed, its index in the stream and the clock. It needs no simulation history, but the system still allocates arrays up to Max and buffers for the drawn instances. The effect is identical for the same settings and clock and already runs at time zero with no Prewarm, and SetClock scrubs it to any time without simulating the gap. It suits the effects whose particles never interact: rain, snow, sparks, dust. The cost is per-particle work each frame, so a stateless emitter is slower per particle than a simulated one; Burst, Prewarm, WorldSpace and the radial and tangential accelerations do not apply to it.

Stop fixes the stateless stream's final birth time and lets existing particles age out. Finished checks their lifetimes at the current clock, even before the next draw; Alive reports the last draw's count. SetClock retains the stop time when scrubbing, and Start resets the clock to zero and resumes births.

Clear removes stateful particles without stopping emission. For a stateless emitter it only resets the cached Alive count; the next draw reconstructs the stream. Use Stop and continue advancing time to drain it.

Particles in the 3D scene

Draw3D draws the same system as camera-facing quads in the 3D scene through gfx.DrawParticles3D: smoke over a battlefield, embers above a fire, snow through a forest. SetPlane says where the simulated plane sits in the world, so a particle at (x, y) lands at origin + xAxis*x + yAxis*y; the default puts it in the world's xy plane with the emitter's up (-Y, as on screen) pointing at the world's up. Sizes are then world units.

The particles are drawn over the finished scene and are hidden by the geometry in front of them. The soft argument fades a particle out over that many world units as it approaches the surface behind it, which hides the hard line a quad otherwise cuts where it meets the ground.

g.smoke.SetPlane(lin.V3(0, 0, -4), lin.V3(1, 0, 0), lin.V3(0, -1, 0))
g.smoke.Draw3D(gr, 1.5) // fade over the last 1.5 units

Lights on sprites

SetLights2D places an ambient colour and up to eight Light2D point lights above the sprite plane for the frame, and DrawLit draws a sprite lit by them through a tangent-space normal map uploaded with TextureOptions{Data: true}. Eight is the most the lit shader holds: lights past the eighth are dropped and FrameStats.Lights2DDropped counts them, so pass the lights nearest the action first.

Set Shadows on a light and it is blocked by the occluders the frame adds with AddOccluder2D, which takes a closed polygon in the same units as sprite positions; two points make a single wall. Softness is the width of the shadow's soft edge in view units, 8 by default. Both the lights and the occluders are set every frame, and every lit sprite in the frame sees the same set, so it does not matter whether the occluders are added before or after the draws.

Each shadowed light gets a polar shadow map built on the CPU: 512 directions around the light, each holding the distance to the nearest occluder edge, uploaded as one small texture the lit shader reads. The cost is the occluder edges times the shadowed lights, so a few hundred edges are free; only edges within a light's radius are visited. Add the walls near the player, not the whole level. A frame whose shadowed lights and occluders are the same as the frame before keeps the maps it already has, so a still scene pays for its shadows once.

gr.SetLights2D(gfx.RGB(30, 30, 45),
	gfx.Light2D{Pos: g.player, Radius: 220, Color: gfx.RGB(255, 200, 120), Shadows: true},
	gfx.Light2D{Pos: brazier, Radius: 140, Height: 20, Color: gfx.RGB(255, 140, 60)},
)
for _, w := range g.walls { // a rectangle's four corners
	gr.AddOccluder2D(w.TopLeft(), w.TopRight(), w.BottomRight(), w.BottomLeft())
}
gr.DrawLit(g.wallTex, g.wallNormal, gfx.Sprite{Pos: lin.V2(x, y), Size: lin.V2(64, 64)})

The sprites example draws a lit floor in the corner of the window with a lamp circling three crates that block it.

Render textures

A RenderTexture is an offscreen surface that draws like the screen and is then used like a texture, for minimaps, portraits, mirrors or a whole low-resolution scene. Set RenderTextureOptions.Nearest to keep its pixels sharp when it is scaled up. DrawTo runs a closure with the render texture as the output; it is rendered before the main frame, so the result can be drawn in the same frame.

g.mini, err = ctx.Gfx.NewRenderTextureOptions(320, 180, gfx.RenderTextureOptions{Nearest: true})
g.mini.SetView(320, 180)
// in Draw:
gr.DrawTo(g.mini, gfx.RGB(5, 5, 12), func() {
	gr.SetCamera2D(gfx.Camera2D{Position: g.level.Size().Mul(0.5), Zoom: 0.1})
	gr.DrawTilemap(g.tilemap, 0, 0, gfx.White)
})
gr.ScreenSpace()
gr.Draw(g.mini.Texture(), gfx.Sprite{Pos: lin.V2(ctx.Width-232, 12), Size: lin.V2(220, 124)})

A render texture also gives a game its own post effect: render the whole game into one and draw it back as a single sprite with a sprite shader on that draw, for a CRT curve, a palette swap or anything else the engine's own post pass does not offer. RenderTexture.Read copies the pixels back, and ctx.Screenshot(path) writes the whole frame to a PNG, which is what every example does with -shot.

Post-processing on a 2D frame

A frame with no 3D draws in it goes straight to the screen and skips the post pass, which is what a 2D game usually wants and costs nothing. Set PostSettings.Post2D to send it through the composite instead:

p := gfx.PostSettings{
	Post2D: true, Exposure: 1, Saturation: 1.1, Contrast: 1,
	Bloom: 0.3, BloomThreshold: 0.9, Vignette: 0.3,
	Aberration: 0.6, Grain: 0.03,
}
gr.SetPost(p)

Bloom, the vignette, saturation and contrast, the LUT grade, chromatic aberration, lens distortion, lens ghosts, film grain and FXAA all apply. The effects that need a depth buffer or a velocity buffer do not: ambient occlusion, depth of field, motion blur, temporal anti-aliasing and god rays stay off however they are set.

Two things to know about the mode. Exposure and tone mapping are skipped, so a frame with Post2D on and nothing else turned up comes back with the colours the game drew; that also means bloom needs BloomThreshold below 1 to catch anything, since 2D colours are already inside the displayable range. And Saturation and Contrast are absolute values whose zero drains the frame, so start from gfx.DefaultPost() or write both as 1, the way the example above does. FXAA follows NoAntiAlias as it does in 3D; a game of hard-edged pixel art wants NoAntiAlias: true.

Post applies to everything in the frame, including text and the interface, because there is one image and one grade. The sprites example puts this on the P key.

Shaders

A game's own fragment shader, compiled to SPIR-V offline with bunyip-shader, colours 2D drawing. SetShader or Shaded sets it for a stretch of sprites, Shader.SetUniforms passes a struct of parameters and Shader.SetImage passes up to four extra textures. Everything drawn while one shader is set is one batch as long as nothing else changes. The shaders guide covers writing and building them.

Performance

ctx.Stats and Graphics.Stats report the last frame's cost. Draws2D is the 2D draw calls after batching and Vertices2D the vertices they covered; Draws3D, Instances and Culled are the 3D counterparts. A rising Draws2D for the same scene means state changes are breaking batches: alternating textures, a blend mode toggled per sprite, a clip pushed around each item, a colour matrix set and unset. Pack sprites into one atlas, group draws by texture with layers, and set blend modes and shaders around groups rather than around single draws.

F3 toggles an overlay with the frame time, the update and draw times, the draw counts and any ctx.Profile scopes, refreshed four times a second; Config.Debug shows it from the start. Config.DrawBudget sets the number of draw calls a frame should stay under, and the overlay warns when a frame goes over, so a batching regression shows up as soon as it appears. Sprites outside the 2D camera's view are dropped before they cost anything, as are tilemap cells and 3D meshes, but the game still walks its entity list to issue the draws; in a large world, test against the camera's visible rectangle first so that walk is short too.

view := g.cam.VisibleRect(ctx.Width, ctx.Height).Inset(-64) // margin for big sprites
for _, e := range g.entities {
	if view.Contains(e.pos) {
		gr.DrawRegion(e.region, gfx.Sprite{Pos: e.pos})
	}
}

Debug drawing

DebugText and Debugf put a line of text on screen in the engine's own font with a dark shadow, so you can print a value before any font is loaded; DebugFont returns that font for measuring. For shapes, call StrokeRect and StrokeCircle on a high layer to draw collision boxes and trigger volumes over the scene.

gr.SetLayer(1000)
gr.Debugf(8, 8, "player %.0f,%.0f  draws %d", g.player.X, g.player.Y, ctx.Stats.Draws2D)
for _, s := range g.solids {
	gr.StrokeRect(s.X, s.Y, s.W, s.H, 1, gfx.RGBA(255, 80, 80, 140))
}
gr.SetLayer(0)

The sprites, tiles, tiled, text, vector, particles, roguelike and tetris examples are complete programs for each of these areas, and every one runs with -seconds 3 -shot out.png.