Bunyip a game engine in Go GitHub

3D graphics

The gfx package draws 2D and 3D through one context. This guide covers the 3D half. It works through cameras, geometry, materials, lighting, sky, then culling, grading and profiling. The examples are the fastest reference: viewer orbits a scene or a glTF file, lighting puts every post-processing setting on a slider, materials shows one sphere per feature, terrain is an outdoor scene with billboards, levels of detail and fog, and space and solar are vacuum scenes.

The frame

There is no scene object and no begin call. Everything queued during Draw is submitted when Draw returns, in a fixed order: render textures first, then the shadow atlas, then the scene into a high dynamic range image (sky, opaque meshes, decals, blended and transmissive meshes back to front, debug lines), then the post pass, then 2D over the tone-mapped result in layer and call order.

So 2D always draws over 3D. ctx.Clear is the colour behind everything, though when Light.Background is set the sky or environment map is drawn instead and the clear colour never shows. To draw a HUD or a name plate, make the 2D calls after the mesh calls, and use Project to turn a world point into the view coordinates 2D uses. To put a 3D character on a 2D field, draw the 3D into a RenderTexture and draw that texture as a sprite between the background and foreground layers.

func (g *game) Draw(ctx *engine.Context) error {
	gr := ctx.Gfx
	ctx.Clear = gfx.RGB(10, 12, 18)
	gr.SetCamera(gfx.OrbitCamera(lin.V3(0, 1, 0), g.yaw, g.pitch, 12))
	gr.SetLight(gfx.Light{Direction: lin.V3(-0.4, -1, -0.6),
		Color:   gfx.Color{R: 2.2, G: 2.1, B: 1.9, A: 1},
		Ambient: gfx.Color{R: 0.18, G: 0.2, B: 0.25, A: 1},
		Shadows: true, ShadowDistance: 40})
	gr.DrawMeshAt(g.ship, gfx.Material{Metallic: 1, Roughness: 0.3}, g.shipAt)
	gr.DebugText(10, 10, "hull 100%")
	return nil
}

SetCamera and SetLight apply to the whole current output for the frame. SetPost applies to every output, including render textures; the final settings are used when the frame is submitted. Call each once for its intended output rather than changing it between draws.

Cameras

Camera looks from Position at Target. FovY is the vertical field of view in radians (zero means 60 degrees), Near and Far default to 0.1 and 1000, Up defaults to +Y. Setting Ortho to half the view's height in world units makes the camera orthographic, so distance no longer shrinks things. That is what an isometric strategy view uses.

// Third person, then the same focus seen isometrically.
gr.SetCamera(gfx.Camera{Position: player.Add(lin.V3(0, 3, 8)),
	Target: player.Add(lin.V3(0, 1.6, 0)), FovY: lin.Radians(55), Far: 500})
gr.SetCamera(gfx.Camera{Position: focus.Add(lin.V3(30, 30, 30)), Target: focus, Ortho: 20})

OrbitCamera(target, yaw, pitch, distance) builds an inspector or strategy camera from three numbers you can drive with the mouse. engine.FlyCamera is a free-flying camera for looking around a scene while it is being built. W, A, S and D move, Q and E go down and up, Shift goes faster, and the view turns while the right mouse button is held, or with every movement when AlwaysLook is set and the cursor is captured. LookAt points it at a position, Forward gives its direction.

g.fly = &engine.FlyCamera{Position: lin.V3(0, 5, 20), Speed: 25} // in Init
func (g *game) Update(ctx *engine.Context) error { g.fly.Update(ctx); return nil }
func (g *game) Draw(ctx *engine.Context) error   { ctx.Gfx.SetCamera(g.fly.Camera()); return nil }

Project(p) maps a world point into the 2D view and returns ok false when the point is behind the camera. ScreenRay(x, y) turns a point in the view (the units the mouse reports) into a world ray, and Mesh.Intersect or Model.Intersect report where it hits. Both are on Graphics for use while drawing and on Camera itself, taking the view size, for picking from Update: g.cam.ScreenRay(x, y, ctx.Width, ctx.Height). For a scene backed by physics, phys.Raycast3 casts against colliders instead, which is cheaper and gives you the entity.

ray := gr.ScreenRay(ctx.Input.Mouse())
for _, u := range g.units {
	if hit, ok := u.model.Intersect(u.at.Matrix(), ray); ok && hit.Distance < best {
		best, g.selected = hit.Distance, u
	}
	if x, y, ok := gr.Project(u.at.Position.Add(lin.V3(0, 2.2, 0))); ok {
		gr.DrawText(g.font, u.name, x, y, gfx.White) // name plate in 2D
	}
}

Camera.Frustum(aspect) and Graphics.Frustum() return the volume the camera sees; the culling section uses them.

Meshes

A Mesh is indexed triangle geometry in device memory. NewMesh uploads a slice of Vertex (position, normal, UV, an optional second UV set and an optional colour that multiplies the material's base colour) and a slice of indices.

The shape functions return those two slices without touching the GPU, so you can transform and merge them first: CubeMesh, SphereMesh(rings, segments), PlaneMesh(segments), QuadMesh, CylinderMesh(segments), ConeMesh(segments), CapsuleMesh(rings, segments, halfHeight), TorusMesh(tube, rings, segments) and HeightfieldMesh(heights, cols, rows, cell). HeightfieldMesh turns a grid of heights, its size in samples and the world units between samples into one mesh; for ground a game walks about on, the Terrain type below builds on the same idea and does the chunking and the levels of detail as well.

verts, idx := gfx.HeightfieldMesh(g.heights, cols, rows, 1.0)
for i := range verts { // colour by height and slope: no textures needed
	v := &verts[i]
	v.Color = gfx.RGB(86, 125, 50)
	if v.Normal.Y < 0.75 {
		v.Color = gfx.RGB(110, 105, 100) // cliff
	} else if v.Pos.Y > 6 {
		v.Color = gfx.RGB(235, 240, 245) // snow
	}
}
terrain, err := ctx.Gfx.NewMesh(verts, idx)

Building geometry yourself takes three helpers. TransformVertices returns a copy of a shape moved by a matrix, AppendMesh merges two shapes into one, and ComputeNormals fills in smooth normals for vertices written by hand. FlatShaded splits shared vertices so every triangle keeps its own normal, for the faceted look and for coarse levels of detail.

// A voxel chunk: one mesh for the visible blocks, one draw call.
cube, cubeIdx := gfx.CubeMesh()
var verts []gfx.Vertex
var idx []uint32
for _, b := range chunk.Visible() {
	placed := gfx.TransformVertices(cube, lin.Translate(b.Pos))
	for i := range placed {
		placed[i].Color = b.Color
	}
	verts, idx = gfx.AppendMesh(verts, idx, placed, cubeIdx)
}
chunk.mesh, err = ctx.Gfx.NewMesh(verts, idx)

Mesh.Update(verts, indices) replaces the geometry of a mesh already on the GPU, for a voxel chunk after a block is dug, a terrain edit, or a mesh that grows. Draws already queued this frame keep the old geometry until the frame ends, so an update is safe at any point in Update or Draw. Use it instead of destroying and recreating a mesh. Mesh.Min and Mesh.Max are the bounds in mesh space, Vertices and Indices read the geometry back, and Destroy frees it.

Draw with DrawMesh(mesh, material, model) where model is a lin.Mat4, or DrawMeshAt(mesh, material, transform) when a gfx.Transform is what you have. Draws sharing a mesh and a material are collected into one instanced call, so a thousand asteroids or a forest of identical trunks cost one draw. This happens automatically, but only while the materials stay identical, so prefer vertex colours or one atlas over a material per object.

NewSkinnedMesh takes SkinVertex values with four joint indices and weights, and DrawSkinned(mesh, material, model, joints) draws it with joint matrices the game computed itself, as the lighting example does.

Terrain

NewTerrain(opts) takes a heightfield and does what a game would otherwise write itself: it splits the field into square chunks, builds each chunk's mesh at several resolutions, and DrawTerrain(terrain) queues each chunk at the resolution its distance from the camera deserves. Chunks are ordinary draws, so the frustum and the frame's occluders cull them, and each carries a skirt around its edge deep enough to hide the crack where it meets a coarser neighbour.

TerrainOptions takes the Heights row by row with Cols and Rows, the Cell between samples and the Centre the field sits on. Cols-1 and Rows-1 must be whole multiples of ChunkSize, which is a power of two and 32 by default; Levels is how many resolutions each chunk keeps (4) and LODDistance how far the finest one reaches, each level after covering twice the distance of the one before. Every chunk at every level is uploaded at once, so a terrain costs about a third more device memory than its finest level alone.

The ground is shaded by a built-in terrain shader. Splat is an RGBA image stretched over the whole field whose four channels weight the four tiling Layers textures, each repeating every LayerScale world units with its own LayerRoughness. Give the layers TextureOptions.Repeat, since they tile. SetSplat(img) replaces the weights later, for a map painted from the terrain's own height and slope or repainted as the ground changes, and Shader() reaches the shader to rebind a layer.

Height(x, z) and Normal(x, z) say where the ground is and which way it faces, for scattering trees, dropping items and refusing to build on a slope. Raycast(ray, reach) finds where a ray first goes under the ground, which is what a click that digs needs. Heights() is the terrain's own sample grid: write into it and call Update(minX, minZ, maxX, maxZ) to rebuild the chunks that changed. Bounds, Size, Chunks, Levels, ChunkLevel and ChunkCentre report what it holds and what the last frame drew.

g.ground, err = ctx.Gfx.NewTerrain(gfx.TerrainOptions{
	Heights: heights, Cols: 129, Rows: 129, Cell: 1, ChunkSize: 32,
	Levels: 4, LODDistance: 45,
	Layers: [4]*gfx.Texture{sand, grass, rock, snow},
	LayerScale: [4]float32{6, 5, 4, 7},
})
g.ground.SetSplat(weightsFromHeightAndSlope(g.ground))

// Digging: edit the samples, then rebuild what they cover.
h := g.ground.Heights()
h[z*cols+x] -= 2
g.ground.Update(x, z, x, z)

gr.DrawTerrain(g.ground) // one draw per chunk, culled and refined for you

Models

gltf.Load reads a .gltf or .glb file into a Document of plain Go slices with no GPU involved, and Graphics.LoadModel uploads it: one Mesh per primitive, one Texture per image, materials with the extensions the renderer supports, skins, clips and morph targets. asset.Model does both through the asset package, from a loose directory, a pack file or an embedded FS.

Malformed buffer bounds and animation accessor shapes or key counts return errors from the glTF loader. Translation and scale outputs are three-component vectors, rotations are four-component vectors, and morph weights are scalars; cubic keys include both tangent records. The loader retains the cubic keys' values but discards their tangents, so playback uses linear interpolation for CUBICSPLINE channels. The loader also rejects cyclic hierarchies, repeated children, multiple parents and invalid node or scene references before resolving resources. Scene roots must be parentless and unique within a scene; different scenes may share roots. Deep valid trees have no traversal depth cutoff.

doc, err := gltf.Load("assets/ship.glb")
if err != nil {
	return err
}
g.ship, err = ctx.Gfx.LoadModel(doc) // or asset.Model(ctx.Gfx, g.fs, "ship.glb")

DrawModel(m, world) queues every part under a world matrix and DrawModelAt(m, transform) takes a Transform instead. Model.Min and Model.Max bound the whole model. Use them to set a camera's distance when you do not know the file in advance. Model.Parts is the placed primitives, each a ModelPart with a Mesh, a Name (the name glTF gave its material), a Material and a World matrix.

To draw a model with a material of your own, DrawModelWith(m, world, override) passes each part through the override and draws what it returns, and DrawModelAnimatedWith does the same for a posed one. The override sees the part's index and the part itself, so it can match on the index or on the name, and returning part.Material leaves a part alone. The model's own materials are never changed.

gr.DrawModelWith(g.ship, world, func(i int, p gfx.ModelPart) gfx.Material {
	if p.Name != "hull" {
		return p.Material
	}
	m := p.Material
	m.BaseColor = teamColor
	return m
})

NodeCount, NodeName, NodeIndex and NodeParent walk the node hierarchy by name, so you can find a node such as a gun muzzle and spawn an effect there; Model.NodeMatrix and NodePosition give a node's rest-pose place in model space for a model that is not animated, and AnimPlayer.NodeMatrix its current place for one that is. Model.Destroy frees the meshes and textures together.

For skinning, Model.Clips lists the animation clips, Model.NewAnimPlayer makes an AnimPlayer holding one instance's pose, Play or CrossFade chooses a clip, Advance(dt) runs it, and DrawModelAnimated(model, transform, player) draws the result. Layers, masks, events, root motion, morph targets and node overrides for inverse kinematics are all on the player; the animation guide covers them.

Transforms

The lin package holds the maths types: Vec2, Vec3, Vec4, Mat4 and Quat in float32, column-major, right-handed, +Y up. Values pass by value and every operation returns a new one. lin.Translate, lin.Scale, lin.Rotate(angle, axis) and lin.TRS(t, r, s) build matrices and Mul composes them left to right, so the last applied is written last. Quaternions avoid the gimbal problems of stacked Euler angles. Build them with lin.AxisAngle, lin.FromEuler(yaw, pitch, roll), lin.QuatLookAt and lin.QuatIdentity. lin.Radians converts degrees.

gfx.Transform is a simpler form. It holds a Position, a Rotation quaternion and a Scale, where a zero rotation means none and a zero scale means 1. gfx.At(x, y, z) makes one, Moved, Rotated and Scaled return adjusted copies, Matrix gives the lin.Mat4 and Forward the direction it faces. It is the component physics, animation and the ECS use, so a body's transform is the one you draw with.

gr.DrawMesh(g.rock, rockMat, lin.Translate(pos).Mul(lin.Rotate(yaw, lin.V3(0, 1, 0))))
gr.DrawMeshAt(g.rock, rockMat, gfx.At(0, 1, 0).Rotated(lin.V3(0, 1, 0), lin.Radians(30)).Scaled(0.5))

For hierarchies, put a gfx.Transform on each entity, parent them with ecs.SetParent(w, child, parent), and ecs.WorldMatrix(w, e) composes the chain from the root down. A turret on a hull on a chassis is three entities and one call at draw time.

Materials

Material is metallic-roughness PBR. Texture is the albedo in sRGB, BaseColor multiplies it (zero means white), Metallic runs 0 for a dielectric to 1 for a metal, and Roughness runs from mirror to matte with a zero meaning 0.6. MetalRoughTexture carries roughness in green and metallic in blue as glTF does, NormalTexture is a tangent-space normal map, EmissiveTexture and Emissive make something glow, and OcclusionTexture with OcclusionStrength darkens ambient light in creases (OcclusionUV2 samples it on the second UV set, the lightmap convention). Every texture is optional; the factors alone are a complete material. Gold is {BaseColor: gfx.RGB(240, 200, 120), Metallic: 1, Roughness: 0.15} and a glowing lamp is {BaseColor: gfx.RGB(255, 180, 60), Emissive: 3}.

Each map keeps the filtering and edge handling its TextureOptions gave it, whatever else the material binds: the engine's four samplers (linear or nearest, repeating or clamped) are shared by every material and the map only says which one to read it through. So a nearest-filtered sprite sheet as albedo and a linear, repeating detail map on the same mesh both sample the way they were made.

Transparency has three modes, and they do different things. AlphaCutoff discards fragments below a threshold in both the lit and the shadow pass, giving hard edges and real shadows, which is what leaves, fences and grass need. Blend draws the material after the opaque scene, sorted back to front, for smoke and water, or without sorting at all under PostSettings.OrderIndependent. The alpha of BaseColor, multiplied by the texture and vertex alpha, fades the whole shaded surface, including lighting, emissive and fog. Pass straight colors: BaseColor: gfx.RGB(120, 180, 255).WithAlpha(0.25) contributes a quarter of its shaded color before tone mapping. The engine handles premultiplication for both transparency paths. Without Blend or Transmission, alpha only controls AlphaCutoff. Transmission is refractive glass, below. Alongside them, DoubleSided turns off back-face culling and lights back faces with a flipped normal, which a single-quad leaf needs; Unlit shows the base colour and emissive as they are, for holograms and map markers; NoDepthTest draws over everything already drawn and NoDepthWrite leaves the depth buffer alone, for ghosts and additive effects; and UVTransform, a lin.Affine on the texture coordinates, scrolls a conveyor or tiles a floor in one field.

The layered features come from the matching glTF extensions and cost nothing left at zero. Clearcoat with ClearcoatRoughness adds a varnish lobe for car paint and wet surfaces, Sheen with SheenRoughness adds soft light at grazing angles, which is the fabric look: velvet, felt, brushed cotton, and a good starting point is a sheen colour near the base colour with a SheenRoughness around 0.4. Subsurface, shaped by a ThicknessTexture, lets light through thin parts for leaves, wax and skin. Transmission makes glass and ice. The opaque scene shows through, refracted by IOR (zero means 1.5) across Thickness world units, blurred by the roughness and absorbed towards AttenuationColor over AttenuationDistance, with a TransmissionTexture letting one material hold an opaque frame and glass panes. Transmissive meshes draw after the opaque ones, like blended ones.

floor := gfx.Material{Texture: g.stripes, Roughness: 0.8,
	UVTransform: lin.Translate2(t*0.05, 0).Mul(lin.Scale2(6, 6))}
glass := gfx.Material{Roughness: 0.05, Transmission: 1, IOR: 1.5, Thickness: 0.8,
	AttenuationColor: gfx.RGB(120, 200, 255), AttenuationDistance: 1}
velvet := gfx.Material{BaseColor: gfx.RGB(40, 30, 90), Roughness: 0.9,
	Sheen: gfx.RGB(200, 180, 255), SheenRoughness: 0.4}

Specular and SpecularColor scale and tint what a dielectric reflects, so chalk (Specular: 0.02) and a warm varnish are describable; zero means the plain 1 and white, and metals are unaffected. Iridescence puts a thin film over the surface whose interference turns the hue with the angle: soap bubbles, oil, beetles, tempered steel. Its look is set by IridescenceThickness in nanometres (zero means 400; 100 to 800 is the range that shows colour) and IridescenceIOR (zero means 1.3), and an IridescenceTexture scales the strength by its red channel and the thickness by its green, the two maps glTF packs into one image. Anisotropy stretches the highlight along the surface for brushed metal, hair, satin and vinyl, turned by AnisotropyRotation; the direction comes from the mesh's texture coordinates, so an anisotropic mesh needs UVs but no tangents of its own.

Shells draws the mesh again that many times, each further out along its normals, which is fur, grass, moss and hair. ShellLength is how far the outermost shell stands off in world units (zero means 0.05) and FurTexture is the strand mask: a shell keeps a fragment where the map's red channel is above that shell's height, so a tiled noise image gives strands of different lengths and a UVTransform tiles it. Shells draw after the opaque scene, leave the depth buffer alone and cast no shadow, and each one costs an instance of the mesh, so sixteen shells of one sphere are one draw call.

Stencil, StencilRef and StencilWrite mask one draw against another. A material with StencilWrite: gfx.StencilReplace and a StencilRef of 1 marks the buffer where it draws, and one with Stencil: gfx.StencilEqual and the same reference draws only inside that mark: portals, cutaways, magic windows. The buffer starts each frame at zero, materials that write it are drawn before those that do not so the order the game queues them in does not matter, and the masked draw still has to pass the depth test, so put it in front of its mask. Outline uses the stencil buffer for itself, so a material with an outline ignores the three.

brushed := gfx.Material{BaseColor: gfx.RGB(200, 200, 210), Metallic: 1,
	Roughness: 0.35, Anisotropy: 0.9}
bubble := gfx.Material{BaseColor: gfx.RGB(140, 140, 150), Metallic: 1,
	Roughness: 0.2, Iridescence: 1, IridescenceThickness: 480}
fur := gfx.Material{BaseColor: gfx.RGB(190, 140, 70), Roughness: 0.9,
	Shells: 16, ShellLength: 0.22, FurTexture: g.noise, UVTransform: lin.Scale2(8, 4)}
mask := gfx.Material{StencilWrite: gfx.StencilReplace, StencilRef: 1}
through := gfx.Material{BaseColor: gfx.RGB(255, 120, 40), Stencil: gfx.StencilEqual, StencilRef: 1}

Vertex colours multiply the base colour and need no field at all; glTF files fill them from COLOR_0 and the terrain snippet above writes them by hand. The second UV set comes from TEXCOORD_1. A Shader from NewMeshShader replaces the surface calculation before lighting, for water, dissolves, triplanar mapping and vertex displacement; the shaders guide covers writing one.

A model's materials arrive from its file. LoadModel reads the glTF extensions the fields above come from, including KHR_materials_specular, KHR_materials_iridescence and KHR_materials_anisotropy, and converts the older KHR_materials_pbrSpecularGlossiness workflow to metallic-roughness as it loads, exactly for the factors and through the glossiness channel for the image.

Lighting

SetLight takes one Light for the frame: the directional light (Direction is the direction the light travels, so a sun overhead points down), its Color, and an Ambient term that lights everything evenly when no sky or environment is set. Light colours are linear and are not clamped to 1; a sun is usually above 2.

Shadows renders cascaded shadow maps for the directional light, reaching ShadowDistance world units from the camera (default 60) at ShadowStrength (zero means fully dark). The cascades pack more resolution near the camera, so ShadowDistance is the setting to tune. Keep it as small as the game allows. Forty over a character scene is crisp; two hundred over a whole valley will alias. Cutout materials cast cutout shadows, so a leaf texture throws a leaf-shaped shadow. A caster far above the cascades, a bridge over a street or a cloud over a field, still casts into them: the shadow pipelines clamp depth rather than clip at the cascade's near plane.

Each caster is recorded only into the cascades, spot maps and cube faces its bounds reach, so a scene spread over a large map pays for the maps a mesh can land in rather than for all thirty-one. FrameStats.ShadowDraws counts the instances that went into the shadow atlas this frame.

AddPointLight(pos, color, range) shines in every direction from a point, fading to nothing at range, for torches, muzzle flashes and glowing ore. AddSpotLight(pos, dir, color, range, inner, outer) shines in a cone, full inside the inner angle and fading to nothing at the outer. AddSpot takes a SpotLight value instead, and AddPoint a PointLight; with Shadows set, either casts shadows from its own depth map. A shadowed spot light renders one map; a shadowed point light renders the six faces of a cube, so it costs six depth passes and is the most expensive light there is. Give it to the lamp the player stands under, not to every torch on the wall.

gr.SetLight(gfx.Light{Direction: lin.V3(-0.4, -0.7, -0.35),
	Color:   gfx.Color{R: 3, G: 2.85, B: 2.5, A: 1},
	Shadows: true, ShadowDistance: 60, Background: true,
	Sky:     gfx.Sky{Zenith: gfx.RGB(50, 105, 215), Horizon: gfx.RGB(184, 199, 224)}})
for i, f := range g.fires {
	flick := 0.8 + 0.2*float32(math.Sin(float64(t)*9+float64(i)))
	gr.AddPointLight(f, gfx.Color{R: 4 * flick, G: 2.2 * flick, B: 0.8 * flick, A: 1}, 12)
}
gr.AddSpot(gfx.SpotLight{Position: towerTop, Direction: beam, Range: 60,
	Color:      gfx.Color{R: 9, G: 8.5, B: 6, A: 1},
	InnerAngle: lin.Radians(14), OuterAngle: lin.Radians(28), Shadows: true})
gr.AddPoint(gfx.PointLight{Position: lampPos, Range: 14,
	Color: gfx.Color{R: 6, G: 5.4, B: 4, A: 1}, Shadows: true})

Lights are clustered. The view is cut into a grid of sixteen tiles across, nine down and twenty-four slices into the distance, spaced exponentially, and each frame's lights are sorted into the clusters they reach. A fragment loops over its own cluster's lights alone, so a scene can add hundreds without every one costing every pixel, and a light with a small range costs only the part of the view it lights. Give a light the smallest Range that looks right: the range is what decides how much of the grid it lands in.

Plan around the limits. A frame keeps its first MaxLights (1024) point and spot lights, gives shadow maps to the first MaxSpotShadows (4) spot lights that ask and cube maps to the first MaxPointShadows (4) point lights; the rest shine without. One cluster keeps 64 lights, so a light past that in a crowded part of the view does not light it. Sort by distance to the camera and add the nearest first; FrameStats.LightsDropped counts the lights a frame refused and FrameStats.Lights the ones it kept, so a scene can tell when it went over. The cascades, the spot maps and the cube faces all share one depth atlas, so shadows cost one texture binding however many lights cast them.

For image-based lighting, NewEnvironment turns an equirectangular panorama into a light probe and NewEnvironmentHDR does the same for a floating-point one, keeping its range. DecodePanorama reads the bytes of whichever kind you have: an OpenEXR file (DecodeEXR), a Radiance .hdr file (DecodeHDR), or an ordinary image, whose sRGB colours it converts. The EXR reader takes scanline files with half or float channels, uncompressed or compressed with RLE, ZIPS or ZIP, and refuses tiled, deep, multi-part and PIZ, PXR24, B44 or DWA files with an error that names what the file is. EnvironmentOptions.Intensity scales it and Size sets the cube map's side in texels (default 128). The prefilter for every roughness runs on all cores, and the image types Go's decoders return are read straight from their pixels. Set it as Light.Environment and it replaces the ambient and the sky. Metals reflect it, rough surfaces take its tint from every direction, and Light.Background draws it behind the scene. Environments hold GPU memory; Destroy releases it early. Graphics releases remaining environments when the engine closes.

panorama, err := gfx.DecodePanorama(data) // .exr, .hdr, .png or .jpg
if err != nil {
	return err
}
g.env, err = ctx.Gfx.NewEnvironmentHDR(panorama, gfx.EnvironmentOptions{Intensity: 1.5, Size: 256})
light.Environment, light.Background = g.env, true

Global illumination

One environment lights the whole scene, which is right outdoors and wrong indoors: a ball in a red room reflects the sky. Three things give parts of a scene their own light. A reflection probe is what a place reflects, a light probe grid is what a place is lit by, and screen-space reflections are what the screen already shows. They stack: probes for reflections, a grid for the diffuse light, and the screen pass on top of both.

ReflectionProbe captures the scene from a point into a cube map. Position is where it is captured, Extent the half-size of the box it covers (or Radius for a sphere probe), Resolution the cube face size in texels (default 64) and Intensity a multiplier. BakeProbe(probe, scene) renders six faces from that point and prefilters them for every roughness; the scene function queues the draws and the light the bake sees, exactly as Draw would. It runs once for each face, so it must queue the same scene every time. Baking submits its own command buffer and waits for it once for all six faces, so call it from Init or Update, never from Draw, and call it again when the room it holds has changed. AddProbe adds a baked probe to a frame the way AddPointLight adds a light.

A draw reflects the probe whose volume holds its centre; everything outside every probe keeps Light.Environment or the Sky. Of two probes holding a draw, the one holding it more firmly wins, and the smaller of two that hold it equally. Only one cube map is bound per draw, so two probes are never blended with each other: Margin fades a probe's reflection towards the frame's average environment over the last few units inside its volume, which is what stops a ball popping as it leaves a room. BoxProjection reflects a box probe's walls at the place the walls are rather than at infinity, so a floor mirrors the wall it faces. A frame keeps its first MaxProbes (8) probes and counts the rest in FrameStats.ProbesDropped. A probe owns GPU memory; Destroy releases it early. Graphics releases its remaining GPU resources at shutdown.

LightProbeGrid is the diffuse half: a lattice of Counts cells from Origin every Spacing units, each holding the light arriving at it as nine spherical harmonics. BakeLightProbes(grid, scene) renders a small cube at every cell (Resolution, default 16, is enough because harmonics keep only the low frequencies) and projects what it saw, four cells to a submission and one wait. SetLightProbes gives a frame a baked grid, which replaces the single ambient term where it reaches, interpolated between the eight cells around each fragment and faded back to the environment over the outer half cell. A grid holds its harmonics in ordinary memory and needs no Destroy; it is at most 4096 cells.

PostSettings.Reflections turns on screen-space reflections, 0 (off, the default) to 1. Smooth surfaces trace a ray through the depth buffer and show what the screen already holds: the bright box standing on a polished floor, a sign over wet asphalt. ReflectionRoughness is the roughness a surface stops reflecting the screen at (default 0.35), ReflectionDistance how far a ray travels in world units (default 30) and ReflectionSteps how many samples it takes (default 32). Where a ray leaves the screen or hits nothing the surface keeps its probe or environment reflection, so the two fit together rather than adding up. What is behind the camera or hidden behind something nearer is not on the screen and so cannot be reflected; a probe is what fills those in.

// In Init: one probe for the room, a grid across its floor.
g.probe = &gfx.ReflectionProbe{Position: lin.V3(0, 1.8, 0),
	Extent: lin.V3(9, 2.5, 9), Margin: 1, Resolution: 96, BoxProjection: true}
if err := ctx.Gfx.BakeProbe(g.probe, func() { g.drawRoom(ctx.Gfx) }); err != nil {
	return err
}
g.grid = &gfx.LightProbeGrid{Origin: lin.V3(-6, 0.9, -6),
	Spacing: lin.V3(6, 2, 6), Counts: [3]int{3, 2, 3}}
if err := ctx.Gfx.BakeLightProbes(g.grid, func() { g.drawRoom(ctx.Gfx) }); err != nil {
	return err
}

// In Draw: add them to the frame, and reflect the screen off the floor.
p := gfx.DefaultPost()
p.Reflections = 0.9
gr.SetPost(p)
gr.AddProbe(g.probe)
gr.SetLightProbes(g.grid)

Bakes are the expensive part: a probe is six scene renders and a prefilter, a grid is six little renders a cell. Bake at load, or when a level changes, and keep what you baked. Both bakes draw the scene through the same code the frame does, so light the bake the way the frame is lit or the probe will disagree with the screen. The probes example puts all three on checkboxes.

Sky, fog and atmosphere

Sky.Space adds a distant image environment behind the procedural sky. Create it with NewEnvironmentHDR and keep Light.Background enabled. The same environment intensity controls its background radiance, diffuse contribution and reflections. Atmospheric optical depth attenuates distant light while the foreground atmosphere and sun disc remain visible; the solid planet occludes the distant sky. It works for stars, distant nebulae and other image backgrounds without replacing the local atmosphere. A cube size of 512 or 1024 preserves small background details better than the default 128, at greater preparation time and GPU memory cost. Light.Environment still replaces the entire sky when set and takes precedence over Sky.Space. The environment is borrowed by the sky and can be released with Destroy when no longer needed.

Light.Sky is a procedural environment. It takes an Up axis, Zenith, Horizon and Ground colours, and how much air there is. It needs no image, costs nothing to change every frame, and lights the scene the way an environment map does. With Light.Background the sun's disc, sized by SunSize and coloured by Sun, its haze and the stars are drawn behind the scene.

Vacuum sets how thin the air is, which is what a space game needs. At 0 the air is full. Raising it towards 1 fades the sky to black while Stars come out, and the ground half stays, so a ship can climb from a runway to orbit with no seam. Point Up away from a nearby planet and set Ground to that planet's colour to light the ship's night side with planet-shine.

Sky.Atmosphere computes the sky instead of describing it. Set its Height to how deep the air is in the game's own units and the sky above the horizon becomes single-scattered sunlight, Rayleigh from the air and Mie from haze, in place of Zenith and Horizon: blue overhead at noon, orange and then red along the horizon as the sun drops, dark once the sun is under it, and thinner as Altitude climbs out of the air, so a plane can fly from a runway to space with no seam. Ground still lights the half below the horizon, and Vacuum still scales the result. Every other field has an Earth-like default scaled to Height, so gfx.Atmosphere{Height: 3000} is a whole sky; PlanetRadius (a hundred times Height by default) sets how far the horizon is, Rayleigh and Mie are the scattering per world unit at the ground, Forward is how tight the glare around the sun is, and Intensity is the sunlight the scattering divides up.

Height is the one number to think about, because it fixes the scale of everything else: the air is as deep as the number says in world units, so a scene a hundred units across under a Height of 60 is looking through a hundred kilometres of air and washes out, while the same scene under 3000 has crisp distance and a sky that still works. Pick it by how far away things should start taking the air's colour, not by the size of a real planet. The model is integrated per pixel, eight samples along the view ray and four towards the sun at each; there is no table to precompute, load or keep in step, and the same function runs on the CPU in Go to project the ambient light onto spherical harmonics. Those harmonics are reprojected when the sun or the altitude moves far enough to matter, not every frame.

Light.Fog fades geometry into a colour with distance, the cheapest way to give a scene depth and hide the far plane. Linear fog ramps from Start to full at End; exponential fog thickens with Density; when both are set the denser wins. Height and HeightFalloff add ground fog, full at and below Height and thinning above it along the world's y axis, which puts mist in a valley without touching the hilltops. The sky is not fogged, so outdoors pick a fog colour close to the horizon's or the join will show.

With an atmosphere the fog has help: distant geometry is dimmed by the air in front of it and takes the light that air scatters, from the same model, after the fog is applied. That is aerial perspective, and it is what makes far hills go pale and blue and the ones at sunset go pink. It costs nothing to ask for beyond the atmosphere itself, so a scene with one often wants Fog only for the valley mist.

// In orbit, the night side lit by the planet below.
sky := gfx.Sky{Up: planetUp.Mul(-1), Vacuum: 1, Stars: 1,
	Ground: gfx.Color{R: 0.2 * k, G: 0.3 * k, B: 0.45 * k, A: 1}}
gr.SetLight(gfx.Light{Direction: sunDir, Sky: sky, Background: true,
	Color: gfx.Color{R: 2.4, G: 2.2, B: 1.9, A: 1}})

// On the ground: air on the horizon, mist in the valley.
cam := gfx.OrbitCamera(lin.V3(0, 2, 0), yaw, pitch, dist)
light.Sky = gfx.Sky{Ground: gfx.RGB(76, 82, 64),
	Atmosphere: gfx.Atmosphere{Height: 3000, Altitude: cam.Position.Y}}
light.Fog = gfx.Fog{Color: mist, Start: 45, End: 200, Height: 0.8, HeightFalloff: 0.4}

Billboards, labels and marks on the world

DrawBillboard puts a textured quad in the scene that turns to face the camera. Upright turns it about the world's up axis only, so a tree stays vertical when the camera looks down; Offset moves the quad in its own plane in units of its size, so (0, 0.5) stands the sprite on the ground; Lit shades it with the scene's lights; Cutout gives it hard edges that write depth and cast shadows; OnTop draws it over everything; Region takes a rectangle of an atlas. Billboards go through the mesh path, so many with one texture become one instanced draw, and five hundred trees are one call. DrawText3D(font, text, pos, scale, color, onTop, opts) draws a line of text the same way, centred on pos, with scale in world units per view unit of the font.

For the many small quads an effect needs rather than the few a scene places by hand, DrawParticles3D(tex, quads, opts) draws a whole slice of camera-facing quads as one instanced call: smoke, embers, snow, magic. It runs over the finished scene, after decals, so the geometry in front hides them, and opts.Soft fades a particle out over that many world units as it nears the surface behind it, which hides the hard line a quad otherwise cuts where it meets the ground. opts.Blend picks the blend mode. The particles are neither lit nor depth sorted against each other, so unlit and additive effects suit them. The particle package fills the slice: GPUSystem.Draw3D simulates an Emitter and hands the result straight to this call. See the 2D graphics guide for the emitter.

DrawDecal(tex, box, tint) projects a texture onto whatever geometry lies inside a box, for bullet holes, blood, footprints and road markings. The box matrix maps the unit cube to the world, the texture projects along the box's y axis with x and z spanning the image, and it fades on surfaces facing away. Two material fields mark a mesh rather than the world. Outline draws a silhouette line of that many pixels in OutlineColor through the stencil buffer, and XRay tints the parts of a mesh hidden behind other geometry, so a selected unit shows through a wall.

gr.DrawBillboard(gfx.Billboard{Texture: g.tree, Position: p, Size: lin.V2(2, 3),
	Offset: lin.V2(0, 0.5), Upright: true, Lit: true, Cutout: true})
gr.DrawText3D(g.font, "Watchtower", top, 0.05, gfx.White, false, gfx.TextOptions{})
gr.DrawDecal(g.splat, lin.Translate(hit.Point).Mul(lin.Scale(lin.V3(2, 1, 2))), gfx.RGB(120, 20, 20))
sel := gfx.Material{BaseColor: gfx.RGB(90, 200, 120), Roughness: 0.5,
	Outline: 3, OutlineColor: gfx.White, XRay: gfx.RGBA(255, 60, 60, 160)}

Culling and levels of detail

Every mesh draw is tested against the camera's frustum and skipped when its bounds are outside; culled draws still cast shadows, which is why a tree behind the camera can still darken the road. A static mesh is bounded by the box its vertices fill. A skinned mesh keeps a box per joint over the vertices weighted to it, and each frame the pose's joint matrices move those boxes and the union bounds the draw, so a limb that swings clear of the bind pose is still drawn.

Two cases need the game's help. A mesh whose drawn shape leaves its geometry takes Mesh.SetBounds(min, max) to say the box it stays inside, in mesh space; Mesh.Bounds() reads the bounds back, and Update leaves bounds given by hand alone. A material shader with a vertex program can put a vertex anywhere, so draws using it are never culled until Shader.VertexBounds says how far the program moves one, as a multiple of the mesh's bounding radius; culling then grows the radius by 1 + VertexBounds.

// A flag whose shader ripples it by a quarter of its own size.
g.flagShader.VertexBounds = 0.25
// A billboard grass mesh the shader bends and scatters over its cell.
grass.SetBounds(lin.V3(-2, 0, -2), lin.V3(2, 3, 2))

Engine culling still costs the work of building the draw, so a game with chunks, regions or a crowd should test them itself first. Graphics.Frustum() gives the current camera's frustum for the current aspect, Camera.Frustum(aspect) gives one for any camera, and gfx.FrustumOf(viewProj) builds one from a matrix; ContainsPoint, ContainsSphere(centre, radius) and ContainsBox(min, max) are the tests.

The frustum only knows what is outside the view. To skip what is inside it but hidden, mark the geometry that blocks the view with AddOccluder3D(mesh, model) or AddOccluder3DAt(mesh, transform): a wall, a hill, a building's shell. Each frame the engine rasterises the occluders into a small depth buffer on the CPU and culls every draw whose bounding sphere lies entirely behind it. FrameStats.Occluded counts them, and they still cast shadows like any culled draw. Adding an occluder does not draw it, so draw the mesh too, or add a coarse box in place of geometry drawn in detail.

Occluders must be opaque and closed enough that nothing shows through their triangles, so a fence whose gaps are a cutout texture is a bad one. Keep them few and low-poly, since every triangle is rasterised on the CPU and a mesh with more than MaxOccluderTriangles is ignored. SetOcclusionSize(width, height) sizes the buffer, 256 by 144 by default: a gap narrower than one of its pixels counts as covered, so raise it when a real gap is being missed and lower it when the test costs more than it saves. Fifty box occluders and a thousand draws cost around 170 microseconds a frame at the default size.

// The castle wall hides most of the town behind it.
gr.AddOccluder3DAt(g.wallBox, g.wallAt)   // a coarse box, not the wall's own mesh
gr.DrawMeshAt(g.wall, stone, g.wallAt)

Both tests are per draw, so a level of ten thousand rocks, crates and lamp posts still costs ten thousand of them. NewStaticBatch(items) takes a slice of BatchItem (a mesh, a material and a model matrix, as DrawMesh takes) and builds a bounding volume hierarchy over them once; DrawBatch(batch) then tests the hierarchy rather than the items and queues only what survives. A subtree behind the camera or behind an occluder is rejected at one node, so the ten thousand cost a few dozen box tests, and FrameStats.CullTests counts them. The items that come through are ordinary draws, instanced, sorted, lit and shadowed like any others. A rejected subtree is walked again against the frame's shadow maps, so an item the camera cannot see still casts its shadow into the view, as a culled DrawMesh draw does. Ten thousand cubes along a strip most of which is behind the camera fall from 220 microseconds of culling a frame to under two.

A batch is for geometry that never moves: the hierarchy is built from the models given and is not rebuilt, so anything that moves belongs in DrawMesh. Rebuild it if mesh geometry or bounds change, and include vertex-shader displacement in the mesh bounds before building it. It does not own its meshes or textures, which are destroyed as usual, and Len and Bounds report what it holds.

var items []gfx.BatchItem
for _, p := range level.Props {
	items = append(items, gfx.BatchItem{Mesh: p.Mesh, Material: p.Mat, Model: p.At.Matrix()})
}
g.props = ctx.Gfx.NewStaticBatch(items) // once, at load

gr.DrawBatch(g.props) // every frame

NewLOD(meshes, distances) takes meshes from finest to coarsest and the camera distances at which each hands over to the next, so three meshes take two distances. A nil last mesh draws nothing beyond the last distance, so distant scenery disappears instead of shimmering. DrawLOD(lod, material, model) and DrawLODAt(lod, material, transform) pick by the camera's distance to the model's origin; LOD.Pick lets you choose the level yourself. Graphics releases the meshes at shutdown; walk LOD.Levels and destroy them when unloading the level earlier.

The coarsest level of all is an impostor: the model baked into pictures of itself. BakeImpostor(model, opts) renders the model from a ring of directions around it into one atlas texture, and DrawImpostor(impostor, pos, yaw, tint) draws the view nearest the camera as a cutout billboard, so a distant tree costs one quad and no vertex work. Set Impostor.Distance and call DrawModelImpostor(model, impostor, transform) to draw the model up close and the impostor beyond. Impostors of one model share an atlas, so a forest of them is one instanced draw.

ImpostorOptions chooses Views (8 by default, at most MaxImpostorViews), Resolution in pixels per view (128), the Pitch each view looks down from (15 degrees, so match it to the camera's usual elevation) and the Light to bake under. The bake fixes its lighting into the atlas the same way for every view, so an impostor does not turn its shading as the sun moves; keep them far enough away that this does not read. It runs a frame of its own and reads the views back, so call it from Init or Update, never from Draw.

// In Init: pines beyond forty units become one quad each.
g.pineFar, err = ctx.Gfx.BakeImpostor(g.pine, gfx.ImpostorOptions{Views: 12, Resolution: 96})
g.pineFar.Distance = 40

// In Draw:
for _, t := range g.forest {
	gr.DrawModelImpostor(g.pine, g.pineFar, t)
}
// A fine rock near, a faceted one far, nothing beyond seventy units.
fine, _ := ctx.Gfx.NewMesh(gfx.SphereMesh(16, 32))
coarse, _ := ctx.Gfx.NewMesh(gfx.FlatShaded(gfx.SphereMesh(5, 8)))
g.rocks = gfx.NewLOD([]*gfx.Mesh{fine, coarse, nil}, []float32{25, 70})

fr := gr.Frustum()
for _, c := range g.chunks {
	if fr.ContainsBox(c.Min, c.Max) {
		gr.DrawMesh(c.mesh, chunkMat, lin.Translate(c.Origin))
	}
}

Post-processing

SetPost replaces the settings the post pass uses on the 3D scene. DefaultPost returns the defaults and Post reads back the current ones, so you can change one field without restating the rest. ConfigurePost makes that edit in a closure, keeping other settings and preserving intentional zero values:

gr.ConfigurePost(func(p *gfx.PostSettings) {
	p.Bloom = 0.3
	p.Saturation = 0 // grayscale
})

It commits the edited copy on normal return; a panic does not commit it.

Exposure multiplies the scene before tone mapping. Use it when a scene is too dark or blown out. Bloom is the strength of the glow around bright pixels and BloomThreshold the luminance where it starts; zero bloom skips the passes. Reflections and the three Reflection* fields are screen-space reflections, which the global illumination section covers. AmbientOcclusion is screen-space occlusion darkening creases and contact points, 0 to 1 with a default of 0.6, over OcclusionRadius world units, and ShowOcclusion displays the occlusion buffer instead of the scene while you tune it. Vignette, Saturation and Contrast are the grade, and NoAntiAlias skips the FXAA pass.

Samples multisamples the scene pass: 1 (the default), 2, 4 or 8, clamped to what the GPU supports, which Graphics.MaxSamples reports. Every triangle edge is then resolved from that many coverage samples, which is the one anti-aliasing that does not blur the picture, at the cost of that many times the scene's colour and depth memory and bandwidth. Shading still runs once a pixel, so the cost is in the attachments rather than in the fragment programs. Set NoAntiAlias with it: FXAA over an already resolved image only softens it again. TemporalAA is the other choice and resolves the same edges over time, so the two are alternatives rather than a pair: leave Samples at 1 when it is on.

p := gfx.DefaultPost()
p.Samples, p.NoAntiAlias = 4, true
gr.SetPost(p)

Changing Samples rebuilds the scene targets and the pipelines that draw into them at the start of the next frame, so it belongs in a settings menu rather than in a per-frame update. Everything after the scene pass reads the resolved single-sample images, so ambient occlusion, decals, reflections and the transmission snapshot behave the same at every sample count; the depth they read is sample zero of each pixel, which is exact except on an edge, where it is one of the surfaces covering it. The order-independent transparency pass keeps its own two images at one sample and tests against that resolved depth, so translucent edges stay as hard as they are at one sample while everything opaque smooths.

PostSettings.LUT grades the finished colours through a lookup table. NeutralLUT(n) returns the identity strip of n slices (16 or 32 are usual); paste it into a corner of a screenshot, grade that in an image editor, crop the strip back out and load it with NewLUT, and every frame gets the same grade, blended in by LUTStrength.

p := gfx.DefaultPost()
p.Exposure, p.Vignette = 1.2, 0.25
p.Bloom, p.BloomThreshold = 0.3, 1.1
p.AmbientOcclusion, p.OcclusionRadius = 0.7, 0.8
p.LUT, p.LUTStrength = g.coldGrade, 0.8
gr.SetPost(p)

Post applies to the 3D scene, not to the 2D drawn over it. A HUD is not bloomed, tone-mapped or graded. That keeps text readable, and it explains why a sprite over the scene can look brighter than the scene does. A frame with no 3D draws in it can go through the composite as well; see 2D graphics. The lighting example puts all of these on sliders.

Temporal anti-aliasing

TemporalAA averages each frame with the ones before it. The projection moves by a fraction of a pixel each frame along a Halton sequence, so successive frames sample a different point inside every pixel, and the resolve blends the last resolved frame into this one after reprojecting it. TemporalBlend is how much of the new frame goes in, 0.02 to 1; zero means 0.1, and lower is steadier and softer. It replaces FXAA while it is on, so NoAntiAlias does not apply.

Reprojection needs to know where every pixel was last frame. The camera's part comes from the depth buffer. An object's own motion has to be told, because immediate-mode drawing has no identity across frames to look a previous transform up by:

gr.DrawMeshMoved(ship, shipMat, at(now), at(before))

DrawMeshMoved, DrawSkinnedMoved, DrawModelMoved and DrawModelAnimatedMoved each take the transform the draw had last frame; the two model forms take a MaterialOverride after it, as DrawModelWith does, and nil draws the file's own materials. Plain DrawMesh says the mesh did not move, which is what a static scene wants and what costs nothing: a frame where nothing moved draws nothing into the velocity buffer. A moving mesh drawn through DrawMesh still resolves, because the neighbourhood clamp will not let the history stray far from the pixels around it, but it softens while it moves. A skinned mesh carries its model matrix's motion and not its pose's, so a character walking across the screen reprojects and an arm swinging in place does not.

Depth of field, motion blur and god rays

FocusDistance is how far in front of the camera the image is sharp, in world units; zero turns depth of field off. FocusRange is how far either side of it stays sharp before the blur grows, and how far past that the blur reaches its full width; zero means a quarter of the focus distance. BokehRadius is that full width in pixels of a 1080-high frame (zero means 12) and BokehSamples how many taps the disc gathers (zero means 16). A wide bokeh wants more of them: the disc is the same in every pixel, so too few taps over a large radius leave a visible pattern on fine detail. Turning the disc per pixel would break that into noise, but it scatters the texture fetches and costs about three times as much, so raising BokehSamples is the better trade.

MotionBlur smears each pixel back along the way it moved since the last frame, 0 to 1; zero is off, and MotionSamples is how many taps it takes (zero means 8). It reads the same velocity buffer, so an object blurs along its own path only when it was drawn with one of the Moved calls; the camera's motion always works.

GodRays is the strength of the shafts the directional light throws past an occluder. Each pixel walks towards the sun's place on screen through the depth buffer, gathering the steps where the sky shows through: GodRayDecay is how fast a shaft fades along its length (zero means 0.96), GodRayDensity how far towards the sun the walk goes (zero means 0.6) and GodRaySamples how many steps it takes (zero means 32). The pass is skipped when the sun is beside or behind the camera, and an orthographic camera has no sun position to walk towards, so it gets none.

The lens

Four settings model the camera rather than the scene, and all four happen inside the composite, so together they cost about as much as a fifth of the bloom.

Aberration splits the red and blue channels apart towards the edge of the frame; 1 is about three pixels at the edge of a 1080-wide frame and 0.5 is a subtle fringe. Distortion bends the image about the centre, positive for barrel and negative for pincushion. Ghosts draws the bright pass mirrored through the centre a few times over, the reflections a lens makes of a bright light, and needs Bloom above zero because that is the image it reads. Grain adds per-pixel noise that moves each frame; 0.05 is subtle. Every one of them is off at zero.

p := gfx.DefaultPost()
p.TemporalAA, p.TemporalBlend = true, 0.1
p.FocusDistance, p.FocusRange, p.BokehRadius = 12, 4, 16
p.MotionBlur = 0.5
p.GodRays = 0.8
p.Aberration, p.Distortion, p.Grain = 0.6, 0.15, 0.03
gr.SetPost(p)

Roughly, at 1280 by 720 on an RTX 4090, over a frame that costs 40 microseconds with none of them on: the lens effects together 3, bloom and FXAA 14 each, god rays 20, motion blur 24, ambient occlusion 32, temporal anti-aliasing 35 and depth of field 42. BenchmarkPost in the gfx package measures them; the numbers are best of five over a scene of two dozen instanced cubes, so they are the passes' own cost rather than a game's, and they move by a few microseconds between runs.

Render textures

NewRenderTexture(w, h) makes an offscreen surface; NewRenderTextureOptions adds Nearest for a low-resolution scene that should stay sharp when scaled up and Repeat for one that tiles. DrawTo(rt, clear, draw) runs the closure with that surface as the output. Every Draw*, SetCamera and SetLight call inside it lands on the texture, with its own camera and its own lighting. It renders before the main frame, so the result can be drawn in the same frame. RenderTexture.Texture() is the texture to draw with, SetView sets its 2D coordinate space, and Read copies the pixels back.

RenderTextureOptions also chooses what the surface is made of. Format picks the colour format: ColorScreen matches the window, eight bits a channel with sRGB encoding, and is the default; ColorHDR is sixteen-bit floating point RGBA, so values above 1 survive into whatever reads the texture; ColorMask is one eight-bit channel, for a mask, a height field or a coverage buffer. NoDepth leaves out the depth buffer of the surface's own pass, which nothing tests against, and saves the memory; a 3D scene drawn into it still works, because the scene has a depth buffer of its own. Samples multisamples the surface itself, so every edge drawn into it, 2D paths and triangles included, is resolved from that many coverage samples; it is separate from PostSettings.Samples, which multisamples the 3D scene behind the composite, here as on screen.

Read decodes whatever format the surface was made with: a ColorHDR surface comes back encoded the way the screen is, so values above 1 clip, and a ColorMask surface comes back as grey. ReadDepth returns the depth the last 3D scene left, one float a pixel from the top-left corner, 0 at the near plane and 1 at the far plane. Both wait for the GPU and copy the whole image to the host, so they belong in tools, tests and one-off queries rather than in a frame.

// A mask for a fog-of-war lookup: one channel, no depth, cheap to sample.
mask, err := gr.NewRenderTextureOptions(256, 256, gfx.RenderTextureOptions{
	Format: gfx.ColorMask, NoDepth: true,
})
// A portrait with smooth edges on its own vector frame.
portrait, err := gr.NewRenderTextureOptions(512, 512, gfx.RenderTextureOptions{
	Samples: 4,
})
// A minimap: the same world from straight above, drawn as a sprite.
gr.DrawTo(g.minimap, gfx.RGB(5, 5, 12), func() {
	gr.SetCamera(gfx.Camera{Position: lin.V3(0, 400, 0.01), Target: lin.V3(0, 0, 0), Ortho: 250})
	gr.SetLight(light)
	g.drawWorld(gr)
})
// ... the main scene ...
gr.DrawTexture(g.minimap.Texture(), ctx.Width-236, 16)

The same call makes a portal (a camera at the far end of the pair, the texture as the portal surface's albedo), a mirror, or a character portrait. To put a 3D character on a 2D field, render the character with a transparent clear, then draw that texture as a sprite on its layer.

Debug drawing

DrawLine3D(a, b, c) draws a line in the world, DrawWireBox(min, max, c) outlines an axis-aligned box, DrawWireCube(m, c) outlines the unit cube under a matrix (the shape of a phys.Box3 collider), DrawWireSphere(centre, radius, c) a sphere, DrawWireFrustum(cam, aspect, c) another camera's view volume, and DrawAxes(m, size) a transform's three axes in red, green and blue. All of them ignore depth, so they show through geometry. DebugText(x, y, text) and Debugf print in the engine's own font with no font to load, and DebugText3D(p, text) puts that text at a world point. gfx.FrustumCorners(viewProj) returns a view volume's eight world-space corners.

gr.DrawWireBox(body.Min, body.Max, gfx.RGB(0, 255, 0))
gr.DrawLine3D(muzzle, muzzle.Add(dir.Mul(50)), gfx.RGB(255, 80, 0))
gr.DrawAxes(t.Matrix(), 1)
scout := gfx.Camera{Position: lin.V3(30, 10, 25), Target: lin.V3(10, 0, 5), Far: 30}
gr.DrawWireFrustum(scout, 16.0/9, gfx.RGB(255, 230, 50))
gr.DebugText3D(scout.Position, "scout")

Performance

ctx.Stats and Graphics.Stats() report the last frame as a FrameStats: Draws3D is mesh draw calls after instancing across all passes, Instances is mesh instances in the main pass, ShadowDraws is the instances recorded into the shadow maps, Culled is the draws skipped as out of view, Occluded how many of those an occluder hid rather than the frustum and CullTests how many bounding volumes were tested to decide, Lights and LightsDropped are the point and spot lights the frame kept and threw away, ProbesDropped is the reflection probes added past MaxProbes, Draws2D and Vertices2D cover the sprite stream, and Waits counts the times the frame stopped for the GPU to go idle, which a running game keeps at zero. The F3 overlay shows them and Config.DrawBudget warns when a frame goes over a number you set.

s := gr.Stats()
gr.Debugf(10, 10, "draws %d  instances %d  shadow %d  culled %d",
	s.Draws3D, s.Instances, s.ShadowDraws, s.Culled)

Graphics.Resources() lists every texture, mesh, model, font, render texture and environment the context has made and not destroyed, with sizes and an estimate of the GPU memory each holds: what to print when a scene is using more memory than it should, or to check that a level teardown freed what it loaded. The debug console shows the same list with a running total.

On MoltenVK devices using Vulkan's portability subset, each index buffer gets a separate allocation of the size required by the driver, bound at offset zero. This compatibility path prevents indexed geometry from disappearing on affected drivers; vertex buffers and other small resources still share memory blocks. Each live index buffer counts toward the device's maxMemoryAllocationCount, including replaced geometry waiting for the GPU to finish. Many small meshes or repeated CPU morph uploads can therefore reach the allocation limit even when their total byte size is modest. Reuse geometry where possible, destroy resources when finished, and handle mesh creation and update errors. The renderer returns an error before exceeding the reported allocation limit.

Draw calls cost more than triangles. A high Draws3D next to a low Instances means batching is breaking. Merge static geometry with AppendMesh or share a material across a crowd to collapse the calls. After that, look at the costs in order: shadows, where halving ShadowDistance doubles the effective resolution at no cost, every shadowed spot light is another pass and every shadowed point light is six; lights, which are per-fragment work over the part of the view their range reaches; transmission, which copies the scene before drawing transmissive meshes; post, where ambient occlusion and bloom are full-screen passes a zero turns off; multisampling, which multiplies the scene's colour and depth bandwidth by its sample count; and render textures, which are whole extra frames.

Transparency sorts by distance to the camera per draw, not per triangle, so two blended meshes that interpenetrate pick an order and keep it, and a large one sorted by its origin can land on the wrong side of a small one. PostSettings.OrderIndependent is the way out: it accumulates every translucent fragment with a weight that favours the nearer one and resolves them in one pass, so a crossing looks right on both sides without any sorting at all. It costs two images the size of the frame and one pass, and it is an approximation: a scene of many overlapping layers comes out slightly flatter than compositing them in order would. Where it is off, prefer AlphaCutoff where hard edges are acceptable, keep blended geometry convex and small, and use NoDepthWrite for additive effects where order does not matter.

Transmission keeps the sorted path either way, because refraction reads the scene behind the surface and so has to be drawn after it. A frame can have both: the copy of the scene that glass and screen-space reflections read is taken from the opaque draws, the order-independent pass resolves over it, and the transmissive draws follow in sorted order. So glass refracts the opaque scene, not the translucent surfaces in front of it.

Meshes, models, textures, environments, render textures, shaders and fonts all hold GPU memory. The renderer owns everything it creates and releases it when the game closes, including when setup or drawing fails. Use Destroy to release a resource earlier, such as when unloading a level. Call it from Init, Update, Draw or Shutdown on the same goroutine, never from another. Destroying inside a frame costs no wait: the object goes on that frame slot's retire list and is freed a couple of frames later, once the GPU has finished with it, so what was already queued still draws. A model's morph buffers and their descriptor sets follow the same lifetime, and calling Destroy again is harmless. Uploads inside a frame are the same shape: NewMesh, Mesh.Update, NewTexture, Texture.Write and NewEnvironment copy through a staging arena into the frame's own command buffer, and what a frame uploads is what that frame draws. Outside a frame, in Init or between frames, the same calls go into one batch that the renderer submits ahead of the next frame, so loading a thousand meshes and textures waits for the GPU no more than loading one.