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.