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.